From 845d6c6ee616911500df62542aedd742ceccb2e0 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Thu, 8 Oct 2026 19:03:17 +0200 Subject: [PATCH] SaraTag, a Flet GUI for tagging files. --- .gitignore | 10 + .saratag/Core.py | 988 ++++++++++++++++++++ .saratag/__init__.py | 1 + Books.py | 237 +++++ BrowsePage.py | 795 ++++++++++++++++ Core.py | 308 ++++++ History.py | 132 +++ Lexicons.py | 373 ++++++++ LexiconsPage.py | 476 ++++++++++ LibrariesPage.py | 190 ++++ Library.py | 120 +++ Main.py | 109 +++ Packaging/Launch.sh | 6 + Query.py | 221 +++++ README.md | 69 ++ Records.py | 426 +++++++++ RecordsPage.py | 388 ++++++++ SaraTag.py | 10 + SaraTagConf.toml | 39 + Settings.py | 286 ++++++ TagEditor.py | 236 +++++ UpdatePage.py | 153 +++ Updater.py | 218 +++++ Widgets.py | 241 +++++ assets/Icon.png | Bin 0 -> 832 bytes assets/fonts/Fixedsys_Excelsior_Regular.ttf | Bin 0 -> 580724 bytes requirements.txt | 3 + 27 files changed, 6035 insertions(+) create mode 100644 .gitignore create mode 100755 .saratag/Core.py create mode 100755 .saratag/__init__.py create mode 100644 Books.py create mode 100644 BrowsePage.py create mode 100644 Core.py create mode 100644 History.py create mode 100644 Lexicons.py create mode 100644 LexiconsPage.py create mode 100644 LibrariesPage.py create mode 100644 Library.py create mode 100644 Main.py create mode 100755 Packaging/Launch.sh create mode 100644 Query.py create mode 100644 README.md create mode 100644 Records.py create mode 100644 RecordsPage.py create mode 100755 SaraTag.py create mode 100755 SaraTagConf.toml create mode 100644 Settings.py create mode 100644 TagEditor.py create mode 100644 UpdatePage.py create mode 100644 Updater.py create mode 100644 Widgets.py create mode 100644 assets/Icon.png create mode 100644 assets/fonts/Fixedsys_Excelsior_Regular.ttf create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6dbdccb --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +*.pyc +.directory +Lexicons/ +build/ +.flet/ +dist/ +.SaraTagRecords.toml +.SaraTagHistory.toml diff --git a/.saratag/Core.py b/.saratag/Core.py new file mode 100755 index 0000000..9c0b017 --- /dev/null +++ b/.saratag/Core.py @@ -0,0 +1,988 @@ +from __future__ import annotations + +import copy +import sys +import tomllib +from pathlib import Path +from typing import Any + + +ApplicationName = "SaraTag" +ApplicationVersion = "0.1.0-beta" + + +def GetApplicationDirectory() -> Path: + """Return the portable directory containing SaraTag.""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + + return Path(__file__).resolve().parent.parent + + +ApplicationDirectory = GetApplicationDirectory() +SaraTagConfPath = ApplicationDirectory / "SaraTagConf.toml" +SaraTagRecordsPath = ApplicationDirectory / ".SaraTagRecords.toml" +SaraTagHistoryPath = ApplicationDirectory / ".SaraTagHistory.toml" + + +DefaultSaraTagConf = f'''# SaraTag's main portable configuration file. +# Paths in this file are interpreted relative to the SaraTag directory +# unless they are absolute. + +[SaraTag] +ConfigVersion = 1 +ApplicationVersion = "{ApplicationVersion}" + +[Files] +RecordsFile = ".SaraTagRecords.toml" +BookSuffix = ".book.toml" + +[Lexicon] +AutoInheritance = true +RelativeSourcePaths = true +LexiconFilePattern = "{{Source}}_{{DirectoryName}}.toml" + +[Explorer] +HideUntaggedDirectories = true +HideTagFiles = true +ShowInvalidEntries = true +ShowImagePreviews = true + +[Media] +VideoPlayer = "mpv" + +[History] +Enabled = true +MaxFileHistory = 30 +MaxDirectoryHistory = 20 +MaxUpdateHistory = 20 + +RememberFilter = true +RememberSorting = true +RememberLastDirectory = true +RememberLastSelectedFile = true +RememberRecentFiles = true +RememberUpdaterMode = true +RememberUpdates = true +''' + +DefaultSaraTagRecords = '''# Records registered with SaraTag. +# Add records through SaraTag rather than editing this file when possible. + +ConfigVersion = 1 +Records = [] +''' + +DefaultSaraTagHistory = """# SaraTag session history. +# This file is managed automatically. + +ConfigVersion = 1 +""" + + +LexiconFilePattern = "{Source}_{DirectoryName}.toml" +BookSuffix = ".book.toml" + + +def GetLexiconConfig() -> dict[str, Any]: + try: + Data = LoadSaraTagConf() + except (OSError, ValueError): + return {} + Value = Data.get("Lexicon", {}) + return Value if isinstance(Value, dict) else {} + + +def GetLexiconFilePattern() -> str: + Config = GetLexiconConfig() + Value = Config.get("LexiconFilePattern") + if isinstance(Value, str) and Value.strip(): + return Value.strip() + + try: + Data = LoadSaraTagConf() + except (OSError, ValueError): + return LexiconFilePattern + + Files = Data.get("Files", {}) + if isinstance(Files, dict): + Legacy = Files.get("LexiconFilePattern") + if isinstance(Legacy, str) and Legacy.strip(): + return Legacy.strip() + + return LexiconFilePattern + + +def GetLexiconSourceName( + DirectoryPath: Path, + SourcePath: Path | None = None, +) -> str: + if SourcePath is not None: + Stem = SourcePath.stem + if Stem.startswith("._Lexicon."): + Stem = Stem[len("._Lexicon."):] + return Stem or DirectoryPath.parent.name or DirectoryPath.name + + return DirectoryPath.parent.name or DirectoryPath.name + + +def FormatLexiconFileName( + DirectoryPath: Path, + SourcePath: Path | None = None, +) -> str: + Values = { + "DirectoryName": DirectoryPath.name, + "Source": GetLexiconSourceName(DirectoryPath, SourcePath), + } + try: + Name = GetLexiconFilePattern().format(**Values) + except (KeyError, ValueError): + Name = LexiconFilePattern.format(**Values) + return Path(Name).name or f"{DirectoryPath.name}.toml" + + +def GetPreferredLexiconPath( + DirectoryPath: Path, + SourcePath: Path | None = None, +) -> Path: + return DirectoryPath / FormatLexiconFileName( + DirectoryPath, + SourcePath, + ) + + +def GetGenericLegacyLexiconPath( + DirectoryPath: Path, +) -> Path: + """Return the short-lived generic Lexicon path.""" + return DirectoryPath / "._Lexicon.toml" + + +def GetLegacyLexiconPath(DirectoryPath: Path) -> Path: + """Return the original directory-tag path.""" + return DirectoryPath / f".{DirectoryPath.name}.tag.toml" + + +def GetLexiconPath(DirectoryPath: Path) -> Path: + """Return an existing Lexicon path, preferring current naming.""" + Candidates = ( + GetPreferredLexiconPath(DirectoryPath), + GetGenericLegacyLexiconPath(DirectoryPath), + GetLegacyLexiconPath(DirectoryPath), + ) + + for Candidate in Candidates: + if Candidate.is_file(): + return Candidate + + return Candidates[0] + + +def MigrateLexiconPath(DirectoryPath: Path) -> Path: + """Rename a legacy Lexicon to its canonical filename.""" + PreferredPath = GetPreferredLexiconPath(DirectoryPath) + + if PreferredPath.is_file(): + return PreferredPath + + for LegacyPath in ( + GetGenericLegacyLexiconPath(DirectoryPath), + GetLegacyLexiconPath(DirectoryPath), + ): + if not LegacyPath.is_file(): + continue + + LegacyPath.replace(PreferredPath) + return PreferredPath + + return PreferredPath + + +def FindParentLexiconPath(DirectoryPath: Path) -> Path | None: + Current = DirectoryPath.expanduser().resolve().parent + while True: + Candidate = GetLexiconPath(Current) + if Candidate.is_file(): + return Candidate + Parent = Current.parent + if Parent == Current: + return None + Current = Parent + +def ResolveLexiconSourcePath( + LexiconPath: Path, + SourceValue: str, +) -> Path: + SourcePath = Path(SourceValue).expanduser() + if not SourcePath.is_absolute(): + SourcePath = LexiconPath.parent / SourcePath + return SourcePath.resolve() + + +def MergeLexiconValues( + SourceValue: Any, + LocalValue: Any, +) -> Any: + """Merge inherited and local values for a SubLexicon override.""" + if isinstance(SourceValue, dict) and isinstance(LocalValue, dict): + Result = copy.deepcopy(SourceValue) + + for Key, Value in LocalValue.items(): + if Key in Result: + Result[Key] = MergeLexiconValues( + Result[Key], + Value, + ) + else: + Result[Key] = copy.deepcopy(Value) + + return Result + + return copy.deepcopy(LocalValue) + + +def MergeTagDefinition( + SourceDefinition: dict[str, Any], + LocalDefinition: dict[str, Any], +) -> dict[str, Any]: + """Apply local properties over an inherited tag definition.""" + return MergeLexiconValues( + SourceDefinition, + LocalDefinition, + ) + + + +def ResolveLexiconData( + LexiconPath: Path, + SeenPaths: set[Path] | None = None, +) -> dict[str, Any]: + """Return a Lexicon with its inherited tags resolved.""" + ResolvedPath = LexiconPath.expanduser().resolve() + + if SeenPaths is None: + SeenPaths = set() + + if ResolvedPath in SeenPaths: + raise ValueError( + f"Circular Lexicon inheritance detected at {ResolvedPath}." + ) + + if not ResolvedPath.is_file(): + raise OSError(f"Lexicon not found: {ResolvedPath}") + + NextSeen = set(SeenPaths) + NextSeen.add(ResolvedPath) + Data = LoadToml(ResolvedPath) + + Options = Data.get("Lexicon", {}) + if not isinstance(Options, dict): + raise ValueError( + f"{ResolvedPath.name} has no valid Lexicon table." + ) + + LocalTags = Data.get("Tags", {}) + if LocalTags is None: + LocalTags = {} + if not isinstance(LocalTags, dict): + raise ValueError( + f"{ResolvedPath.name} has no valid Tags table." + ) + + Inherit = Options.get("Inherit", False) + if not isinstance(Inherit, bool): + raise ValueError( + f"{ResolvedPath.name}: Lexicon.Inherit must be Boolean." + ) + + RawExcludeTags = Options.get("ExcludeTags", []) + if RawExcludeTags is None: + RawExcludeTags = [] + + if not isinstance(RawExcludeTags, list): + raise ValueError( + f"{ResolvedPath.name}: Lexicon.ExcludeTags must be a list." + ) + + ExcludeTags: list[str] = [] + SeenExcludedTags: set[str] = set() + + for Item in RawExcludeTags: + if not isinstance(Item, str) or not Item.strip(): + raise ValueError( + f"{ResolvedPath.name}: every ExcludeTags item " + "must be a non-empty string." + ) + + TagName = Item.strip() + + if TagName in SeenExcludedTags: + continue + + SeenExcludedTags.add(TagName) + ExcludeTags.append(TagName) + + EffectiveTags: dict[str, Any] = {} + + if Inherit: + SourceValue = Options.get("Source") + if not isinstance(SourceValue, str) or not SourceValue.strip(): + raise ValueError( + f"{ResolvedPath.name}: Inherit is true but Source is missing." + ) + + SourcePath = ResolveLexiconSourcePath( + ResolvedPath, + SourceValue.strip(), + ) + SourceData = ResolveLexiconData(SourcePath, NextSeen) + SourceTags = SourceData.get("Tags", {}) + EffectiveTags = { + TagName: copy.deepcopy(Definition) + for TagName, Definition in SourceTags.items() + if isinstance(TagName, str) + and isinstance(Definition, dict) + } + + for TagName in ExcludeTags: + EffectiveTags.pop(TagName, None) + + for TagName, Definition in LocalTags.items(): + if not isinstance(TagName, str): + continue + + if not isinstance(Definition, dict): + raise ValueError( + f"{ResolvedPath.name}: Tags.{TagName} must be a table." + ) + + if TagName in EffectiveTags: + EffectiveTags[TagName] = MergeTagDefinition( + EffectiveTags[TagName], + Definition, + ) + else: + EffectiveTags[TagName] = copy.deepcopy(Definition) + + Result = dict(Data) + Result["Tags"] = EffectiveTags + return Result + + +def ResolveLexiconTags( + LexiconPath: Path, +) -> dict[str, dict[str, Any]]: + Data = ResolveLexiconData(LexiconPath) + Tags = Data.get("Tags", {}) + return { + TagName: copy.deepcopy(Definition) + for TagName, Definition in Tags.items() + if isinstance(TagName, str) + and isinstance(Definition, dict) + } + + +def ResolveRecordPath( + Record: dict[str, Any], + ApplicationBase: Path | None = None, +) -> Path: + """Resolve a Record's collection directory from Base and Path.""" + if ApplicationBase is None: + ApplicationBase = ApplicationDirectory + + RawBase = Record.get("Base", str(ApplicationBase)) + RawPath = Record.get("Path") + + if not isinstance(RawBase, str) or not RawBase.strip(): + RawBase = str(ApplicationBase) + + if not isinstance(RawPath, str) or not RawPath.strip(): + raise ValueError("Record has no valid Path.") + + BasePath = Path(RawBase).expanduser() + if not BasePath.is_absolute(): + BasePath = ApplicationBase / BasePath + + PathValue = Path(RawPath).expanduser() + if PathValue.is_absolute(): + return PathValue.resolve() + + return (BasePath / PathValue).resolve() + + +def ResolveRecordLexiconPath( + Record: dict[str, Any], + ApplicationBase: Path | None = None, +) -> Path: + """Resolve a Record's Lexicon, falling back to the collection.""" + RecordPath = ResolveRecordPath(Record, ApplicationBase) + RawLexicon = Record.get("Lexicon") + + if not isinstance(RawLexicon, str) or not RawLexicon.strip(): + return GetLexiconPath(RecordPath) + + RawBase = Record.get("Base", str(ApplicationBase or ApplicationDirectory)) + BasePath = Path(str(RawBase)).expanduser() + if not BasePath.is_absolute(): + BasePath = (ApplicationBase or ApplicationDirectory) / BasePath + + LexiconPath = Path(RawLexicon).expanduser() + if LexiconPath.is_absolute(): + return LexiconPath.resolve() + + return (BasePath / LexiconPath).resolve() + + +def ParseEnabledRecords( + Data: dict[str, Any], + ApplicationBase: Path | None = None, +) -> tuple[list[tuple[dict[str, Any], Path, Path]], list[str]]: + """Resolve enabled Record tables.""" + RawRecords = Data.get("Record") + if RawRecords is None: + RawRecords = Data.get("Records", []) + + Errors: list[str] = [] + Results: list[tuple[dict[str, Any], Path, Path]] = [] + + if not isinstance(RawRecords, list): + return [], [".SaraTagRecords.toml has no valid Record list."] + + for Index, Record in enumerate(RawRecords): + if not isinstance(Record, dict): + Errors.append(f"Record entry {Index + 1} is not a table.") + continue + + if not Record.get("Enabled", True): + continue + + try: + DirectoryPath = ResolveRecordPath(Record, ApplicationBase) + LexiconPath = ResolveRecordLexiconPath(Record, ApplicationBase) + except ValueError as Error: + Errors.append(f"Record entry {Index + 1}: {Error}") + continue + + Results.append((Record, DirectoryPath, LexiconPath)) + + return Results, Errors + + + +def FindRecordForDirectory( + DirectoryPath: Path, + RecordsData: dict[str, Any] | None = None, + ApplicationBase: Path | None = None, +) -> tuple[dict[str, Any], Path, Path] | None: + if RecordsData is None: + try: + RecordsData = LoadSaraTagRecords() + except (OSError, ValueError): + return None + + Parsed, _ = ParseEnabledRecords(RecordsData, ApplicationBase) + Target = DirectoryPath.expanduser().resolve() + for Record, RecordPath, LexiconPath in Parsed: + if RecordPath == Target: + return Record, RecordPath, LexiconPath + return None + + +def ResolveLexiconForDirectory( + DirectoryPath: Path, + RecordsData: dict[str, Any] | None = None, + ApplicationBase: Path | None = None, +) -> Path: + Record = FindRecordForDirectory( + DirectoryPath, + RecordsData, + ApplicationBase, + ) + return Record[2] if Record is not None else GetLexiconPath(DirectoryPath) + + +def ResolveBookLexiconPath( + BookDirectory: Path, + ParentValue: str, + RecordsData: dict[str, Any] | None = None, + ApplicationBase: Path | None = None, +) -> Path: + ParentPath = Path(ParentValue).expanduser() + + if ParentPath.suffix == ".toml": + Candidate = ( + ParentPath + if ParentPath.is_absolute() + else BookDirectory / ParentPath + ).resolve() + if Candidate.is_file(): + return Candidate + ParentDirectory = BookDirectory.resolve() + else: + ParentDirectory = ( + ParentPath + if ParentPath.is_absolute() + else BookDirectory / ParentPath + ).resolve() + + return ResolveLexiconForDirectory( + ParentDirectory, + RecordsData, + ApplicationBase, + ) + + +def GetBookParentValue(DirectoryPath: Path) -> str: + return str(DirectoryPath.expanduser().resolve()) + + + +def GetPreferredBookPath(FilePath: Path) -> Path: + return FilePath.parent / f".{FilePath.name}{BookSuffix}" + +def GetLegacyBookPath(FilePath: Path) -> Path: + return FilePath.parent / f".{FilePath.name}.tag.toml" + +def GetBookPath(FilePath: Path) -> Path: + Preferred = GetPreferredBookPath(FilePath) + if Preferred.is_file(): + return Preferred + Legacy = GetLegacyBookPath(FilePath) + if Legacy.is_file(): + return Legacy + return Preferred + +def IsBookPath(PathValue: Path) -> bool: + if not PathValue.is_file() or not PathValue.name.startswith('.'): + return False + if ( + PathValue.name.startswith("._Lexicon.") + and PathValue.name.endswith(".toml") + ) or PathValue.name == "._Lexicon.toml": + return False + return PathValue.name.endswith(BookSuffix) or PathValue.name.endswith('.tag.toml') + +def NormaliseTagDefinition(Definition: dict[str, Any]) -> dict[str, Any]: + Result = dict(Definition) + if 'Required' not in Result and 'Force' in Result: + Result['Required'] = Result.pop('Force') + else: + Result.pop('Force', None) + Result.setdefault('Required', False) + Result.setdefault('ForceDefault', False) + return Result + +def CreateDirectoryTagFiles() -> None: + for Record, RootPath, RecordLexicon in ParseEnabledRecords(LoadSaraTagRecords())[0]: + + if not RootPath.is_dir(): + print(f"Missing root: {RootPath}") + continue + + for Directory in RootPath.rglob("*"): + if not Directory.is_dir(): + continue + + TagFile = GetPreferredLexiconPath(Directory) + + if TagFile.exists(): + continue + + TagFile.write_text( + f"""# SaraTag Lexicon + +[Lexicon] +ConfigVersion = 1 +Inherit = false +Name = "{Directory.name}" +""", + encoding="utf-8", + ) + + print(f"Created {TagFile}") + + # Also create one for the Record itself. + RootTag = RecordLexicon + + if not RootTag.exists(): + RootTag.write_text( + f"""# SaraTag Lexicon + +[Lexicon] +ConfigVersion = 1 +Inherit = false +Name = "{RootPath.name}" +""", + encoding="utf-8", + ) + + print(f"Created {RootTag}") + +def FormatTomlValue(Value: Any) -> str: + """Convert a simple Python value into TOML syntax.""" + if isinstance(Value, bool): + return "true" if Value else "false" + + if isinstance(Value, str): + EscapedValue = Value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{EscapedValue}"' + + if isinstance(Value, (int, float)): + return str(Value) + + if isinstance(Value, list): + FormattedValues = ", ".join( + FormatTomlValue(Item) for Item in Value + ) + return f"[{FormattedValues}]" + + raise TypeError( + f"Unsupported TOML value type: {type(Value).__name__}" + ) + + + +def FormatRating( + Value: Any, + Definition: dict[str, Any], +) -> str: + """Render a Rating value while keeping its stored form numeric.""" + if isinstance(Value, bool) or not isinstance(Value, int): + return str(Value) + + FilledSymbol = Definition.get("FilledSymbol", "★") + EmptySymbol = Definition.get("EmptySymbol", "") + + if not isinstance(FilledSymbol, str) or not FilledSymbol: + FilledSymbol = "★" + + if not isinstance(EmptySymbol, str): + EmptySymbol = "" + + FilledCount = max(0, Value) + Rendered = FilledSymbol * FilledCount + + Maximum = Definition.get( + "Maximum", + Definition.get("Max"), + ) + + # Without a declared maximum, rendering empty symbols would falsely + # imply that the Rating has a fixed ceiling. + if ( + isinstance(Maximum, int) + and not isinstance(Maximum, bool) + and Maximum >= FilledCount + and EmptySymbol + ): + Rendered += EmptySymbol * (Maximum - FilledCount) + + return Rendered + + +def GetInitialTagValue(TagDefinition: dict[str, Any]) -> Any: + Definition = NormaliseTagDefinition(TagDefinition) + if 'Default' in Definition: + return Definition['Default'] + TagType = str(Definition.get('Type', '')).strip().casefold() + Required = bool(Definition.get('Required', False)) + if TagType == 'boolean': + return False + if TagType == 'number': + return Definition.get('Minimum', '?') if Required else '?' + if TagType == 'rating': + if Required: + Minimum = Definition.get('Minimum', 0) + if isinstance(Minimum, int) and not isinstance(Minimum, bool) and Minimum >= 0: + return Minimum + return 0 + return '?' + if TagType == 'list': + Values = Definition.get('Values', []) + if Required and isinstance(Values, list) and Values: + return Values[0] + return '?' + return '?' + +def CreateFileTagFile( + FilePath: Path, + DirectoryTagPath: Path, + DirectorySchema: dict[str, Any], +) -> bool: + """Create a metadata file for one ordinary file.""" + FileTagPath = GetPreferredBookPath(FilePath) + + if FileTagPath.exists(): + return False + + Tags = DirectorySchema.get("Tags", {}) + + Lines = [ + "# SaraTag Book", + "", + f"Parent = {FormatTomlValue(GetBookParentValue(FilePath.parent))}", + "", + ] + + for TagName, TagDefinition in Tags.items(): + InitialValue = GetInitialTagValue(TagDefinition) + Lines.append( + f"{TagName} = {FormatTomlValue(InitialValue)}" + ) + + FileTagPath.write_text( + "\n".join(Lines) + "\n", + encoding="utf-8", + ) + + print(f"Created file tag: {FileTagPath}") + return True + +def CreateFileTagFilesForDirectory( + DirectoryPath: Path, + LexiconPathOverride: Path | None = None, +) -> int: + """Create Books for all non-hidden files in one directory.""" + DirectoryTagPath = ( + LexiconPathOverride + if LexiconPathOverride is not None + else ResolveLexiconForDirectory(DirectoryPath) + ) + + if not DirectoryTagPath.is_file(): + print(f"No directory tag config: {DirectoryTagPath}") + return 0 + + try: + DirectorySchema = ResolveLexiconData(DirectoryTagPath) + except (OSError, ValueError) as Error: + print( + f"Could not load directory schema " + f"{DirectoryTagPath}: {Error}", + file=sys.stderr, + ) + return 0 + + CreatedCount = 0 + + for FilePath in DirectoryPath.iterdir(): + if not FilePath.is_file(): + continue + + # Ignore all dotfiles, including SaraTag metadata files. + if FilePath.name.startswith("."): + continue + + if CreateFileTagFile( + FilePath, + DirectoryTagPath, + DirectorySchema, + ): + CreatedCount += 1 + + return CreatedCount + +def CreateFileTagFiles(SaraTagRecords: dict[str, Any]) -> int: + """Create file metadata throughout all enabled roots.""" + CreatedCount = 0 + + for Record, RootPath, RecordLexicon in ParseEnabledRecords(SaraTagRecords)[0]: + + if not RootPath.is_dir(): + print(f"Missing root: {RootPath}") + continue + + Directories = [RootPath] + + Directories.extend( + Path(DirectoryPath) + for DirectoryPath, DirectoryNames, FileNames + in RootPath.walk() + if Path(DirectoryPath) != RootPath + ) + + for DirectoryPath in Directories: + CreatedCount += CreateFileTagFilesForDirectory( + DirectoryPath, + LexiconPathOverride=( + RecordLexicon + if DirectoryPath.resolve() == RootPath.resolve() + else None + ), + ) + + return CreatedCount + +def EnsureTextFile(FilePath: Path, DefaultContent: str) -> bool: + """Create a configuration file when missing. + + Returns True when a new file was created. + """ + if FilePath.exists(): + return False + + FilePath.write_text(DefaultContent, encoding="utf-8") + return True + + +def EnsureConfigFiles() -> list[Path]: + """Ensure SaraTag's portable configuration files exist.""" + CreatedFiles: list[Path] = [] + + if EnsureTextFile(SaraTagConfPath, DefaultSaraTagConf): + CreatedFiles.append(SaraTagConfPath) + + if EnsureTextFile(SaraTagRecordsPath, DefaultSaraTagRecords): + CreatedFiles.append(SaraTagRecordsPath) + + if EnsureTextFile(SaraTagHistoryPath, DefaultSaraTagHistory): + CreatedFiles.append(SaraTagHistoryPath) + + return CreatedFiles + + +def LoadToml(FilePath: Path) -> dict[str, Any]: + """Load a TOML file and provide a useful error on invalid syntax.""" + try: + with FilePath.open("rb") as TomlFile: + return tomllib.load(TomlFile) + except tomllib.TOMLDecodeError as Error: + raise ValueError(f"Invalid TOML in {FilePath.name}: {Error}") from Error + except OSError as Error: + raise OSError(f"Could not read {FilePath}: {Error}") from Error + + + +def SetExplorerShowImagePreviews(Enabled: bool) -> None: + """Persist Explorer.ShowImagePreviews in SaraTagConf.toml.""" + try: + Lines = SaraTagConfPath.read_text( + encoding="utf-8", + ).splitlines() + except OSError as Error: + raise OSError( + f"Could not read {SaraTagConfPath}: {Error}" + ) from Error + + InExplorerSection = False + SettingFound = False + ExplorerSectionFound = False + UpdatedLines: list[str] = [] + + for Line in Lines: + Stripped = Line.strip() + + if Stripped.startswith("[") and Stripped.endswith("]"): + if InExplorerSection and not SettingFound: + UpdatedLines.append( + "ShowImagePreviews = " + + ("true" if Enabled else "false") + ) + SettingFound = True + + InExplorerSection = Stripped == "[Explorer]" + ExplorerSectionFound = ( + ExplorerSectionFound or InExplorerSection + ) + UpdatedLines.append(Line) + continue + + if ( + InExplorerSection + and Stripped.startswith("ShowImagePreviews") + and "=" in Stripped + ): + UpdatedLines.append( + "ShowImagePreviews = " + + ("true" if Enabled else "false") + ) + SettingFound = True + continue + + UpdatedLines.append(Line) + + if InExplorerSection and not SettingFound: + UpdatedLines.append( + "ShowImagePreviews = " + + ("true" if Enabled else "false") + ) + SettingFound = True + + if not ExplorerSectionFound: + if UpdatedLines and UpdatedLines[-1] != "": + UpdatedLines.append("") + + UpdatedLines.extend( + [ + "[Explorer]", + ( + "ShowImagePreviews = " + + ("true" if Enabled else "false") + ), + ] + ) + + UpdatedText = "\n".join(UpdatedLines).rstrip() + "\n" + TemporaryPath = SaraTagConfPath.with_name( + f"{SaraTagConfPath.name}.tmp" + ) + + try: + TemporaryPath.write_text( + UpdatedText, + encoding="utf-8", + ) + TemporaryPath.replace(SaraTagConfPath) + except OSError as Error: + raise OSError( + f"Could not save {SaraTagConfPath}: {Error}" + ) from Error + finally: + try: + if TemporaryPath.exists(): + TemporaryPath.unlink() + except OSError: + pass + + +def LoadSaraTagConf() -> dict[str, Any]: + return LoadToml(SaraTagConfPath) + + +def LoadSaraTagRecords() -> dict[str, Any]: + return LoadToml(SaraTagRecordsPath) + + +def Main() -> int: + try: + CreatedFiles = EnsureConfigFiles() + SaraTagConf = LoadSaraTagConf() + SaraTagRecords = LoadSaraTagRecords() + CreateDirectoryTagFiles() + CreatedFileTagCount = CreateFileTagFiles(SaraTagRecords) + print(f"Created file tags: {CreatedFileTagCount}") + except (OSError, ValueError) as Error: + print(f"{ApplicationName}: {Error}", file=sys.stderr) + return 1 + + print(f"{ApplicationName} {ApplicationVersion}") + print(f"Application directory: {ApplicationDirectory}") + + for CreatedFile in CreatedFiles: + print(f"Created: {CreatedFile.name}") + + RootCount = len(SaraTagRecords.get("Record", SaraTagRecords.get("Records", []))) + print(f"Registered records: {RootCount}") + + ConfigVersion = SaraTagConf.get("SaraTag", {}).get("ConfigVersion", "unknown") + print(f"Config version: {ConfigVersion}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(Main()) + + +# Compatibility aliases for older plugins/modules. +LoadSaraTagRoots = LoadSaraTagRecords +SaraTagRootsPath = SaraTagRecordsPath diff --git a/.saratag/__init__.py b/.saratag/__init__.py new file mode 100755 index 0000000..6277e34 --- /dev/null +++ b/.saratag/__init__.py @@ -0,0 +1 @@ +"""Internal SaraTag implementation modules.""" diff --git a/Books.py b/Books.py new file mode 100644 index 0000000..83d4759 --- /dev/null +++ b/Books.py @@ -0,0 +1,237 @@ +"""Books: the per-file sidecars (..book.toml) holding a file's tag +values, plus the rules for what a valid value is. Written in the same layout +as before (a "# SaraTag Book" header, Parent first), so SaraFem and SaraView +read them unchanged.""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from Library import Bound, DataError, ReadToml, TagCore, TagType, TomlValue, Unset, WriteAtomically + +Mixed = object() # several files selected, with different values for a tag + + +def BookPathFor(FilePath: Path) -> Path: + return TagCore.GetBookPath(FilePath) + + +def HasBook(FilePath: Path) -> bool: + return TagCore.GetBookPath(FilePath).is_file() + + +def IsBook(EntryPath: Path) -> bool: + return TagCore.IsBookPath(EntryPath) + + +def FileForBook(BookPath: Path) -> Path | None: + """The ordinary file a Book belongs to (it may no longer exist).""" + Name = BookPath.name + for Suffix in (TagCore.BookSuffix, ".tag.toml"): + if Name.startswith(".") and Name.endswith(Suffix) and len(Name) > len(Suffix) + 1: + return BookPath.with_name(Name[1:-len(Suffix)]) + return None + + +def SerializeBook(Metadata: dict[str, Any]) -> str: + Lines = ["# SaraTag Book", ""] + if "Parent" in Metadata: + Lines += [f"Parent = {TomlValue(Metadata['Parent'])}", ""] + Lines += [f"{Key} = {TomlValue(Value)}" for Key, Value in Metadata.items() if Key != "Parent"] + return "\n".join(Lines).rstrip() + "\n" + + +def WriteBook(BookPath: Path, Metadata: dict[str, Any]) -> None: + try: + WriteAtomically(BookPath, SerializeBook(Metadata)) + except OSError as Error: + raise DataError(f"Couldn't save {BookPath.name}: {Error}") from Error + + +def Normalise(Definition: dict[str, Any]) -> dict[str, Any]: + return TagCore.NormaliseTagDefinition(Definition) + + +def InitialValue(Definition: dict[str, Any]) -> Any: + return TagCore.GetInitialTagValue(Definition) + + +def ValidateValue(TagName: str, Value: Any, Definition: dict[str, Any]) -> str | None: + """An error message when Value breaks its definition, else None.""" + Definition = Normalise(Definition) + if Value == Unset: + return f"{TagName} is Required." if Definition.get("Required") else None + Kind = TagType(Definition) + Minimum, Maximum = Bound(Definition, "Minimum"), Bound(Definition, "Maximum") + if Kind == "Boolean": + return None if isinstance(Value, bool) else f"{TagName} must be Yes or No." + if Kind == "Text": + return None if isinstance(Value, str) else f"{TagName} must be Text." + if Kind == "List": + Values = Definition.get("Values", []) + if not isinstance(Values, list): + return f"{TagName} has an invalid Values list." + return None if Value in Values else f"{TagName}: {Value!r} isn't one of its Values." + if Kind in ("Number", "Rating"): + if Kind == "Rating": + if isinstance(Value, bool) or not isinstance(Value, int): + return f"{TagName} must be a whole number." + Minimum = Minimum if isinstance(Minimum, int) and Minimum >= 0 else 0 + elif isinstance(Value, bool) or not isinstance(Value, (int, float)): + return f"{TagName} must be a number." + if Minimum is not None and Value < Minimum: + return f"{TagName} is below its Minimum ({Value} < {Minimum})." + if Maximum is not None and Value > Maximum: + return f"{TagName} is above its Maximum ({Value} > {Maximum})." + return None + return f"{TagName} has an unknown Type {Definition.get('Type')!r}." + + +def ParseNumber(Text: str) -> int | float: + """"3" -> 3, "2.5" -> 2.5; raises ValueError otherwise.""" + Text = Text.strip() + return float(Text) if any(Mark in Text for Mark in ".eE") else int(Text) + + +@dataclass +class Book: + FilePath: Path + BookPath: Path + Metadata: dict[str, Any] + Definitions: dict[str, dict[str, Any]] + LexiconPath: Path | None + Error: str = "" # why Definitions is empty, if it is + + @property + def Tags(self) -> dict[str, Any]: + return {Key: Value for Key, Value in self.Metadata.items() if Key != "Parent"} + + +def _ResolveDefinitions(Directory: Path, Parent: str) -> tuple[Path | None, dict[str, dict[str, Any]], str]: + """(Lexicon path, effective definitions, error) for Books in Directory with this Parent.""" + try: + LexiconPath = TagCore.ResolveBookLexiconPath(Directory, Parent) + except (OSError, ValueError) as Error: + return None, {}, f"Couldn't find its Lexicon: {Error}" + if not LexiconPath.is_file(): + return LexiconPath, {}, f"Its Lexicon is missing: {LexiconPath}" + try: + Definitions = {Name: Normalise(Definition) for Name, Definition in TagCore.ResolveLexiconTags(LexiconPath).items()} + except (OSError, ValueError) as Error: + return LexiconPath, {}, f"Couldn't read its Lexicon: {Error}" + return LexiconPath, Definitions, "" + + +def ReadBook(FilePath: Path, Cache: dict | None = None) -> Book | None: + """The file's Book with its Lexicon's effective definitions, or None if it + has no Book. Raises DataError if the Book itself can't be read. Pass the + same Cache dict for many files: their Lexicon is then resolved once.""" + BookPath = BookPathFor(FilePath) + if not BookPath.is_file(): + return None + Metadata = ReadToml(BookPath) + TheBook = Book(FilePath, BookPath, Metadata, {}, None) + Parent = Metadata.get("Parent") + if not isinstance(Parent, str) or not Parent.strip(): + TheBook.Error = "This Book has no Parent, so its Lexicon is unknown. Repair Book parents on the Update page." + return TheBook + Key = (FilePath.parent, Parent) + if Cache is None or Key not in Cache: + Resolved = _ResolveDefinitions(FilePath.parent, Parent) + if Cache is None: + Cache = {} + Cache[Key] = Resolved + TheBook.LexiconPath, TheBook.Definitions, TheBook.Error = Cache[Key] + return TheBook + + +@dataclass +class Selection: + """What the tag editor edits: one or more Books. With several, only tags + every Book defines the same way are shown, and differing values are Mixed.""" + + Books: list[Book] + Definitions: dict[str, dict[str, Any]] = field(default_factory=dict) + Values: dict[str, Any] = field(default_factory=dict) + Extra: dict[str, Any] = field(default_factory=dict) # values no Lexicon defines (one Book only) + + +def BuildSelection(Books: list[Book]) -> Selection: + Result = Selection(Books) + if not Books: + return Result + Shared = dict(Books[0].Definitions) + for Other in Books[1:]: + Shared = {Name: Definition for Name, Definition in Shared.items() if Other.Definitions.get(Name) == Definition} + Result.Definitions = Shared + for Name in Shared: + Values = [TheBook.Metadata.get(Name, Unset) for TheBook in Books] + Result.Values[Name] = Values[0] if all(Value == Values[0] for Value in Values[1:]) else Mixed + if len(Books) == 1: + Result.Extra = {Key: Value for Key, Value in Books[0].Tags.items() if Key not in Shared} + return Result + + +def SetTagValue(Books: list[Book], TagName: str, Value: Any) -> int: + """Writes one tag's value into every Book. Returns how many changed.""" + Changed = 0 + for TheBook in Books: + Current = TheBook.Metadata.get(TagName, Mixed) + if Current is not Mixed and type(Current) is type(Value) and Current == Value: + continue + Metadata = dict(TheBook.Metadata) + Metadata[TagName] = Value + WriteBook(TheBook.BookPath, Metadata) + TheBook.Metadata = Metadata + Changed += 1 + return Changed + + +def CreateBook(FilePath: Path) -> Book: + """A new Book for a file in a directory that has a Lexicon, with every tag + at its initial value (what the Update page's "add missing" does).""" + LexiconPath = TagCore.ResolveLexiconForDirectory(FilePath.parent) + if not LexiconPath.is_file(): + raise DataError(f"{FilePath.parent.name} has no Lexicon; add one on the Records page first.") + try: + Definitions = TagCore.ResolveLexiconTags(LexiconPath) + except (OSError, ValueError) as Error: + raise DataError(f"Couldn't read the Lexicon: {Error}") from Error + Metadata: dict[str, Any] = {"Parent": TagCore.GetBookParentValue(FilePath.parent)} + Metadata.update({Name: InitialValue(Definition) for Name, Definition in Definitions.items()}) + WriteBook(TagCore.GetPreferredBookPath(FilePath), Metadata) + return ReadBook(FilePath) + + +def RenameFile(SourcePath: Path, NewName: str) -> Path: + """Renames a file and its Book together; undoes the file rename if the + Book can't follow. Returns the new path.""" + NewName = NewName.strip() + if not NewName or "/" in NewName or NewName in (".", ".."): + raise DataError("That isn't a valid file name.") + if NewName.startswith("."): + raise DataError("Names starting with a dot are hidden, and SaraTag skips hidden files.") + TargetPath = SourcePath.with_name(NewName) + if TargetPath == SourcePath: + return SourcePath + if TargetPath.exists(): + raise DataError(f"'{NewName}' already exists.") + SourceBook = BookPathFor(SourcePath) + TargetBook = TagCore.GetPreferredBookPath(TargetPath) + HasSourceBook = SourceBook.is_file() + if HasSourceBook and TargetBook.exists(): + raise DataError(f"A Book named '{TargetBook.name}' already exists.") + try: + SourcePath.rename(TargetPath) + except OSError as Error: + raise DataError(f"Couldn't rename: {Error}") from Error + if HasSourceBook: + try: + SourceBook.rename(TargetBook) + except OSError as Error: + try: + TargetPath.rename(SourcePath) + except OSError as RollbackError: + raise DataError(f"The Book couldn't follow ({Error}), and putting the file back failed too: {RollbackError}") from Error + raise DataError(f"Couldn't rename the Book, so the file kept its name: {Error}") from Error + return TargetPath diff --git a/BrowsePage.py b/BrowsePage.py new file mode 100644 index 0000000..1b3c187 --- /dev/null +++ b/BrowsePage.py @@ -0,0 +1,795 @@ +"""Browse: pick a Record or Library on the left, walk its files in the middle +(filter and sort them), and see the selected file's preview and edit its tags +on the right. Tick several files to edit their shared tags at once. + +Keys (while no text field has focus): Up/Down move, Enter opens, Space ticks, +F2 renames, Ctrl+A ticks all, Backspace goes up a directory, Escape clears +the ticks or leaves the fullscreen view, Left/Right step through it. +""" + +import time +from pathlib import Path +from typing import Optional + +import flet as ft + +from Books import Book, CreateBook, IsBook, ReadBook, RenameFile +from Core import ChipButton, DisplayPath, Note, Palette +from History import ExplorerState, RecordRecentFile, SaveExplorerState +from Library import ConfValue, DataError, TagCore +from Query import Entry, CriterionLabel, Matches, ParseFilter, SortEntries, SortNames +from Records import CreateDirectoryLexicon, DirectoryState, LexiconFor, ListLibraries, ListRecords, LibraryEntry, Record +from TagEditor import BookProblems, TagEditor, TagSummary +from Widgets import App, EmptyState, HumanSize, MenuItem, Plural, RightClickHost, TextInput + +RowHeight = 40 +SidebarWidth = 210 +DoubleClickSeconds = 0.4 +FilterHelp = ( + "Conditions separated by spaces; all must match.\n" + " *beach* name (wildcards * ? [..])\n" + " ext:png type:image|video|file|directory\n" + " size:>=5MB modified:>=2026-07-01 accessed: created:\n" + " Colour:Orange a tag (or tag.Colour:Orange)\n" + " Rating:>=2 Legs:!=4 Name:? record:Cats\n" + "Operators: = (default) != > >= < <=" +) + + +class BrowsePage(ft.Container): + def __init__(self, TheApp: App): + super().__init__(expand=True) + self.App = TheApp + self.Records: list[Record] = [] + self.Libraries: list[LibraryEntry] = [] + self.Source: Optional[tuple[str, object]] = None # ("Record", Record) / ("Library", LibraryEntry) + self.Directory: Optional[Path] = None + self.Entries: list[Entry] = [] + self.Visible: list[Entry] = [] + self.Focused: Optional[Path] = None + self.Checked: set[Path] = set() + self.Sorts: list[str] = ["Name"] + self.Descending = False + self.FilterError = "" + self.LastTap = (None, 0.0) + self.LoadGeneration = 0 + self.Fullscreen: Optional[Path] = None + self.ShowSidebar = True + self.RowControls: dict[Path, ft.Container] = {} + + self.SourceList = ft.ListView(spacing=0, expand=True) + self.Sidebar = ft.Container( + ft.Column([ft.Text("Collections", color=Palette.Overlay0, size=12), self.SourceList], spacing=6, expand=True), + width=SidebarWidth, bgcolor=Palette.Mantle, padding=ft.Padding.only(left=8, right=8, top=8), + border=ft.Border.only(right=ft.BorderSide(1, Palette.Surface1)), + ) + + self.Breadcrumbs = ft.Row(spacing=0, scroll=ft.ScrollMode.AUTO, expand=True) + self.UpButton = ft.IconButton(ft.Icons.ARROW_UPWARD, tooltip="Up a directory (Backspace)", on_click=lambda e: self.GoUp()) + self.FilterField = TextInput(TheApp, Hint="Filter… e.g. Rating:>=2 ext:png", Width=300, + Prefix=ft.Icons.FILTER_ALT_OUTLINED, OnChange=lambda e: self._FilterChanged()) + self.SortButton = ft.PopupMenuButton(icon=ft.Icons.SORT, tooltip="Sort", items=[]) + self.DirectionButton = ft.IconButton(ft.Icons.ARROW_DOWNWARD, tooltip="Ascending", on_click=lambda e: self._ToggleDirection()) + self.PreviewToggle = ft.IconButton(ft.Icons.VIEW_SIDEBAR_OUTLINED, tooltip="Show/hide the side panel", + on_click=lambda e: self._TogglePanel()) + Toolbar = ft.Container( + ft.Row([ + ft.IconButton(ft.Icons.MENU_OPEN, tooltip="Show/hide collections", on_click=lambda e: self._ToggleSidebar()), + self.UpButton, self.Breadcrumbs, self.FilterField, + ft.IconButton(ft.Icons.HELP_OUTLINE, icon_color=Palette.Overlay0, tooltip=FilterHelp), + self.SortButton, self.DirectionButton, + ft.IconButton(ft.Icons.REFRESH, tooltip="Reload", on_click=lambda e: self.Reload()), + self.PreviewToggle, + ], spacing=2, vertical_alignment=ft.CrossAxisAlignment.CENTER), + padding=ft.Padding.symmetric(horizontal=8, vertical=4), + border=ft.Border.only(bottom=ft.BorderSide(1, Palette.Surface1)), + ) + + self.ListArea = ft.ListView(expand=True, item_extent=RowHeight, spacing=0) + # The list never leaves the page: Flet drops the session when a reply + # (e.g. to scroll_to) arrives for a control that was swapped out. The + # spinner, empty states and fullscreen view show beside it instead. + self.Overlay = ft.Container(expand=True, visible=False) + self.ListHost = ft.Column([self.ListArea, self.Overlay], expand=True, spacing=0) + self.Status = ft.Text("", size=12, color=Palette.Overlay0, expand=True) + self.TickButtons = ft.Row([ + ft.TextButton("Tick all", on_click=lambda e: self.TickAll()), + ft.TextButton("Clear ticks", on_click=lambda e: self.ClearTicks()), + ], spacing=0) + StatusBar = ft.Container( + ft.Row([self.Status, self.TickButtons], vertical_alignment=ft.CrossAxisAlignment.CENTER), + padding=ft.Padding.symmetric(horizontal=12), height=34, + border=ft.Border.only(top=ft.BorderSide(1, Palette.Surface1)), bgcolor=Palette.Mantle, + ) + self.Menu = RightClickHost(self._MenuItems, ft.GestureDetector( + content=self.ListHost, expand=True, + on_secondary_tap_down=lambda e: self.Menu.Request(None, e), + )) + self.Middle = ft.Column([Toolbar, self.Menu, StatusBar], spacing=0, expand=True) + + self.PreviewBox = ft.Container(height=300, alignment=ft.Alignment.CENTER, padding=8) + self.Editor = TagEditor(TheApp, self._BooksChanged) + self.SidePanel = ft.Container( + ft.Column([ + self.PreviewBox, + ft.Divider(height=1, color=Palette.Surface1), + ft.Container(self.Editor, expand=True, padding=ft.Padding.only(left=12, right=12, top=8)), + ], spacing=0, expand=True), + width=TheApp.Config.PreviewWidth, bgcolor=Palette.Mantle, visible=TheApp.Config.ShowPreview, + border=ft.Border.only(left=ft.BorderSide(1, Palette.Surface1)), + ) + self.content = ft.Row([self.Sidebar, self.Middle, self.SidePanel], spacing=0, expand=True, + vertical_alignment=ft.CrossAxisAlignment.STRETCH) + + # Page lifecycle (Main calls Refresh on every visit) + + def Refresh(self, Record: str = "", Library: str = "", Directory: str = "", Select: str = "") -> None: + self.page.on_keyboard_event = self._OnKey + try: + self.Records = [Item for Item in ListRecords()[0] if Item.Enabled] + self.Libraries = [Item for Item in ListLibraries() if Item.Enabled] + except DataError as Error: + self.App.Notify(str(Error), "error") + self.Records, self.Libraries = [], [] + Wanted: Optional[tuple[str, object]] = None + if Record: + Wanted = next((("Record", Item) for Item in self.Records if str(Item.Directory) == Record), None) + elif Library: + Wanted = next((("Library", Item) for Item in self.Libraries if Item.Name == Library), None) + elif self.Source is not None: # same collection as before, refreshed + Kind, Old = self.Source + Pool = self.Records if Kind == "Record" else self.Libraries + Wanted = next(((Kind, Item) for Item in Pool if self._SameSource(Item, Old)), None) + if Wanted is None and self.Source is None and (self.Records or self.Libraries): + Wanted = ("Record", self.Records[0]) if self.Records else ("Library", self.Libraries[0]) + self._BuildSources() + if Wanted is None: + self.Source = None + self._ShowEmpty() + return + if Wanted != self.Source or Directory or Select or Record or Library: + self.Open(Wanted, Path(Directory) if Directory else None, Path(Select) if Select else None) + else: + self.Reload() + + @staticmethod + def _SameSource(New, Old) -> bool: + return New.Directory == Old.Directory if isinstance(New, Record) else New.Name == Old.Name + + def Leave(self) -> None: + self._SaveState() + + def _ShowEmpty(self) -> None: + self._ShowOverlay(EmptyState("No Records yet.", "Add a directory on the Records page to start tagging.", + ft.Icons.FOLDER_OFF_OUTLINED)) + self.Breadcrumbs.controls = [] + self.Status.value = "" + self.Editor.ShowBooks([], Update=False) + self.update() + + def _BuildSources(self) -> None: + Tiles: list[ft.Control] = [] + + def Tile(Text: str, Icon, Selected: bool, OnClick, Warning: str = "") -> ft.Control: + return ft.Container( + ft.Row([ft.Icon(Icon, size=16, color=Palette.Mauve if Selected else Palette.Overlay0), + ft.Text(Text, size=13, color=Palette.Mauve if Selected else Palette.Text, expand=True, + max_lines=1, overflow=ft.TextOverflow.ELLIPSIS), + *([ft.Icon(ft.Icons.WARNING_AMBER, size=14, color=Palette.Yellow, tooltip=Warning)] if Warning else [])], + spacing=8), + padding=ft.Padding.symmetric(horizontal=8, vertical=6), border_radius=4, + bgcolor=Palette.Surface0 if Selected else None, on_click=OnClick, ink=True, + ) + + if self.Libraries: + Tiles.append(Note("Libraries", Size=11)) + for Item in self.Libraries: + Selected = self.Source is not None and self.Source[0] == "Library" and self.Source[1].Name == Item.Name + Tiles.append(Tile(Item.Name, ft.Icons.COLLECTIONS_BOOKMARK_OUTLINED, Selected, + lambda e, Item=Item: self.Open(("Library", Item)))) + Tiles.append(Note("Records", Size=11)) + for Item in self.Records: + Selected = self.Source is not None and self.Source[0] == "Record" and self.Source[1].Directory == Item.Directory + Tiles.append(Tile(Item.Name, ft.Icons.FOLDER_SPECIAL_OUTLINED, Selected, + lambda e, Item=Item: self.Open(("Record", Item)), Item.Problem)) + self.SourceList.controls = Tiles + + # Opening and loading + + def Open(self, Source: tuple[str, object], Directory: Optional[Path] = None, Select: Optional[Path] = None) -> None: + if self.Source is not None: + self._SaveState() + self.Source = Source + Kind, Item = Source + State = ExplorerState(Root=Item.Directory) if Kind == "Record" else ExplorerState(Library=Item.Name) + self.Checked.clear() + self.Fullscreen = None + self.FilterField.value = State.get("Filter", "") + self.Sorts = [Value for Value in State.get("Sorts", ["Name"]) if isinstance(Value, str)] or ["Name"] + self.Descending = State.get("SortDirection") == "Descending" + if Kind == "Record": + Remembered = Path(State["Directory"]) if State.get("Directory") else None + Target = Directory or Remembered or Item.Directory + if not (Target.is_dir() and Target.resolve().is_relative_to(Item.Directory)): + Target = Item.Directory + self.Directory = Target.resolve() + else: + self.Directory = None + Remembered = State.get("SelectedFile") + self.Focused = Select or (Path(Remembered) if Remembered else None) + self._BuildSources() + self.Reload() + + def Reload(self) -> None: + if self.Source is None: + return + self.LoadGeneration += 1 + Generation = self.LoadGeneration + self._ShowOverlay(ft.Container(ft.ProgressRing(width=28, height=28), alignment=ft.Alignment.CENTER, expand=True)) + self._UpdateToolbar() + self.update() + self.page.run_thread(self._LoadEntries, Generation) + + def _LoadEntries(self, Generation: int) -> None: + Kind, Item = self.Source + Cache: dict = {} + Entries: list[Entry] = [] + Error = "" + HideUntagged = bool(ConfValue("Explorer", "HideUntaggedDirectories", True)) + HideTagFiles = bool(ConfValue("Explorer", "HideTagFiles", True)) + ShowInvalid = bool(ConfValue("Explorer", "ShowInvalidEntries", True)) + if Kind == "Record": + Sources = [(self.Directory, "", True)] + else: + Names = {Record.Directory: Record.Name for Record in self.Records} + Sources = [(Member, Names.get(Member, Member.name), False) for Member in Item.Members] + for Directory, RecordName, WithDirectories in Sources: + try: + Children = list(Directory.iterdir()) + except OSError as Problem: + Error = f"Couldn't read {DisplayPath(str(Directory))}: {Problem}" + continue + OwnLexicon = TagCore.GetLexiconPath(Directory) if HideTagFiles else None + for Child in Children: + if Child.name.startswith(".") or IsBook(Child) or Child == OwnLexicon: + continue + try: + IsDirectory = Child.is_dir() + Stat = Child.stat() + except OSError: + continue + if IsDirectory: + if not WithDirectories or (HideUntagged and DirectoryState(Child) == "Untagged"): + continue + Entries.append(Entry(Child, True, Stat, RecordName=RecordName)) + continue + TheEntry = Entry(Child, False, Stat, RecordName=RecordName) + try: + TheEntry.Book = ReadBook(Child, Cache) + except DataError as Problem: + TheEntry.BookError = str(Problem) + if not ShowInvalid and (TheEntry.BookError or BookProblems(TheEntry.Book)): + continue + Entries.append(TheEntry) + if Generation != self.LoadGeneration: + return + if Generation != self.LoadGeneration: + return + self.Entries = Entries + self.LoadError = Error + self._ApplyView() + self._UpdateSortMenu() + self.update() + if self.Focused is not None: + self._ScrollTo(self.Focused) # only after update(): the list must exist on the client first + + # Filter, sort and the list + + def _FilterChanged(self) -> None: + self._ApplyView() + self.update() + + def _ApplyView(self) -> None: + try: + Conditions = ParseFilter(self.FilterField.value or "") + self.FilterError = "" + except ValueError as Error: + Conditions = [] + self.FilterError = str(Error) + self.FilterField.error = self.FilterError or None + # Directories always stay, so the filter never hides the way down. + Shown = [Item for Item in self.Entries if Item.IsDirectory or Matches(Item, Conditions)] + self.Visible = SortEntries(Shown, self.Sorts, self.Descending) + if self.Focused is not None and not any(Item.Path == self.Focused for Item in self.Visible): + self.Focused = None + self._BuildList() + self._ShowSelection(Update=False) + + def _BuildList(self) -> None: + self.RowControls = {} + if not self.Visible: + Message = "Nothing matches the filter." if self.Entries else "This directory is empty." + self._ShowOverlay(EmptyState(Message, getattr(self, "LoadError", ""), ft.Icons.SEARCH_OFF)) + else: + self.ListArea.controls = [self._Row(Item) for Item in self.Visible] + self._ShowOverlay(None) + self._UpdateStatus() + + def _Row(self, Item: Entry) -> ft.Control: + if Item.IsDirectory: + Icon, IconColor = ft.Icons.FOLDER, Palette.Mauve + elif Item.Kind == "image": + Icon, IconColor = ft.Icons.IMAGE_OUTLINED, Palette.Blue + elif Item.Kind == "video": + Icon, IconColor = ft.Icons.MOVIE_OUTLINED, Palette.Peach + else: + Icon, IconColor = ft.Icons.INSERT_DRIVE_FILE_OUTLINED, Palette.Overlay0 + if Item.IsDirectory: + Status = ft.Container(width=16) + Summary = "Has its own Lexicon" if TagCore.GetLexiconPath(Item.Path).is_file() else "" + else: + Problems = [Item.BookError] if Item.BookError else BookProblems(Item.Book) + if Item.Book is None and not Item.BookError: + Status = ft.Icon(ft.Icons.CIRCLE_OUTLINED, size=12, color=Palette.Surface1, tooltip="No Book") + elif Problems: + Status = ft.Icon(ft.Icons.ERROR_OUTLINE, size=14, color=Palette.Red, tooltip="\n".join(Problems[:6])) + else: + Status = ft.Icon(ft.Icons.CIRCLE, size=10, color=Palette.Mauve, tooltip="Tagged") + Summary = TagSummary(Item.Book) + Checkbox = ft.Checkbox(value=Item.Path in self.Checked, on_change=lambda e, P=Item.Path: self.Tick(P, e.control.value), + visible=not Item.IsDirectory) + Name = ft.Text(Item.Path.name, size=13, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS, expand=3) + Children: list[ft.Control] = [ + ft.Container(Checkbox, width=36) if not Item.IsDirectory else ft.Container(width=36), + ft.Icon(Icon, size=18, color=IconColor), Name, + ] + if Item.RecordName: + Children.append(ft.Text(Item.RecordName, size=11, color=Palette.Overlay0, width=110, max_lines=1, + overflow=ft.TextOverflow.ELLIPSIS)) + Children += [ft.Text(Summary, size=11, color=Palette.Overlay0, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS, expand=4), + Status] + Row = ft.Container( + ft.Row(Children, spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER), + height=RowHeight, padding=ft.Padding.only(left=4, right=12), + bgcolor=self._RowColor(Item.Path), + ) + self.RowControls[Item.Path] = Row + return ft.GestureDetector( + content=Row, + on_tap=lambda e, Item=Item: self._Tap(Item), + on_secondary_tap_down=lambda e, Item=Item: self._RightClick(Item, e), + mouse_cursor=ft.MouseCursor.CLICK, + ) + + def _ShowOverlay(self, Content: Optional[ft.Control]) -> None: + """Content in place of the list, or None for the list itself.""" + self.Overlay.content = Content + self.Overlay.visible = Content is not None + self.ListArea.visible = Content is None + + def _RowColor(self, PathValue: Path) -> Optional[str]: + if PathValue == self.Focused: + return Palette.Surface1 + if PathValue in self.Checked: + return Palette.Surface0 + return None + + def _Recolor(self, Paths: list[Optional[Path]]) -> None: + for PathValue in Paths: + Row = self.RowControls.get(PathValue) if PathValue else None + if Row is not None: + Row.bgcolor = self._RowColor(PathValue) + + def _UpdateStatus(self) -> None: + Files = [Item for Item in self.Visible if not Item.IsDirectory] + WithBooks = sum(1 for Item in Files if Item.Book is not None) + Parts = [Plural(len(Files), "file"), f"{WithBooks} with Books"] + Directories = len(self.Visible) - len(Files) + if Directories: + Parts.insert(0, Plural(Directories, "directory", "directories")) + if len(self.Visible) != len(self.Entries): + Parts.append(f"{len(self.Entries) - len(self.Visible)} filtered out") + if self.Checked: + Parts.append(f"{len(self.Checked)} ticked") + self.Status.value = " · ".join(Parts) + + def _UpdateToolbar(self) -> None: + Kind, Item = self.Source + Crumbs: list[ft.Control] = [] + if Kind == "Library": + Crumbs.append(ft.Text(f"Library: {Item.Name}", color=Palette.Mauve, size=14)) + self.UpButton.disabled = True + else: + Parts: list[Path] = [self.Directory] + while Parts[-1] != Item.Directory and Parts[-1].parent != Parts[-1]: + Parts.append(Parts[-1].parent) + for Index, PathValue in enumerate(reversed(Parts)): + if Index: + Crumbs.append(ft.Icon(ft.Icons.CHEVRON_RIGHT, size=16, color=Palette.Overlay0)) + Label = Item.Name if PathValue == Item.Directory else PathValue.name + Crumbs.append(ft.TextButton(Label, on_click=lambda e, P=PathValue: self.GoTo(P), + style=ft.ButtonStyle(color=Palette.Mauve if PathValue == self.Directory else Palette.Text, + padding=ft.Padding.symmetric(horizontal=6)))) + self.UpButton.disabled = self.Directory == Item.Directory + self.Breadcrumbs.controls = Crumbs + self.DirectionButton.icon = ft.Icons.ARROW_UPWARD if self.Descending else ft.Icons.ARROW_DOWNWARD + self.DirectionButton.tooltip = "Descending" if self.Descending else "Ascending" + + def _UpdateSortMenu(self) -> None: + TagNames: list[str] = [] + for Item in self.Entries: + if Item.Book: + TagNames += [Name for Name in list(Item.Book.Definitions) + list(Item.Book.Tags) if Name not in TagNames] + Items: list[ft.PopupMenuItem] = [ft.PopupMenuItem(content=Note("Click to add or remove; first = most important"))] + + def Choice(Criterion: str) -> ft.PopupMenuItem: + Label = SortNames.get(Criterion, Criterion[4:] if Criterion.startswith("Tag:") else Criterion) + if Criterion in self.Sorts: + Label = f"{self.Sorts.index(Criterion) + 1}. {Label}" + return ft.PopupMenuItem(content=Label, checked=Criterion in self.Sorts, + on_click=lambda e, Criterion=Criterion: self._ToggleSort(Criterion)) + + Basics = [Name for Name in SortNames if Name != "Record" or (self.Source and self.Source[0] == "Library")] + Items += [Choice(Name) for Name in Basics] + if TagNames: + Items.append(ft.PopupMenuItem()) # divider + Items += [Choice(f"Tag:{Name}") for Name in TagNames] + Items += [ft.PopupMenuItem(), ft.PopupMenuItem(content="Reset to Name", icon=ft.Icons.CLEAR_ALL, + on_click=lambda e: self._SetSorts(["Name"]))] + self.SortButton.items = Items + self.SortButton.tooltip = "Sorted by " + ", then ".join(CriterionLabel(Criterion) for Criterion in self.Sorts) + + def _ToggleSort(self, Criterion: str) -> None: + Sorts = list(self.Sorts) + if Criterion in Sorts: + Sorts.remove(Criterion) + else: + if Sorts == ["Name"]: + Sorts = [] # Name is the tie-breaker anyway + Sorts.append(Criterion) + self._SetSorts(Sorts or ["Name"]) + + def _SetSorts(self, Sorts: list[str]) -> None: + self.Sorts = Sorts + self._ApplyView() + self._UpdateSortMenu() + self._SaveState() + self.update() + + def _ToggleDirection(self) -> None: + self.Descending = not self.Descending + self._UpdateToolbar() + self._ApplyView() + self._SaveState() + self.update() + + # Selection, ticks and the side panel + + def _Tap(self, Item: Entry) -> None: + Now = time.monotonic() + Double = self.LastTap[0] == Item.Path and Now - self.LastTap[1] < DoubleClickSeconds + self.LastTap = (None, 0.0) if Double else (Item.Path, Now) + if Double: + self.OpenEntry(Item) + return + self.Focus(Item.Path) + self._SaveState() # so the selection is remembered even if the window just closes + + def Focus(self, PathValue: Optional[Path], Scroll: bool = False) -> None: + Previous = self.Focused + self.Focused = PathValue + self._Recolor([Previous, PathValue]) + self._ShowSelection(Update=False) + self.update() + if Scroll and PathValue is not None: + self._ScrollTo(PathValue) + + def _ScrollTo(self, PathValue: Path) -> None: + Index = next((Number for Number, Item in enumerate(self.Visible) if Item.Path == PathValue), None) + if Index is None or not self.ListArea.visible: + return + + async def Scroll() -> None: + try: + await self.ListArea.scroll_to(offset=max(0, (Index - 3) * RowHeight), duration=0) + except Exception: + pass # the list was swapped out meanwhile; nothing to scroll + + self.page.run_task(Scroll) + + def Tick(self, PathValue: Path, Value: bool) -> None: + if Value: + self.Checked.add(PathValue) + else: + self.Checked.discard(PathValue) + self._Recolor([PathValue]) + self._UpdateStatus() + self._ShowSelection(Update=False) + self.update() + + def TickAll(self) -> None: + self.Checked = {Item.Path for Item in self.Visible if not Item.IsDirectory} + self._BuildList() + self._ShowSelection(Update=False) + self.update() + + def ClearTicks(self) -> None: + self.Checked.clear() + self._BuildList() + self._ShowSelection(Update=False) + self.update() + + def _EntryFor(self, PathValue: Optional[Path]) -> Optional[Entry]: + return next((Item for Item in self.Entries if Item.Path == PathValue), None) if PathValue else None + + def _ShowSelection(self, Update: bool = True) -> None: + """The preview shows the focused entry; the tag panel edits the ticked + files, or the focused one when nothing is ticked.""" + Focused = self._EntryFor(self.Focused) + self._ShowPreview(Focused) + if self.Checked: + Ticked = [Item for Item in self.Entries if Item.Path in self.Checked] + Books = [Item.Book for Item in Ticked if Item.Book is not None] + Missing = len(Ticked) - len(Books) + Header = f"{len(Ticked)} files ticked" + (f" ({Missing} without a Book)" if Missing else "") + self.Editor.ShowBooks(Books, Header if Books else f"None of the {len(Ticked)} ticked files has a Book.", Update=False) + elif Focused is None or Focused.IsDirectory: + self.Editor.ShowBooks([], "", Update=False) + elif Focused.Book is None: + self.Editor.ShowBooks([], Focused.BookError or "This file has no Book yet.", Update=False) + if not Focused.BookError and LexiconFor(Focused.Path.parent): + self.Editor.controls.append(ChipButton("Create its Book", lambda e: self.MakeBook(Focused), ft.Icons.ADD)) + else: + self.Editor.ShowBooks([Focused.Book], Focused.Path.name, Update=False) + if Update: + self.update() + + def _ShowPreview(self, Item: Optional[Entry]) -> None: + Box = self.PreviewBox + if Item is None: + Box.content = Note("Nothing selected.") + return + Lines = [ft.Text(Item.Path.name, size=12, text_align=ft.TextAlign.CENTER, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS)] + if Item.Stat is not None and not Item.IsDirectory: + Lines.append(Note(f"{HumanSize(Item.Stat.st_size)} · {time.strftime('%Y-%m-%d %H:%M', time.localtime(Item.Stat.st_mtime))}", Size=11)) + if Item.IsDirectory: + Visual: ft.Control = ft.Icon(ft.Icons.FOLDER, size=64, color=Palette.Mauve) + elif Item.Kind == "image" and ConfValue("Explorer", "ShowImagePreviews", True): + Visual = ft.GestureDetector( + content=ft.Image(src=str(Item.Path), fit=ft.BoxFit.CONTAIN, gapless_playback=True, + error_content=ft.Icon(ft.Icons.BROKEN_IMAGE_OUTLINED, size=48, color=Palette.Overlay0)), + on_tap=lambda e: self.ShowFullscreen(Item.Path), mouse_cursor=ft.MouseCursor.ZOOM_IN, expand=True, + ) + elif Item.Kind == "video": + Visual = ft.Column([ + ft.Icon(ft.Icons.MOVIE_OUTLINED, size=64, color=Palette.Peach), + ChipButton("Play", lambda e: self.App.OpenPath(Item.Path), ft.Icons.PLAY_ARROW), + ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, tight=True) + else: + Visual = ft.Icon(ft.Icons.INSERT_DRIVE_FILE_OUTLINED, size=64, color=Palette.Overlay0) + Box.content = ft.Column([ft.Container(Visual, expand=True, alignment=ft.Alignment.CENTER), *Lines], + horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=4) + + def _BooksChanged(self, Books: list[Book]) -> None: + """The tag panel wrote some Books: refresh their rows (and the order, + when sorting by a tag).""" + self._ApplyView() + self.update() + + def _TogglePanel(self) -> None: + self.SidePanel.visible = not self.SidePanel.visible + self.App.Config.ShowPreview = self.SidePanel.visible + self.App.Config.Save() + self.update() + + def _ToggleSidebar(self) -> None: + self.Sidebar.visible = not self.Sidebar.visible + self.update() + + # Navigation and actions + + def GoTo(self, Directory: Path) -> None: + if self.Source is None or self.Source[0] != "Record": + return + Previous = self.Directory + self._SaveState() + self.Directory = Directory + self.Checked.clear() + self.Focused = Previous if Previous and Previous.parent == Directory else None + self.Reload() + + def GoUp(self) -> None: + if self.Source and self.Source[0] == "Record" and self.Directory != self.Source[1].Directory: + self.GoTo(self.Directory.parent) + + def OpenEntry(self, Item: Entry) -> None: + if Item.IsDirectory: + self.GoTo(Item.Path) + return + RecordRecentFile(Item.Path) + if Item.Kind == "image": + self.ShowFullscreen(Item.Path) + else: + self.App.OpenPath(Item.Path) + + def MakeBook(self, Item: Entry) -> None: + try: + Item.Book = CreateBook(Item.Path) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + self.App.Notify(f"Created a Book for {Item.Path.name}.", "success") + self._ApplyView() + self.update() + + def MakeBooks(self, Items: list[Entry]) -> None: + Made = 0 + for Item in Items: + if Item.Book is None and not Item.BookError: + try: + Item.Book = CreateBook(Item.Path) + Made += 1 + except DataError as Error: + self.App.Notify(str(Error), "error") + break + if Made: + self.App.Notify(f"Created {Plural(Made, 'Book')}.", "success") + self._ApplyView() + self.update() + + def Rename(self, Item: Entry) -> None: + def Done(NewName: str) -> None: + try: + NewPath = RenameFile(Item.Path, NewName) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + if NewPath != Item.Path: + if Item.Path in self.Checked: + self.Checked.discard(Item.Path) + self.Checked.add(NewPath) + self.Focused = NewPath + self.App.Notify(f"Renamed to {NewName.strip()}" + (" (its Book came along)." if Item.Book else "."), "success") + self.Reload() + + self.App.Ask("Rename", "New name", Item.Path.name, Done, "Rename", + "The file's Book is renamed with it.") + + def TagDirectory(self, Item: Entry) -> None: + Stop = self.Source[1].Directory if self.Source and self.Source[0] == "Record" else None + try: + LexiconPath = CreateDirectoryLexicon(Item.Path, Stop) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + self.App.Notify(f"Created {LexiconPath.name}. Run Update to give its files Books.", "success") + self.Reload() + + # Fullscreen image view + + def ShowFullscreen(self, PathValue: Path) -> None: + self.Fullscreen = PathValue + Images = [Item.Path for Item in self.Visible if Item.Kind == "image"] + Index = Images.index(PathValue) if PathValue in Images else 0 + Header = ft.Row([ + ft.IconButton(ft.Icons.ARROW_BACK, tooltip="Back to the list (Escape)", on_click=lambda e: self.CloseFullscreen()), + ft.Text(PathValue.name, expand=True, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS), + Note(f"{Index + 1} / {len(Images)}"), + ft.IconButton(ft.Icons.CHEVRON_LEFT, tooltip="Previous (Left)", on_click=lambda e: self.StepFullscreen(-1)), + ft.IconButton(ft.Icons.CHEVRON_RIGHT, tooltip="Next (Right)", on_click=lambda e: self.StepFullscreen(1)), + ft.IconButton(ft.Icons.OPEN_IN_NEW, tooltip="Open in the default app", on_click=lambda e: self.App.OpenPath(PathValue)), + ]) + self._ShowOverlay(ft.Container(ft.Column([ + Header, + ft.Container(ft.Image(src=str(PathValue), fit=ft.BoxFit.CONTAIN, gapless_playback=True), expand=True, + alignment=ft.Alignment.CENTER), + ], expand=True), padding=8, bgcolor=Palette.Crust, expand=True)) + Previous = self.Focused + self.Focused = PathValue + self._Recolor([Previous, PathValue]) + self._ShowSelection(Update=False) + self.update() + + def StepFullscreen(self, Offset: int) -> None: + Images = [Item.Path for Item in self.Visible if Item.Kind == "image"] + if not Images or self.Fullscreen is None: + return + Index = Images.index(self.Fullscreen) if self.Fullscreen in Images else 0 + self.ShowFullscreen(Images[(Index + Offset) % len(Images)]) + + def CloseFullscreen(self) -> None: + self.Fullscreen = None + self._BuildList() + self.update() + if self.Focused: + self._ScrollTo(self.Focused) + + # Right-click menus + + def _RightClick(self, Item: Entry, Event) -> None: + if Item.Path != self.Focused: + self.Focus(Item.Path) + self.Menu.Request(Item, Event) + + def _MenuItems(self, Target) -> list[ft.PopupMenuItem]: + if Target is None: + Items = [MenuItem("Reload", self.Reload, ft.Icons.REFRESH)] + if self.Visible: + Items.insert(0, MenuItem("Tick All", self.TickAll, ft.Icons.CHECK_BOX_OUTLINED)) + if self.Checked: + Items.insert(1, MenuItem("Clear Ticks", self.ClearTicks, ft.Icons.CHECK_BOX_OUTLINE_BLANK)) + Without = [Item for Item in self.Entries if Item.Path in self.Checked and Item.Book is None] + if Without: + Items.append(MenuItem(f"Create Books for {Plural(len(Without), 'Ticked File')}", lambda: self.MakeBooks(Without), ft.Icons.ADD)) + if self.Source and self.Source[0] == "Record" and self.Directory != self.Source[1].Directory: + Items.append(MenuItem("Up a Directory", self.GoUp, ft.Icons.ARROW_UPWARD)) + if self.Directory: + Items.append(MenuItem("Open Folder", lambda: self.App.OpenPath(self.Directory), ft.Icons.FOLDER_OPEN_OUTLINED)) + return Items + Item: Entry = Target + if Item.IsDirectory: + Items = [MenuItem("Open", lambda: self.GoTo(Item.Path), ft.Icons.FOLDER_OPEN)] + OwnLexicon = TagCore.GetLexiconPath(Item.Path) + if OwnLexicon.is_file(): + Items.append(MenuItem("Edit Its Lexicon", lambda: self.App.Navigate("Lexicons", Open=str(OwnLexicon)), ft.Icons.EDIT_NOTE)) + else: + Items.append(MenuItem("Add a Lexicon (Tag It)", lambda: self.TagDirectory(Item), ft.Icons.NEW_LABEL_OUTLINED)) + else: + Items = [MenuItem("Open", lambda: self.OpenEntry(Item), ft.Icons.OPEN_IN_NEW)] + if Item.Kind == "image": + Items.append(MenuItem("View Fullscreen", lambda: self.ShowFullscreen(Item.Path), ft.Icons.FULLSCREEN)) + Items.append(MenuItem("Rename", lambda: self.Rename(Item), ft.Icons.DRIVE_FILE_RENAME_OUTLINE)) + Items.append(MenuItem("Untick" if Item.Path in self.Checked else "Tick", + lambda: self.Tick(Item.Path, Item.Path not in self.Checked), ft.Icons.CHECK_BOX_OUTLINED)) + if Item.Book is None and not Item.BookError and LexiconFor(Item.Path.parent): + Items.append(MenuItem("Create Its Book", lambda: self.MakeBook(Item), ft.Icons.ADD)) + if Item.Book is not None and Item.Book.LexiconPath and Item.Book.LexiconPath.is_file(): + Items.append(MenuItem("Edit Its Lexicon", lambda: self.App.Navigate("Lexicons", Open=str(Item.Book.LexiconPath)), ft.Icons.EDIT_NOTE)) + Items.append(MenuItem("Open Containing Folder", lambda: self.App.OpenPath(Item.Path.parent), ft.Icons.FOLDER_OPEN_OUTLINED)) + Items.append(MenuItem("Copy Path", lambda: self.App.CopyText(str(Item.Path), "the path"), ft.Icons.COPY)) + return Items + + # Keyboard + + def _OnKey(self, Event: ft.KeyboardEvent) -> None: + if self.App.Typing or self.Source is None or self.page is None: + return + Key = Event.key + if self.Fullscreen is not None: + if Key == "Escape": + self.CloseFullscreen() + elif Key in ("Arrow Left", "Arrow Up"): + self.StepFullscreen(-1) + elif Key in ("Arrow Right", "Arrow Down", " "): + self.StepFullscreen(1) + return + Index = next((Number for Number, Item in enumerate(self.Visible) if Item.Path == self.Focused), -1) + Current = self.Visible[Index] if Index >= 0 else None + if Key in ("Arrow Down", "Arrow Up") and self.Visible: + Step = 1 if Key == "Arrow Down" else -1 + Target = 0 if Index < 0 else max(0, min(len(self.Visible) - 1, Index + Step)) + self.Focus(self.Visible[Target].Path, Scroll=True) + elif Key == "Enter" and Current is not None: + self.OpenEntry(Current) + elif Key == " " and Current is not None and not Current.IsDirectory: + self.Tick(Current.Path, Current.Path not in self.Checked) + elif Key == "F2" and Current is not None and not Current.IsDirectory: + self.Rename(Current) + elif Key == "A" and Event.ctrl: + self.TickAll() + elif Key == "Backspace" or (Key == "Arrow Left" and Event.alt): + self.GoUp() + elif Key == "Escape" and self.Checked: + self.ClearTicks() + + # History + + def _SaveState(self) -> None: + if self.Source is None: + return + Kind, Item = self.Source + SaveExplorerState( + Item.Directory if Kind == "Record" else None, Item.Name if Kind == "Library" else None, + self.Directory, self.FilterField.value or "", self.Sorts, self.Descending, + self.Focused if self.Focused and not self.Focused.is_dir() else None, + ) diff --git a/Core.py b/Core.py new file mode 100644 index 0000000..6f091c7 --- /dev/null +++ b/Core.py @@ -0,0 +1,308 @@ +"""Shared app-wide constants: paths, palette, theme, SaraTag's own GUI config. + +The palette, theming and shared settings mirror SaraSuite's, SaraFem's and +SaraSort's Core.py and read the same ~/.config/SaraSuite files, so a theme +picked for one Sara program looks the same in all of them. + +SaraTag's data (SaraTagConf.toml, .SaraTagRecords.toml, .SaraTagHistory.toml, +Lexicons/) stays portable in the program folder; see Library.py. +""" + +import os +import sys +from dataclasses import asdict, dataclass +from pathlib import Path + +import flet as ft +import tomlkit + +AppName = "SaraTag" +AppVersion = "0.2.0-beta" +# Bundled in assets/fonts and always registered, so it works as FontFamily +# without being installed. +BundledFontFamily = "Fixedsys Excelsior" +ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme + +Home = Path.home() +ConfigHome = Home / ".config" +DataHome = Home / ".local" / "share" + +SharedConfigDir = ConfigHome / "SaraSuite" +ThemesDir = SharedConfigDir / "Themes" +SharedConfigPath = SharedConfigDir / "Config.toml" +ConfigDir = SharedConfigDir / AppName +ConfigPath = ConfigDir / "Config.toml" + +ProgramDir = Path(__file__).resolve().parent +AssetsDir = ProgramDir / "assets" + +# Themes SaraFem seeds into ThemesDir. Only other themes travel with an +# export as full palettes (same rule as SaraFem's ExportSettings). +BuiltinThemeNames = {"CatppuccinMocha", "Forest", "Gruvbox"} + + +class Palette: + """Live values for the active theme (see ApplyTheme). Same attribute names + and role mapping as SaraSuite's and SaraFem's Palette.""" + + Mauve = "#cba6f7" + Red = "#f38ba8" + Peach = "#fab387" + Yellow = "#f9e2af" + Green = "#a6e3a1" + Blue = "#89b4fa" + Text = "#cdd6f4" + White = "#cdd6f4" + Overlay0 = "#6c7086" + Surface1 = "#45475a" + Surface0 = "#313244" + Base = "#1e1e2e" + Mantle = "#181825" + Crust = "#11111b" + + +_ThemeRoleMap = { + "Background": "Base", "Surface": "Mantle", "SurfaceAlt": "Surface0", "Border": "Surface1", + "Text": "Text", "TextMuted": "Overlay0", "Black": "Crust", "White": "White", + "Red": "Red", "Orange": "Peach", "Yellow": "Yellow", "Green": "Green", + "Blue": "Blue", "Purple": "Mauve", +} + + +def ApplyTheme(ThemeName: str) -> None: + """Mutate Palette in place from ~/.config/SaraSuite/Themes/.toml. + Keeps the built-in Catppuccin Mocha values if the file is missing. Must + run before any control reads Palette.X.""" + ThemeFile = ThemesDir / f"{ThemeName}.toml" + if not ThemeFile.exists(): + return + try: + RoleValues = tomlkit.parse(ThemeFile.read_text()).unwrap().get("Palette", {}) + except tomlkit.exceptions.TOMLKitError: + return + for RoleName, AttrName in _ThemeRoleMap.items(): + if RoleName in RoleValues: + setattr(Palette, AttrName, RoleValues[RoleName]) + + +def AvailableThemes() -> list[str]: + Names = {ThemePath.stem for ThemePath in ThemesDir.glob("*.toml")} if ThemesDir.is_dir() else set() + Names.add("CatppuccinMocha") + return sorted(Names) + + +@dataclass +class AppConfig: + # True: Theme, FontFamily and HideWindowTitleBar come from the shared + # ~/.config/SaraSuite/Config.toml. False: SaraTag's own values below. + UseSharedSettings: bool = True + Theme: str = "CatppuccinMocha" + FontFamily: str = BundledFontFamily + HideWindowTitleBar: bool = False + StartPage: str = "Browse" + PreviewWidth: int = 360 # the preview and tag panel on the Browse page + ShowPreview: bool = True + + def Save(self) -> None: + ConfigDir.mkdir(parents=True, exist_ok=True) + ConfigPath.write_text(tomlkit.dumps(asdict(self))) + + +def LoadConfig() -> AppConfig: + if not ConfigPath.exists(): + Config = AppConfig() + Config.Save() + return Config + try: + Data = tomlkit.parse(ConfigPath.read_text()).unwrap() + except tomlkit.exceptions.TOMLKitError: + return AppConfig() + KnownFields = set(AppConfig.__dataclass_fields__) + return AppConfig(**{Key: Value for Key, Value in Data.items() if Key in KnownFields}) + + +@dataclass +class SharedSettings: + """~/.config/SaraSuite/Config.toml, shared by every SaraSuite program. + Same schema and fallback rules as SaraSuite's and SaraFem's.""" + + Theme: str = "CatppuccinMocha" + FontFamily: str = BundledFontFamily + HideWindowTitleBar: bool = False + + def Save(self) -> None: + SharedConfigDir.mkdir(parents=True, exist_ok=True) + SharedConfigPath.write_text(tomlkit.dumps(asdict(self))) + + +def _OwnSettings(Config: AppConfig) -> SharedSettings: + return SharedSettings(Config.Theme, Config.FontFamily, Config.HideWindowTitleBar) + + +def LoadSharedSettings(Config: AppConfig) -> SharedSettings: + """The shared file, created from SaraTag's own values if missing.""" + if not SharedConfigPath.exists(): + Shared = _OwnSettings(Config) + Shared.Save() + return Shared + Result = _OwnSettings(Config) # fallback for keys the file doesn't have + try: + Data = tomlkit.parse(SharedConfigPath.read_text()).unwrap() + except tomlkit.exceptions.TOMLKitError: + return Result + for Key in SharedSettings.__dataclass_fields__: + if Key in Data: + setattr(Result, Key, Data[Key]) + return Result + + +def EffectiveSettings(Config: AppConfig) -> SharedSettings: + return LoadSharedSettings(Config) if Config.UseSharedSettings else _OwnSettings(Config) + + +def ExportSettings(Config: AppConfig, DestPath: Path) -> None: + """One portable file in the same format as SaraFem's export: the shared + settings under [SaraSuite] (with a custom theme's palette as ThemeData) + and SaraTag's own under [SaraTag]. Other programs ignore [SaraTag], + and SaraTag ignores their sections.""" + Shared = LoadSharedSettings(Config) + SharedData = asdict(Shared) + if Shared.Theme not in BuiltinThemeNames: + ThemeFile = ThemesDir / f"{Shared.Theme}.toml" + if ThemeFile.exists(): + try: + SharedData["ThemeData"] = tomlkit.parse(ThemeFile.read_text()).unwrap().get("Palette", {}) + except tomlkit.exceptions.TOMLKitError: + pass + DestPath.write_text(tomlkit.dumps({"SaraSuite": SharedData, AppName: asdict(Config)})) + + +def ImportSettings(Config: AppConfig, SourcePath: Path) -> None: + """Applies [SaraSuite] to the shared file and [SaraTag] onto Config, key + by key: keys the file doesn't mention stay as they are, unknown sections + and keys are skipped. Raises TOMLKitError on an unreadable file.""" + Data = tomlkit.parse(SourcePath.read_text()).unwrap() + + SharedData = Data.get("SaraSuite") + if isinstance(SharedData, dict): + ThemeData = SharedData.pop("ThemeData", None) + Merged = asdict(LoadSharedSettings(Config)) + Merged.update({Key: Value for Key, Value in SharedData.items() if Key in SharedSettings.__dataclass_fields__}) + ImportedTheme = Merged.get("Theme") + if isinstance(ThemeData, dict) and ImportedTheme and ImportedTheme not in BuiltinThemeNames: + ThemesDir.mkdir(parents=True, exist_ok=True) + (ThemesDir / f"{ImportedTheme}.toml").write_text(tomlkit.dumps({"Palette": ThemeData})) + SharedSettings(**Merged).Save() + + OwnData = Data.get(AppName) + if isinstance(OwnData, dict): + for Key, Value in OwnData.items(): + if Key in AppConfig.__dataclass_fields__: + setattr(Config, Key, Value) + Config.Save() + + +def ApplyAppearance(Settings: SharedSettings) -> None: + """Theme colours and font; must run before anything reads Palette.X.""" + global ActiveFontFamily + ApplyTheme(Settings.Theme) + ActiveFontFamily = Settings.FontFamily or BundledFontFamily + + +def ChildEnvironment() -> dict[str, str]: + """The environment for child processes (xdg-open, the video player). + A PyInstaller build points LD_LIBRARY_PATH at its own bundled libraries; + system tools need the original back.""" + Environment = dict(os.environ) + if getattr(sys, "frozen", False): + Original = Environment.pop("LD_LIBRARY_PATH_ORIG", None) + if Original is not None: + Environment["LD_LIBRARY_PATH"] = Original + else: + Environment.pop("LD_LIBRARY_PATH", None) + for Key in ("PYTHONHOME", "PYTHONPATH"): + Environment.pop(Key, None) + return Environment + + +def BuildTheme() -> ft.Theme: + ColorScheme = ft.ColorScheme( + primary=Palette.Mauve, + on_primary=Palette.Crust, + primary_container=Palette.Surface0, + on_primary_container=Palette.Mauve, + secondary=Palette.Mauve, + on_secondary=Palette.Crust, + secondary_container=Palette.Surface0, + on_secondary_container=Palette.Mauve, + surface=Palette.Base, + on_surface=Palette.Text, + on_surface_variant=Palette.Overlay0, + surface_container=Palette.Mantle, + surface_container_low=Palette.Mantle, + surface_container_high=Palette.Surface0, + surface_container_highest=Palette.Surface1, + outline=Palette.Surface1, + outline_variant=Palette.Surface1, + error=Palette.Red, + on_error=Palette.Crust, + ) + return ft.Theme( + font_family=ActiveFontFamily, + color_scheme=ColorScheme, + use_material3=True, + scaffold_bgcolor=Palette.Base, + canvas_color=Palette.Base, + dialog_theme=ft.DialogTheme( + bgcolor=Palette.Mantle, + shape=ft.RoundedRectangleBorder(radius=8, side=ft.BorderSide(1, Palette.Mauve)), + title_text_style=ft.TextStyle(color=Palette.Mauve, size=18, font_family=ActiveFontFamily), + ), + ) + + +def BlendHex(BaseHex: str, AccentHex: str, Ratio: float) -> str: + """Solid colour mixing Base and Accent (0 = pure Base, 1 = pure Accent), + for tinted-but-still-dark backgrounds. Same as SaraFem's.""" + BaseRGB = tuple(int(BaseHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4)) + AccentRGB = tuple(int(AccentHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4)) + Mixed = (round(B + (A - B) * Ratio) for B, A in zip(BaseRGB, AccentRGB)) + return "#" + "".join(f"{C:02x}" for C in Mixed) + + +def NotificationStyle(Kind: str) -> tuple[str, str]: + """(Background, AccentColor) for a toast, from the theme's own colours like + SaraFem's: "success" green, "error" red, plus "warning" yellow and "info" + mauve for plain notes.""" + Accent = {"success": Palette.Green, "error": Palette.Red, "warning": Palette.Yellow}.get(Kind, Palette.Mauve) + return BlendHex(Palette.Mantle, Accent, 0.12), Accent + + +def ChipButtonStyle(Accent: str = "") -> ft.ButtonStyle: + """SaraView's button look: SurfaceAlt fill, 1px accent border, small radius.""" + return ft.ButtonStyle( + color=Palette.Text, + bgcolor=Palette.Surface0, + side=ft.BorderSide(1, Accent or Palette.Mauve), + shape=ft.RoundedRectangleBorder(radius=4), + ) + + +def ChipButton(Text: str, OnClick, Icon=None, Accent: str = "", Disabled: bool = False) -> ft.OutlinedButton: + return ft.OutlinedButton(content=Text, icon=Icon, style=ChipButtonStyle(Accent), on_click=OnClick, disabled=Disabled) + + +def Note(Text: str, Size: int = 12) -> ft.Text: + return ft.Text(Text, color=Palette.Overlay0, size=Size) + + +def Heading(Text: str) -> ft.Text: + return ft.Text(Text, color=Palette.Mauve, size=20) + + +def DisplayPath(PathText: str) -> str: + """/home/you/Pictures -> ~/Pictures, for labels.""" + HomeText = str(Home) + if PathText == HomeText or PathText.startswith(HomeText + "/"): + return "~" + PathText[len(HomeText):] + return PathText diff --git a/History.py b/History.py new file mode 100644 index 0000000..44e358a --- /dev/null +++ b/History.py @@ -0,0 +1,132 @@ +"""Session history in .SaraTagHistory.toml: where each Record or Library was +left (directory, filter, sort, selected file), recent files and the last +updater runs. SaraTagConf.toml's [History] section says what's remembered and +how much. Same schema as the terminal version wrote, so nothing is lost.""" + +from datetime import datetime +from pathlib import Path +from typing import Any + +import tomlkit + +from Library import ConfValue, DataError, HistoryPath, ReadToml, TagCore, WriteAtomically + + +def _Settings() -> dict[str, Any]: + Defaults = { + "Enabled": True, "MaxFileHistory": 30, "MaxDirectoryHistory": 20, "MaxUpdateHistory": 20, + "RememberFilter": True, "RememberSorting": True, "RememberLastDirectory": True, + "RememberLastSelectedFile": True, "RememberRecentFiles": True, "RememberUpdaterMode": True, + "RememberUpdates": True, + } + return {Key: ConfValue("History", Key, Value) for Key, Value in Defaults.items()} + + +def _Load() -> dict[str, Any]: + TagCore.EnsureConfigFiles() + try: + return ReadToml(HistoryPath) + except DataError: + return {"ConfigVersion": 1} # a broken history file never blocks anything + + +def _Save(History: dict[str, Any]) -> None: + Document = tomlkit.document() + Document.add(tomlkit.comment("SaraTag session history.")) + Document.add(tomlkit.comment("This file is managed automatically.")) + Document.add(tomlkit.nl()) + for Key, Value in History.items(): + Document[Key] = Value + try: + WriteAtomically(HistoryPath, tomlkit.dumps(Document)) + except OSError: + pass # history is a convenience; failing to save it isn't worth an error + + +def _Now() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def _Key(Root: Path | None, Library: str | None) -> tuple[str, str]: + return ("Library", Library) if Library is not None else ("Root", str(Root.resolve()) if Root else "") + + +def ExplorerState(Root: Path | None = None, Library: str | None = None) -> dict[str, Any]: + """Where a Record (by Root) or Library (by name) was left, filtered by + what [History] says to remember.""" + Settings = _Settings() + if not Settings["Enabled"]: + return {} + KeyName, KeyValue = _Key(Root, Library) + State = next((Item for Item in _Load().get("ExplorerState", []) if Item.get(KeyName) == KeyValue), {}) + State = dict(State) + if "Sorts" not in State and State.get("SortType"): + State["Sorts"] = [f"Tag:{State['SortTag']}" if State["SortType"] == "Tag" and State.get("SortTag") else State["SortType"]] + for Setting, Keys in (("RememberFilter", ["Filter"]), ("RememberSorting", ["Sorts", "SortDirection"]), + ("RememberLastDirectory", ["Directory"]), ("RememberLastSelectedFile", ["SelectedFile"])): + if not Settings[Setting]: + for Key in Keys: + State.pop(Key, None) + return State + + +def SaveExplorerState(Root: Path | None, Library: str | None, Directory: Path | None, Filter: str, + Sorts: list[str], Descending: bool, SelectedFile: Path | None) -> None: + Settings = _Settings() + if not Settings["Enabled"]: + return + History = _Load() + KeyName, KeyValue = _Key(Root, Library) + States = [Item for Item in History.get("ExplorerState", []) if Item.get(KeyName) != KeyValue] + First = Sorts[0] if Sorts else "Name" + State: dict[str, Any] = {KeyName: KeyValue} + if Directory is not None: + State["Directory"] = str(Directory) + State.update({ + "Filter": Filter, + "SortType": "Tag" if First.startswith("Tag:") else First, + "SortDirection": "Descending" if Descending else "Ascending", + "Sorts": list(Sorts), + "Updated": _Now(), + }) + if First.startswith("Tag:"): + State["SortTag"] = First[4:] + if SelectedFile is not None: + State["SelectedFile"] = str(SelectedFile) + History["ExplorerState"] = [State] + States[: max(0, int(Settings["MaxDirectoryHistory"]) - 1)] + _Save(History) + + +def RecordRecentFile(FilePath: Path) -> None: + Settings = _Settings() + if not (Settings["Enabled"] and Settings["RememberRecentFiles"]): + return + History = _Load() + Others = [Item for Item in History.get("RecentFile", []) if Item.get("Path") != str(FilePath)] + History["RecentFile"] = ([{"Path": str(FilePath), "Time": _Now()}] + Others)[: int(Settings["MaxFileHistory"])] + _Save(History) + + +def RecentFiles() -> list[Path]: + return [Path(Item["Path"]) for Item in _Load().get("RecentFile", []) if Item.get("Path")] + + +def UpdaterState() -> dict[str, Any]: + Settings = _Settings() + if not (Settings["Enabled"] and Settings["RememberUpdaterMode"]): + return {} + return dict(_Load().get("Updater", {})) + + +def RecordUpdaterRun(Mode: str, DryRun: bool, Scope: str, ChangedBooks: list[Path]) -> None: + Settings = _Settings() + if not Settings["Enabled"]: + return + History = _Load() + Now = _Now() + if Settings["RememberUpdaterMode"]: + History["Updater"] = {"LastMode": Mode, "LastRun": Now, "LastDryRun": DryRun, "LastScope": Scope} + if Settings["RememberUpdates"] and ChangedBooks and not DryRun: + New = [{"Path": str(BookPath), "Time": Now, "Mode": Mode, "DryRun": DryRun, "Scope": Scope} for BookPath in ChangedBooks] + History["RecentUpdate"] = (New + list(History.get("RecentUpdate", [])))[: int(Settings["MaxUpdateHistory"])] + _Save(History) diff --git a/Lexicons.py b/Lexicons.py new file mode 100644 index 0000000..163c650 --- /dev/null +++ b/Lexicons.py @@ -0,0 +1,373 @@ +"""Editing a Lexicon: its options (Inherit, Source, ExcludeTags) and tags. + +With Inherit on, a Lexicon starts from its Source's tags. Each tag then shows +where it comes from: Local (only here), Inherited (only in the Source), +Overridden (inherited, with some properties changed here; only the changed +properties are written) or Excluded (inherited but listed in ExcludeTags). +Saved in the same layout the terminal version wrote. +""" + +import copy +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from Library import DataError, LexiconsDir, ReadToml, TagCore, TagTypes, TagType, TomlValue, WriteAtomically + +# Properties each type has, in the order they're shown. Description, Required, +# ForceDefault and Default come first for every type. +CommonProperties = ["Description", "Required", "ForceDefault", "Default"] +TypeProperties = { + "Text": [], + "Number": ["Minimum", "Maximum"], + "Rating": ["Minimum", "Maximum", "FilledSymbol", "EmptySymbol"], + "List": ["Values"], + "Boolean": [], +} +PropertyHelp = { + "Description": "What the tag is for.", + "Required": "Books must have a real value (not ?).", + "ForceDefault": "Every Book is set to Default on each update; the value can't really be edited.", + "Default": "The value new Books start with.", + "Minimum": "The lowest allowed value.", + "Maximum": "The highest allowed value (also how many symbols a Rating shows).", + "FilledSymbol": "Shown once per point of a Rating.", + "EmptySymbol": "Fills a Rating up to Maximum.", + "Values": "The choices, in order (sorting follows this order).", +} + + +def StarterDefinition(Kind: str) -> dict[str, Any]: + Definition: dict[str, Any] = {"Description": "", "Type": Kind, "Required": False, "ForceDefault": False} + if Kind == "Rating": + Definition.update({"Minimum": 0, "Maximum": 5, "FilledSymbol": "★", "EmptySymbol": "☆"}) + elif Kind == "Boolean": + Definition["Default"] = False + elif Kind == "List": + Definition["Values"] = [] + return Definition + + +def ValidateDefinition(TagName: str, Definition: dict[str, Any]) -> str | None: + Kind = TagType(Definition) + if not Kind: + return f"{TagName} has an unknown Type {Definition.get('Type')!r}." + if not isinstance(Definition.get("Description", ""), str): + return f"{TagName}.Description must be text." + for Name in ("Required", "ForceDefault"): + if not isinstance(Definition.get(Name, False), bool): + return f"{TagName}.{Name} must be Yes or No." + if Definition.get("ForceDefault") and "Default" not in Definition: + return f"{TagName}: ForceDefault needs a Default." + Default = Definition.get("Default") + if Kind in ("Number", "Rating"): + Whole = Kind == "Rating" + Values = {Name: Definition.get(Name) for Name in ("Minimum", "Maximum", "Default")} + for Name, Value in Values.items(): + if Value is None: + continue + if isinstance(Value, bool) or not isinstance(Value, int if Whole else (int, float)): + return f"{TagName}.{Name} must be a {'whole number' if Whole else 'number'}." + if Whole and Value < 0: + return f"{TagName}.{Name} can't be negative." + Minimum = Values["Minimum"] if Values["Minimum"] is not None else (0 if Whole else None) + Maximum = Values["Maximum"] + if Minimum is not None and Maximum is not None and Minimum > Maximum: + return f"{TagName}: Minimum is above Maximum." + if Default is not None and Minimum is not None and Default < Minimum: + return f"{TagName}: Default is below Minimum." + if Default is not None and Maximum is not None and Default > Maximum: + return f"{TagName}: Default is above Maximum." + if Whole and (not isinstance(Definition.get("FilledSymbol", "★"), str) or not Definition.get("FilledSymbol", "★")): + return f"{TagName}.FilledSymbol can't be empty." + elif Kind == "List": + Values = Definition.get("Values", []) + if not isinstance(Values, list): + return f"{TagName}.Values must be a list." + if len(set(map(str, Values))) != len(Values): + return f"{TagName}.Values has the same value twice." + if Default is not None and Default not in Values: + return f"{TagName}: Default isn't one of its Values." + if Definition.get("Required") and not Values: + return f"{TagName} can't be Required with no Values." + elif Kind == "Text" and Default is not None and not isinstance(Default, str): + return f"{TagName}.Default must be text." + elif Kind == "Boolean" and Default is not None and not isinstance(Default, bool): + return f"{TagName}.Default must be Yes or No." + return None + + +def ConvertDefinition(Definition: dict[str, Any], NewKind: str) -> dict[str, Any]: + """The definition as another Type: shared properties stay, the Default + is converted when it can be, type-specific ones are replaced.""" + Result = StarterDefinition(NewKind) + for Name in ("Description", "Required", "ForceDefault"): + if Name in Definition: + Result[Name] = Definition[Name] + OldKind = TagType(Definition) + if OldKind in ("Number", "Rating") and NewKind in ("Number", "Rating"): + for Name in ("Minimum", "Maximum"): + if Name in Definition: + Result[Name] = int(Definition[Name]) if NewKind == "Rating" else Definition[Name] + if OldKind == "List" and NewKind == "List": + Result["Values"] = list(Definition.get("Values", [])) + Default = Definition.get("Default") + if Default is not None: + try: + if NewKind == "Text": + Result["Default"] = str(Default) + elif NewKind == "Number": + Result["Default"] = float(Default) if isinstance(Default, float) or "." in str(Default) else int(Default) + elif NewKind == "Rating": + Result["Default"] = max(0, int(Default)) + elif NewKind == "Boolean": + Result["Default"] = bool(Default) if isinstance(Default, (bool, int)) else str(Default).casefold() in ("true", "yes", "1") + elif NewKind == "List": + Result["Values"] = Result.get("Values") or [str(Default)] + Result["Default"] = str(Default) if str(Default) in map(str, Result["Values"]) else Result["Values"][0] + except (TypeError, ValueError): + pass + if Result.get("ForceDefault") and "Default" not in Result: + Result["ForceDefault"] = False + return Result + + +@dataclass +class TagView: + Name: str + Origin: str # Local, Inherited, Overridden, Excluded + Definition: dict[str, Any] # effective + Inherited: dict[str, Any] # the Source's definition ({} for Local) + + def PropertyOrigin(self, Name: str, Local: dict[str, Any]) -> str: + if self.Origin == "Local": + return "Local" + return "Overridden" if Name in Local else "Inherited" + + +class Lexicon: + def __init__(self, FilePath: Path): + self.Path = FilePath + self.Reload() + + def Reload(self) -> None: + self.Data = ReadToml(self.Path) + if not isinstance(self.Data.get("Lexicon"), dict): + self.Data["Lexicon"] = {"ConfigVersion": 1, "Inherit": False} + if not isinstance(self.Data.get("Tags"), dict): + self.Data["Tags"] = {} + self.Rebuild() + + @property + def Options(self) -> dict[str, Any]: + return self.Data["Lexicon"] + + @property + def Local(self) -> dict[str, dict[str, Any]]: + return self.Data["Tags"] + + @property + def Excluded(self) -> list[str]: + return [Name for Name in self.Options.get("ExcludeTags", []) or [] if isinstance(Name, str)] + + @property + def SourcePath(self) -> Path | None: + Source = self.Options.get("Source") + if not self.Options.get("Inherit") or not isinstance(Source, str) or not Source.strip() or Source == "None": + return None + return TagCore.ResolveLexiconSourcePath(self.Path, Source.strip()) + + def Rebuild(self) -> None: + """Works out every tag's origin and effective definition.""" + self.SourceError = "" + SourceTags: dict[str, dict[str, Any]] = {} + if self.SourcePath is not None: + try: + SourceTags = TagCore.ResolveLexiconTags(self.SourcePath) + except (OSError, ValueError) as Error: + self.SourceError = str(Error) + self.Views: list[TagView] = [] + for Name, Inherited in SourceTags.items(): + if Name in self.Excluded: + self.Views.append(TagView(Name, "Excluded", copy.deepcopy(Inherited), Inherited)) + elif Name in self.Local: + self.Views.append(TagView(Name, "Overridden" if self.Local[Name] else "Inherited", + TagCore.MergeTagDefinition(Inherited, self.Local[Name]), Inherited)) + else: + self.Views.append(TagView(Name, "Inherited", copy.deepcopy(Inherited), Inherited)) + for Name, Definition in self.Local.items(): + if Name not in SourceTags and isinstance(Definition, dict): + self.Views.append(TagView(Name, "Local", copy.deepcopy(Definition), {})) + + def View(self, Name: str) -> TagView | None: + return next((Item for Item in self.Views if Item.Name == Name), None) + + def Save(self) -> None: + Lines = ["# SaraTag Lexicon", "", "[Lexicon]"] + Lines += [f"{Key} = {TomlValue(Value)}" for Key, Value in self.Options.items()] + for Name, Definition in self.Local.items(): + Lines += ["", f"[Tags.{TomlKey(Name)}]"] + for Key, Value in Definition.items(): + if Key == "Values" and isinstance(Value, list): + Lines += ["Values = ["] + [f" {TomlValue(Item)}," for Item in Value] + ["]"] + else: + Lines.append(f"{Key} = {TomlValue(Value)}") + try: + WriteAtomically(self.Path, "\n".join(Lines).rstrip() + "\n") + except OSError as Error: + raise DataError(f"Couldn't save {self.Path.name}: {Error}") from Error + self.Rebuild() + + def _Commit(self, Check: str | None = None) -> None: + """Validates the effective tags, then saves; undoes the change on failure.""" + self.Rebuild() + for Item in self.Views: + Problem = ValidateDefinition(Item.Name, Item.Definition) if Item.Origin != "Excluded" else None + if Problem and (Check is None or Item.Name == Check): + self.Reload() + raise DataError(Problem) + try: + self.Save() + except DataError: + self.Reload() + raise + + # Options + + def SetOptions(self, Inherit: bool, Source: str) -> None: + self.Options["Inherit"] = Inherit + if Source.strip(): + self.Options["Source"] = Source.strip() + else: + self.Options.pop("Source", None) + if Inherit and self.SourcePath is None: + self.Reload() + raise DataError("Inherit needs a Source Lexicon.") + if Inherit and self.SourcePath is not None and self.SourcePath.resolve() == self.Path.resolve(): + self.Reload() + raise DataError("A Lexicon can't inherit from itself.") + self.Rebuild() + if self.SourceError: + Error = self.SourceError + self.Reload() + raise DataError(f"Can't use that Source: {Error}") + self._Commit() + + # Tags + + def AddTag(self, Name: str, Kind: str) -> None: + Name = Name.strip() + if not Name or not all(Character.isalnum() or Character in "_-" for Character in Name): + raise DataError("Tag names can use letters, digits, _ and -.") + if Name == "Parent" or any(Item.Name.casefold() == Name.casefold() for Item in self.Views): + raise DataError(f"There's already a tag called {Name}.") + if Kind not in TagTypes: + raise DataError(f"Unknown type {Kind}.") + self.Local[Name] = StarterDefinition(Kind) + self._Commit(Name) + + def RenameTag(self, OldName: str, NewName: str) -> None: + """Local tags only; inherited ones are named by their Source.""" + View = self.View(OldName) + if View is None or View.Origin != "Local": + raise DataError("Only local tags can be renamed; rename inherited ones in their Source.") + NewName = NewName.strip() + if NewName == OldName: + return + if not NewName or not all(Character.isalnum() or Character in "_-" for Character in NewName): + raise DataError("Tag names can use letters, digits, _ and -.") + if NewName == "Parent" or any(Item.Name.casefold() == NewName.casefold() and Item.Name != OldName for Item in self.Views): + raise DataError(f"There's already a tag called {NewName}.") + self.Data["Tags"] = {(NewName if Key == OldName else Key): Value for Key, Value in self.Local.items()} + self._Commit() + + def DeleteTag(self, Name: str) -> None: + """Deletes a local tag; an inherited one is excluded instead.""" + View = self.View(Name) + if View is None: + return + if View.Origin == "Local": + del self.Local[Name] + else: + self.Local.pop(Name, None) + self.Options["ExcludeTags"] = self.Excluded + [Name] + self._Commit() + + def RestoreTag(self, Name: str) -> None: + Remaining = [Item for Item in self.Excluded if Item != Name] + if Remaining: + self.Options["ExcludeTags"] = Remaining + else: + self.Options.pop("ExcludeTags", None) + self._Commit() + + def MoveTag(self, Name: str, Offset: int) -> None: + """Reorders local tags (the order Books are written in).""" + Names = list(self.Local) + if Name not in Names: + raise DataError("Only local tags can be moved; inherited ones follow their Source.") + Index = Names.index(Name) + Target = max(0, min(len(Names) - 1, Index + Offset)) + Names.insert(Target, Names.pop(Index)) + self.Data["Tags"] = {Key: self.Local[Key] for Key in Names} + self.Save() + + def SetProperty(self, Name: str, Property: str, Value: Any) -> None: + """Sets (or with Value None, removes) one property. On an inherited tag + this writes an override holding only what differs from the Source.""" + View = self.View(Name) + if View is None: + raise DataError(f"{Name} doesn't exist any more.") + Local = dict(self.Local.get(Name, {})) + if Value is None: + Local.pop(Property, None) + elif View.Origin != "Local" and View.Inherited.get(Property) == Value and type(View.Inherited.get(Property)) is type(Value): + Local.pop(Property, None) # same as inherited: no override needed + else: + Local[Property] = Value + if View.Origin == "Local" or Local: + self.Local[Name] = Local + else: + self.Local.pop(Name, None) + self._Commit(Name) + + def ChangeType(self, Name: str, NewKind: str) -> None: + View = self.View(Name) + if View is None or View.Origin != "Local": + raise DataError("Only local tags can change type.") + self.Local[Name] = ConvertDefinition(View.Definition, NewKind) + self._Commit(Name) + + +def TomlKey(Name: str) -> str: + return Name if Name and all(Character.isalnum() or Character in "_-" for Character in Name) else TomlValue(Name) + + +def KnownLexicons(Directories: list[Path]) -> list[Path]: + """Every Lexicon in Lexicons/, plus the ones inside the given Record + directories and their subdirectories.""" + Found: dict[Path, None] = {} + if LexiconsDir.is_dir(): + for FilePath in sorted(LexiconsDir.glob("*.toml"), key=lambda Item: Item.name.casefold()): + Found[FilePath.resolve()] = None + for Directory in Directories: + for Root, Names, _ in os.walk(Directory, onerror=lambda Error: None): + Names[:] = sorted(Name for Name in Names if not Name.startswith(".")) + Candidate = TagCore.GetLexiconPath(Path(Root)) + if Candidate.is_file(): + Found[Candidate.resolve()] = None + return list(Found) + + +def NewSharedLexicon(Name: str) -> Path: + """An empty Lexicon in Lexicons/ for other Lexicons to inherit from.""" + Name = Name.strip().removesuffix(".toml") + if not Name or "/" in Name or Name.startswith("."): + raise DataError("That isn't a valid file name.") + LexiconsDir.mkdir(parents=True, exist_ok=True) + FilePath = LexiconsDir / f"{Name}.toml" + if FilePath.exists(): + raise DataError(f"{FilePath.name} already exists.") + WriteAtomically(FilePath, "# SaraTag Lexicon\n\n[Lexicon]\nConfigVersion = 1\nInherit = false\n") + return FilePath diff --git a/LexiconsPage.py b/LexiconsPage.py new file mode 100644 index 0000000..b805df7 --- /dev/null +++ b/LexiconsPage.py @@ -0,0 +1,476 @@ +"""Lexicons: edit tag definitions. Left, every Lexicon (Lexicons/ and the +ones inside Record directories); middle, its options and tags; right, the +selected tag's properties. Every change is saved straight away. + +Inherited tags show their Source's values; changing one writes an override +with only that property. "Revert" drops the override again. +""" + +import os +from pathlib import Path +from typing import Any, Callable, Optional + +import flet as ft + +from Books import ParseNumber +from Core import ChipButton, DisplayPath, Heading, Note, Palette +from Library import DataError, FormatValue, LexiconsDir, TagType, TagTypes +from Lexicons import ( + CommonProperties, KnownLexicons, Lexicon, NewSharedLexicon, PropertyHelp, TagView, TypeProperties, +) +from Records import ListRecords +from Widgets import App, Badge, EmptyState, Panel, SectionTitle, TextInput + +OriginColors = {"Local": Palette.Mauve, "Inherited": Palette.Overlay0, "Overridden": Palette.Peach, "Excluded": Palette.Red} +OriginHelp = { + "Local": "Defined in this Lexicon.", + "Inherited": "Comes unchanged from the Source Lexicon.", + "Overridden": "Comes from the Source, with some properties changed here.", + "Excluded": "In the Source, but left out here (ExcludeTags).", +} + + +class LexiconsPage(ft.Container): + def __init__(self, TheApp: App): + super().__init__(expand=True) + self.App = TheApp + self.Paths: list[Path] = [] + self.UsedBy: dict[Path, list[str]] = {} + self.Current: Optional[Lexicon] = None + self.SelectedTag: Optional[str] = None + + self.Search = TextInput(TheApp, Hint="Find a Lexicon", Prefix=ft.Icons.SEARCH, OnChange=lambda e: self._BuildList()) + self.List = ft.ListView(spacing=2, expand=True) + Left = ft.Container( + ft.Column([ + SectionTitle("Lexicons", [ChipButton("New", lambda e: self.NewDialog(), ft.Icons.ADD)]), + self.Search, self.List, + ], spacing=8, expand=True), + width=270, bgcolor=Palette.Mantle, padding=ft.Padding.only(left=12, right=8, top=8), + border=ft.Border.only(right=ft.BorderSide(1, Palette.Surface1)), + ) + self.Middle = ft.Container(width=330, padding=12, border=ft.Border.only(right=ft.BorderSide(1, Palette.Surface1))) + self.Right = ft.Container(expand=True, padding=16) + self.content = ft.Row([Left, self.Middle, self.Right], spacing=0, expand=True, + vertical_alignment=ft.CrossAxisAlignment.STRETCH) + + def Refresh(self, Open: str = "") -> None: + self.page.on_keyboard_event = None + try: + Records = ListRecords()[0] + except DataError as Error: + self.App.Notify(str(Error), "error") + Records = [] + self.UsedBy = {} + for Record in Records: + self.UsedBy.setdefault(Record.LexiconPath.resolve(), []).append(Record.Name) + self.Paths = KnownLexicons([Record.Directory for Record in Records if Record.Directory.is_dir()]) + Target = Path(Open).resolve() if Open else (self.Current.Path if self.Current else None) + if Target is not None and Target not in self.Paths and Target.is_file(): + self.Paths.append(Target) + if Target is None and self.Paths: + Target = self.Paths[0] + self._Load(Target, Keep=not Open) + + def Leave(self) -> None: + pass + + def _Load(self, FilePath: Optional[Path], Keep: bool = False) -> None: + Previous = self.SelectedTag if Keep else None + self.Current = None + self.LoadError = "" + if FilePath is not None: + try: + self.Current = Lexicon(FilePath) + except DataError as Error: + self.LoadError = str(Error) + self.CurrentPath = FilePath + self.SelectedTag = Previous if self.Current and Previous and self.Current.View(Previous) else ( + self.Current.Views[0].Name if self.Current and self.Current.Views else None) + self.CurrentPath = FilePath + self._BuildAll() + + def _BuildAll(self) -> None: + self._BuildList(Update=False) + self._BuildMiddle() + self._BuildRight() + self.update() + + def _Label(self, FilePath: Path) -> str: + try: + return str(FilePath.relative_to(LexiconsDir.resolve())) + except ValueError: + return DisplayPath(str(FilePath)) + + def _BuildList(self, Update: bool = True) -> None: + Query = (self.Search.value or "").casefold() + Shared, Inside = [], [] + for FilePath in self.Paths: + if Query and Query not in str(FilePath).casefold(): + continue + Selected = FilePath == getattr(self, "CurrentPath", None) + Users = self.UsedBy.get(FilePath, []) + Tile = ft.Container( + ft.Column([ + ft.Text(FilePath.name, size=13, color=Palette.Mauve if Selected else Palette.Text, max_lines=1, + overflow=ft.TextOverflow.ELLIPSIS), + *([Note("Used by " + ", ".join(Users), Size=11)] if Users else []), + *([Note(DisplayPath(str(FilePath.parent)), Size=11)] if FilePath.parent != LexiconsDir.resolve() else []), + ], spacing=0, tight=True), + padding=ft.Padding.symmetric(horizontal=8, vertical=6), border_radius=4, ink=True, + bgcolor=Palette.Surface0 if Selected else None, on_click=lambda e, P=FilePath: self._Load(P), + ) + (Shared if FilePath.parent == LexiconsDir.resolve() else Inside).append(Tile) + Controls: list[ft.Control] = [] + if Shared: + Controls += [Note("In Lexicons/", Size=11)] + Shared + if Inside: + Controls += [Note("Inside directories", Size=11)] + Inside + self.List.controls = Controls or [Note("No Lexicons found.")] + if Update: + self.List.update() + + # Middle: options and tags + + def _BuildMiddle(self) -> None: + Lex = self.Current + if Lex is None: + self.Middle.content = EmptyState(self.LoadError or "No Lexicon selected.", Icon=ft.Icons.MENU_BOOK_OUTLINED) + return + Others = [FilePath for FilePath in self.Paths if FilePath != Lex.Path.resolve()] + SourceText = str(Lex.Options.get("Source", "") or "") + SourceText = "" if SourceText == "None" else SourceText + Choices = {self._Relative(Lex.Path, FilePath): FilePath for FilePath in Others} + if SourceText and SourceText not in Choices: + Choices = {SourceText: None} | Choices + SourcePicker = ft.Dropdown( + label="Source", value=SourceText or None, editable=True, enable_filter=True, dense=True, menu_height=320, + options=[ft.DropdownOption(key=Key, text=Key) for Key in Choices], expand=True, + ) + InheritSwitch = ft.Switch(label="Inherit tags from a Source", value=bool(Lex.Options.get("Inherit"))) + + def ApplyOptions(e=None) -> None: + Source = SourcePicker.value or "" + try: + Lex.SetOptions(bool(InheritSwitch.value), Source) + except DataError as Error: + self.App.Notify(str(Error), "error") + self._BuildAll() + return + self.App.Notify("Saved. Run Update to bring Books in line.", "success") + self._BuildAll() + + InheritSwitch.on_change = ApplyOptions + SourcePicker.on_select = ApplyOptions + + Header: list[ft.Control] = [ + Heading(Lex.Path.name), + Note(DisplayPath(str(Lex.Path.parent)), Size=11), + ft.Row([InheritSwitch]), + ft.Row([SourcePicker, ft.IconButton(ft.Icons.OPEN_IN_NEW, tooltip="Open the Source", + on_click=lambda e: self._Load(Lex.SourcePath) if Lex.SourcePath else None, + disabled=Lex.SourcePath is None)]), + ] + if Lex.SourceError: + Header.append(ft.Text(Lex.SourceError, color=Palette.Red, size=12)) + Tags = [] + for View in Lex.Views: + Selected = View.Name == self.SelectedTag + Tags.append(ft.Container( + ft.Row([ + ft.Text(View.Name, size=13, expand=True, color=Palette.Overlay0 if View.Origin == "Excluded" else Palette.Text, + style=ft.TextStyle(decoration=ft.TextDecoration.LINE_THROUGH) if View.Origin == "Excluded" else None), + Note(TagType(View.Definition) or "?", Size=11), + ft.Container(Badge(View.Origin, OriginColors[View.Origin]), tooltip=OriginHelp[View.Origin]), + ], spacing=6), + padding=ft.Padding.symmetric(horizontal=8, vertical=6), border_radius=4, ink=True, + bgcolor=Palette.Surface0 if Selected else None, + on_click=lambda e, Name=View.Name: self._SelectTag(Name), + )) + self.Middle.content = ft.Column([ + *Header, + ft.Divider(height=1, color=Palette.Surface1), + SectionTitle("Tags", [ChipButton("Add", lambda e: self.AddTagDialog(), ft.Icons.ADD)]), + ft.Column(Tags, spacing=2, scroll=ft.ScrollMode.AUTO, expand=True) if Tags else Note("No tags yet."), + ], spacing=8, expand=True) + + @staticmethod + def _Relative(LexiconPath: Path, Target: Path) -> str: + Relative = os.path.relpath(Target, LexiconPath.resolve().parent) + return Relative if Relative.startswith(".") else "./" + Relative + + def _SelectTag(self, Name: str) -> None: + self.SelectedTag = Name + self._BuildMiddle() + self._BuildRight() + self.update() + + # Right: one tag's properties + + def _BuildRight(self) -> None: + Lex = self.Current + View = Lex.View(self.SelectedTag) if Lex and self.SelectedTag else None + if View is None: + self.Right.content = EmptyState("Pick a tag." if Lex else "", Icon=ft.Icons.LABEL_OUTLINE if Lex else None) + return + Kind = TagType(View.Definition) + Local = Lex.Local.get(View.Name, {}) + Actions: list[ft.Control] = [] + if View.Origin == "Local": + Actions += [ + ChipButton("Rename", lambda e: self.RenameDialog(View), ft.Icons.DRIVE_FILE_RENAME_OUTLINE), + ft.IconButton(ft.Icons.ARROW_UPWARD, tooltip="Move up", on_click=lambda e: self._Do(lambda: Lex.MoveTag(View.Name, -1))), + ft.IconButton(ft.Icons.ARROW_DOWNWARD, tooltip="Move down", on_click=lambda e: self._Do(lambda: Lex.MoveTag(View.Name, 1))), + ChipButton("Delete", lambda e: self.DeleteTag(View), ft.Icons.DELETE_OUTLINE, Accent=Palette.Red), + ] + elif View.Origin == "Excluded": + Actions.append(ChipButton("Restore (stop excluding)", lambda e: self._Do(lambda: Lex.RestoreTag(View.Name), "Restored."), + ft.Icons.RESTORE)) + else: + Actions.append(ChipButton("Exclude from this Lexicon", lambda e: self._Do(lambda: Lex.DeleteTag(View.Name), "Excluded."), + ft.Icons.REMOVE_CIRCLE_OUTLINE, Accent=Palette.Red)) + if View.Origin == "Overridden": + Actions.append(ChipButton("Revert all overrides", lambda e: self.RevertAll(View), ft.Icons.UNDO)) + + TypePicker = ft.Dropdown( + label="Type", value=Kind or None, dense=True, width=180, disabled=View.Origin != "Local", + options=[ft.DropdownOption(key=Name, text=Name) for Name in TagTypes], + on_select=lambda e: self._ChangeType(View, e.control.value), + tooltip=None if View.Origin == "Local" else "Change the type in the Source Lexicon", + ) + Rows: list[ft.Control] = [] + if View.Origin != "Excluded": + for Property in CommonProperties + TypeProperties.get(Kind, []): + Rows.append(self._PropertyRow(Lex, View, Property, Local)) + return_note = { + "Local": "Changes here apply to every Book using this Lexicon (and Lexicons inheriting from it).", + "Inherited": "Changing a property here overrides it for this Lexicon only.", + "Overridden": "Orange properties are overridden here; Revert brings back the Source's value.", + "Excluded": "Excluded tags aren't part of this Lexicon. Clean unused tags removes them from Books.", + }[View.Origin] + self.Right.content = ft.Column([ + ft.Row([Heading(View.Name), Badge(View.Origin, OriginColors[View.Origin])], spacing=10), + Note(return_note), + ft.Row(Actions, wrap=True, spacing=8), + TypePicker if View.Origin != "Excluded" else ft.Container(), + ft.Divider(height=1, color=Palette.Surface1), + *Rows, + ], spacing=12, scroll=ft.ScrollMode.AUTO, expand=True) + + def _Do(self, Action: Callable[[], None], Success: str = "") -> None: + try: + Action() + except DataError as Error: + self.App.Notify(str(Error), "error") + else: + if Success: + self.App.Notify(Success, "success") + if self.Current and self.SelectedTag and not self.Current.View(self.SelectedTag): + self.SelectedTag = self.Current.Views[0].Name if self.Current.Views else None + self._BuildMiddle() + self._BuildRight() + self.update() + + def _Set(self, View: TagView, Property: str, Value: Any) -> None: + self._Do(lambda: self.Current.SetProperty(View.Name, Property, Value)) + + def _PropertyRow(self, Lex: Lexicon, View: TagView, Property: str, Local: dict[str, Any]) -> ft.Control: + Definition = View.Definition + Kind = TagType(Definition) + Origin = View.PropertyOrigin(Property, Local) + Present = Property in Definition + Value = Definition.get(Property) + Label = ft.Text(Property, size=13, width=120, + color=Palette.Peach if Origin == "Overridden" else Palette.Text, tooltip=PropertyHelp.get(Property)) + Trailing: list[ft.Control] = [] + if Origin == "Overridden": + Trailing.append(ft.IconButton(ft.Icons.UNDO, icon_size=16, tooltip="Revert to the Source's value", + on_click=lambda e: self._Set(View, Property, None))) + elif Property in ("Default", "Minimum", "Maximum", "EmptySymbol") and Present and View.Origin == "Local": + Trailing.append(ft.IconButton(ft.Icons.CLEAR, icon_size=16, tooltip=f"Remove {Property}", + on_click=lambda e: self._Set(View, Property, None))) + + if Property in ("Required", "ForceDefault"): + Editor: ft.Control = ft.Switch(value=bool(Value), on_change=lambda e: self._Set(View, Property, bool(e.control.value))) + elif Property == "Values": + Editor = self._ValuesEditor(View, list(Value or [])) + elif Property == "Default" and Kind == "List": + Options = [ft.DropdownOption(key=str(Item), text=str(Item)) for Item in Definition.get("Values", [])] + ByKey = {str(Item): Item for Item in Definition.get("Values", [])} + Editor = ft.Dropdown(value=str(Value) if Present else None, options=Options, dense=True, expand=True, + hint_text="(none)", on_select=lambda e: self._Set(View, "Default", ByKey.get(e.control.value))) + elif Property == "Default" and Kind == "Boolean": + Editor = ft.Dropdown(value=("Yes" if Value else "No") if Present else None, dense=True, width=140, hint_text="(none)", + options=[ft.DropdownOption(key="Yes", text="Yes"), ft.DropdownOption(key="No", text="No")], + on_select=lambda e: self._Set(View, "Default", e.control.value == "Yes")) + else: + Numeric = Property in ("Minimum", "Maximum") or (Property == "Default" and Kind in ("Number", "Rating")) + Editor = self._TextEditor(View, Property, "" if not Present else FormatValue(Value) if not Numeric else str(Value), + Numeric, Whole=Kind == "Rating") + return ft.Row([Label, ft.Container(Editor, expand=True), *Trailing], vertical_alignment=ft.CrossAxisAlignment.CENTER) + + def _TextEditor(self, View: TagView, Property: str, Shown: str, Numeric: bool, Whole: bool) -> ft.Control: + Field = TextInput(self.App, Value=Shown, Expand=True, Hint="(none)" if Numeric or Property == "Default" else None) + State = {"Saved": Shown} + + def Commit(e=None) -> None: + Text = Field.value or "" + if Text == State["Saved"]: + return + State["Saved"] = Text + if Numeric: + if not Text.strip(): + self._Set(View, Property, None) + return + try: + Value = ParseNumber(Text) + except ValueError: + self.App.Notify(f"{Property} must be a number.", "error") + return + if Whole and not isinstance(Value, int): + self.App.Notify(f"{Property} must be a whole number for a Rating.", "error") + return + self._Set(View, Property, Value) + else: + self._Set(View, Property, Text) + + Field.on_submit = Commit + OldBlur = Field.on_blur + + def Blur(e) -> None: + OldBlur(e) + Commit() + + Field.on_blur = Blur + return Field + + def _ValuesEditor(self, View: TagView, Values: list[Any]) -> ft.Control: + def Save(NewValues: list[Any]) -> None: + self._Set(View, "Values", NewValues) + + def Move(Index: int, Offset: int) -> None: + Target = Index + Offset + if 0 <= Target < len(Values): + NewValues = list(Values) + NewValues.insert(Target, NewValues.pop(Index)) + Save(NewValues) + + def Rename(Index: int) -> None: + def Done(Text: str) -> None: + Text = Text.strip() + if not Text or Text == str(Values[Index]): + return + NewValues = list(Values) + NewValues[Index] = Text + Save(NewValues) + Default = self.Current.View(View.Name).Definition.get("Default") if self.Current.View(View.Name) else None + if Default == Values[Index]: + self._Set(View, "Default", Text) + + self.App.Ask("Rename value", "Value", str(Values[Index]), Done, + Help="Books holding the old value keep it (and show as invalid) until you change them.") + + def Remove(Index: int) -> None: + Save([Item for Number, Item in enumerate(Values) if Number != Index]) + + Rows: list[ft.Control] = [] + for Index, Item in enumerate(Values): + Rows.append(ft.Row([ + ft.Text(str(Item), size=13, expand=True), + ft.IconButton(ft.Icons.ARROW_UPWARD, icon_size=14, on_click=lambda e, I=Index: Move(I, -1), disabled=Index == 0), + ft.IconButton(ft.Icons.ARROW_DOWNWARD, icon_size=14, on_click=lambda e, I=Index: Move(I, 1), + disabled=Index == len(Values) - 1), + ft.IconButton(ft.Icons.EDIT_OUTLINED, icon_size=14, on_click=lambda e, I=Index: Rename(I)), + ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=14, icon_color=Palette.Red, on_click=lambda e, I=Index: Remove(I)), + ], spacing=0, height=32)) + NewField = TextInput(self.App, Hint="Add a value…", Expand=True) + + def Add(e=None) -> None: + Text = (NewField.value or "").strip() + if Text: + Save(Values + [Text]) + + NewField.on_submit = Add + Rows.append(ft.Row([NewField, ft.IconButton(ft.Icons.ADD, on_click=Add)])) + return Panel(ft.Column(Rows, spacing=0), padding=6) + + # Dialogs and actions + + def _ChangeType(self, View: TagView, NewKind: str) -> None: + if NewKind == TagType(View.Definition): + return + + def Yes() -> None: + self._Do(lambda: self.Current.ChangeType(View.Name, NewKind), f"{View.Name} is now {NewKind}.") + + self.App.Confirm("Change type", f"Make {View.Name} a {NewKind}? Its type-specific properties are replaced; the " + "Default is converted when possible. Existing Book values aren't changed (Set all to defaults " + "on the Update page resets them).", Yes, "Change", Accent=Palette.Yellow) + + def AddTagDialog(self) -> None: + if self.Current is None: + return + NameField = TextInput(self.App, Label="Tag name", Autofocus=True) + KindPicker = ft.Dropdown(label="Type", value="Text", options=[ft.DropdownOption(key=Name, text=Name) for Name in TagTypes]) + + def Add(e=None) -> None: + Name = (NameField.value or "").strip() + try: + self.Current.AddTag(Name, KindPicker.value or "Text") + except DataError as Error: + self.App.Notify(str(Error), "error") + return + self.App.Close() + self.SelectedTag = Name + self.App.Notify(f"Added {Name}. Run Update to add it to Books.", "success") + self._BuildMiddle() + self._BuildRight() + self.update() + + NameField.on_submit = Add + self.App.ShowDialog("Add a tag", ft.Column([NameField, KindPicker, Note( + "Text: anything. Number: any number, optional Minimum/Maximum. Rating: whole points shown as symbols. " + "List: one of fixed Values. Boolean: yes or no.")], tight=True, spacing=10), [ + ChipButton("Cancel", lambda e: self.App.Close()), ChipButton("Add", Add, ft.Icons.ADD)], Width=460) + + def RenameDialog(self, View: TagView) -> None: + def Done(NewName: str) -> None: + try: + self.Current.RenameTag(View.Name, NewName) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + self.SelectedTag = NewName.strip() + self.App.Notify(f"Renamed to {NewName.strip()}. Books still hold the old key; Clean unused tags drops it, " + "and Update adds the new one.", "success") + self._BuildMiddle() + self._BuildRight() + self.update() + + self.App.Ask("Rename tag", "New name", View.Name, Done, "Rename") + + def DeleteTag(self, View: TagView) -> None: + self.App.Confirm("Delete tag", f"Delete {View.Name} from this Lexicon? Books keep their value until you run " + "Clean unused tags.", lambda: self._Do(lambda: self.Current.DeleteTag(View.Name), f"Deleted {View.Name}."), + "Delete") + + def RevertAll(self, View: TagView) -> None: + def Revert() -> None: + self.Current.Local.pop(View.Name, None) + self.Current.Save() + + self._Do(Revert, "Overrides removed.") + + def NewDialog(self) -> None: + def Done(Name: str) -> None: + try: + FilePath = NewSharedLexicon(Name) + except (DataError, OSError) as Error: + self.App.Notify(str(Error), "error") + return + self.Paths.append(FilePath.resolve()) + self._Load(FilePath.resolve()) + + self.App.Ask("New shared Lexicon", "File name (in Lexicons/)", "", Done, "Create", + "For tags several Records share: point their Lexicons' Source at it. A Record's own Lexicon is " + "made when you add the Record.") diff --git a/LibrariesPage.py b/LibrariesPage.py new file mode 100644 index 0000000..1d7b545 --- /dev/null +++ b/LibrariesPage.py @@ -0,0 +1,190 @@ +"""Libraries: named groups of Records, browsed as one list of files (each +Record's top-level files, with a Record column to sort and filter by).""" + +from pathlib import Path +from typing import Optional + +import flet as ft + +from Core import ChipButton, Heading, Note, Palette +from Library import DataError +from Records import CreateLibrary, DeleteLibrary, DirectorySize, LibraryEntry, ListLibraries, ListRecords, Record, SaveLibrary +from Widgets import App, EmptyState, HumanSize, Panel, Plural, SectionTitle, TextInput + + +class LibrariesPage(ft.Container): + def __init__(self, TheApp: App): + super().__init__(expand=True) + self.App = TheApp + self.Libraries: list[LibraryEntry] = [] + self.Records: list[Record] = [] + self.Selected: Optional[str] = None + self.Sizes: dict[Path, tuple[int, int]] = {} + self.SizeLabels: dict[Path, ft.Text] = {} + self.TotalLabel: Optional[ft.Text] = None + + self.List = ft.ListView(spacing=4, expand=True, padding=8) + Left = ft.Container( + ft.Column([ + ft.Container(SectionTitle("Libraries", [ChipButton("New", lambda e: self.NewDialog(), ft.Icons.ADD)]), + padding=ft.Padding.only(left=12, right=8, top=8)), + self.List, + ], spacing=0, expand=True), + width=300, bgcolor=Palette.Mantle, border=ft.Border.only(right=ft.BorderSide(1, Palette.Surface1)), + ) + self.Details = ft.Container(expand=True, padding=16) + self.content = ft.Row([Left, self.Details], spacing=0, expand=True, vertical_alignment=ft.CrossAxisAlignment.STRETCH) + + def Refresh(self) -> None: + self.page.on_keyboard_event = None + try: + self.Libraries = ListLibraries() + self.Records = ListRecords()[0] + except DataError as Error: + self.App.Notify(str(Error), "error") + self.Libraries, self.Records = [], [] + if self.Selected not in {Item.Name for Item in self.Libraries}: + self.Selected = self.Libraries[0].Name if self.Libraries else None + self._Build() + self.page.run_thread(self._MeasureSizes) + + def Leave(self) -> None: + pass + + def _Current(self) -> Optional[LibraryEntry]: + return next((Item for Item in self.Libraries if Item.Name == self.Selected), None) + + def _Build(self) -> None: + Controls = [] + for Item in self.Libraries: + Selected = Item.Name == self.Selected + Controls.append(ft.Container( + ft.Column([ + ft.Row([ft.Icon(ft.Icons.COLLECTIONS_BOOKMARK_OUTLINED, size=18, + color=Palette.Mauve if Item.Enabled else Palette.Overlay0), + ft.Text(Item.Name, size=14, expand=True, color=Palette.Text if Item.Enabled else Palette.Overlay0)]), + Note(Plural(len(Item.Members), "Record") + ("" if Item.Enabled else " · disabled"), Size=11), + ], spacing=2, tight=True), + padding=10, border_radius=6, ink=True, bgcolor=Palette.Surface0 if Selected else None, + border=ft.Border.all(1, Palette.Mauve if Selected else Palette.Surface0), + on_click=lambda e, Name=Item.Name: self._Select(Name), + )) + self.List.controls = Controls or [Note("No Libraries yet. A Library lets you browse several Records as one list.")] + self.Details.content = self._DetailView(self._Current()) + self.update() + + def _Select(self, Name: str) -> None: + self.Selected = Name + self._Build() + + def _MeasureSizes(self) -> None: + for Item in self.Records: + if Item.Directory not in self.Sizes and Item.Directory.is_dir(): + self.Sizes[Item.Directory] = DirectorySize(Item.Directory) + # Only the size labels change, so half-made edits in the form survive. + for Directory, Label in self.SizeLabels.items(): + Label.value = self._SizeText(Directory) + if self.TotalLabel is not None: + self.TotalLabel.value = self._TotalText(self._Current()) + if self.page is not None: + self.update() + + def _SizeText(self, Directory: Path) -> str: + Size = self.Sizes.get(Directory) + return f"{HumanSize(Size[0])} · {Plural(Size[1], 'file')}" if Size else "…" + + def _TotalText(self, Item: Optional[LibraryEntry]) -> str: + if Item is None: + return "" + Members = [Record for Record in self.Records if Record.Directory in Item.Members] + Sizes = [self.Sizes.get(Record.Directory) for Record in Members] + Total = (f"{HumanSize(sum(Size[0] for Size in Sizes))} in {Plural(sum(Size[1] for Size in Sizes), 'file')}" + if Sizes and all(Sizes) else "measuring…") + return f"{Plural(len(Members), 'Record')} · {Total}" + + def _DetailView(self, Item: Optional[LibraryEntry]) -> ft.Control: + if Item is None: + self.SizeLabels, self.TotalLabel = {}, None + return EmptyState("No Library selected.", "Make one with the New button.", ft.Icons.COLLECTIONS_BOOKMARK_OUTLINED) + NameField = TextInput(self.App, Label="Name", Value=Item.Name, Width=320) + EnabledSwitch = ft.Switch(label="Enabled (shown on the Browse page)", value=Item.Enabled) + Boxes: dict[Path, ft.Checkbox] = {} + Rows: list[ft.Control] = [] + Known = {Member.Directory for Member in self.Records} + self.SizeLabels = {} + for Member in self.Records: + Boxes[Member.Directory] = ft.Checkbox(value=Member.Directory in Item.Members) + self.SizeLabels[Member.Directory] = Note(self._SizeText(Member.Directory), Size=11) + Rows.append(ft.Row([Boxes[Member.Directory], ft.Text(Member.Name, size=13, expand=True), + self.SizeLabels[Member.Directory]])) + Missing = [Member for Member in Item.Members if Member not in Known] + for Member in Missing: + Rows.append(ft.Row([ft.Icon(ft.Icons.WARNING_AMBER, size=16, color=Palette.Yellow), + ft.Text(f"{Member} isn't a Record any more (dropped on save)", size=12, color=Palette.Yellow)])) + self.TotalLabel = Note(self._TotalText(Item)) + + def Save(e) -> None: + Chosen = [Directory for Directory, Box in Boxes.items() if Box.value] + try: + SaveLibrary(Item.Index, NameField.value or "", Chosen, EnabledSwitch.value) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + self.Selected = (NameField.value or "").strip() or "Unnamed library" + self.App.Notify("Library saved.", "success") + self.Refresh() + + def Delete(e) -> None: + def Yes() -> None: + try: + DeleteLibrary(Item.Index) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + self.Selected = None + self.App.Notify(f"Deleted {Item.Name}.", "success") + self.Refresh() + + self.App.Confirm("Delete Library", f"Delete the Library {Item.Name}? Its Records aren't touched.", Yes, "Delete") + + return ft.Column([ + Heading(Item.Name), + self.TotalLabel, + ft.Row([ + ChipButton("Browse", lambda e: self.App.Navigate("Browse", Library=Item.Name), ft.Icons.FOLDER_OPEN, + Disabled=not Item.Enabled), + ChipButton("Save", Save, ft.Icons.SAVE_OUTLINED), + ChipButton("Delete", Delete, ft.Icons.DELETE_OUTLINE, Accent=Palette.Red), + ], spacing=8), + ft.Divider(height=1, color=Palette.Surface1), + NameField, EnabledSwitch, + SectionTitle("Records in it"), + Panel(ft.Column(Rows, spacing=0), padding=8) if Rows else Note("There are no Records yet."), + ], spacing=12, scroll=ft.ScrollMode.AUTO, expand=True) + + def NewDialog(self) -> None: + NameField = TextInput(self.App, Label="Name", Autofocus=True) + Boxes = {Record.Directory: ft.Checkbox(label=Record.Name, value=False) for Record in self.Records} + + def Create(e=None) -> None: + Chosen = [Directory for Directory, Box in Boxes.items() if Box.value] + if not Chosen: + self.App.Notify("Tick at least one Record.", "warning") + return + try: + CreateLibrary(NameField.value or "", Chosen) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + self.App.Close() + self.Selected = (NameField.value or "").strip() or "New library" + self.App.Notify(f"Created {self.Selected}.", "success") + self.Refresh() + + self.App.ShowDialog("New Library", ft.Column([ + NameField, Note("Records in it:"), + ft.Column(list(Boxes.values()), spacing=0, scroll=ft.ScrollMode.AUTO, height=min(360, 40 * max(1, len(Boxes)))), + ], tight=True, spacing=8), [ + ChipButton("Cancel", lambda e: self.App.Close()), + ChipButton("Create", Create, ft.Icons.ADD), + ], Width=460) diff --git a/Library.py b/Library.py new file mode 100644 index 0000000..e45dccc --- /dev/null +++ b/Library.py @@ -0,0 +1,120 @@ +"""SaraTag's data library, .saratag/Core.py, loaded the same way SaraFem and +SaraView load it (by file path, as "SaraTagCore"). That file stays +stdlib-only and keeps its location and function names, because SaraFem and +SaraView point their CoreModulePath at .saratag/. + +Also the shared helpers on top of it: reading and writing TOML, Books and +SaraTagConf.toml. +""" + +import importlib.util +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + +import tomlkit + +_CorePath = Path(__file__).resolve().parent / ".saratag" / "Core.py" +_Spec = importlib.util.spec_from_file_location("SaraTagCore", _CorePath) +TagCore = importlib.util.module_from_spec(_Spec) +sys.modules["SaraTagCore"] = TagCore +_Spec.loader.exec_module(TagCore) + +DataDir: Path = TagCore.ApplicationDirectory +ConfPath: Path = TagCore.SaraTagConfPath +RecordsPath: Path = TagCore.SaraTagRecordsPath +HistoryPath: Path = TagCore.SaraTagHistoryPath +LexiconsDir = DataDir / "Lexicons" + +TagTypes = ["Text", "Number", "Rating", "List", "Boolean"] +Unset = "?" # a Book value nobody has filled in yet + + +class DataError(Exception): + """A SaraTag data file that can't be read or written; the message is shown as is.""" + + +def ReadToml(FilePath: Path) -> dict[str, Any]: + try: + with FilePath.open("rb") as TomlFile: + return tomllib.load(TomlFile) + except tomllib.TOMLDecodeError as Error: + raise DataError(f"Invalid TOML in {FilePath.name}: {Error}") from Error + except OSError as Error: + raise DataError(f"Couldn't read {FilePath}: {Error}") from Error + + +def WriteAtomically(FilePath: Path, Text: str) -> None: + """Write through a temporary sibling, so a crash never leaves half a file.""" + TemporaryPath = FilePath.with_name(f"{FilePath.name}.tmp") + try: + TemporaryPath.write_text(Text, encoding="utf-8") + TemporaryPath.replace(FilePath) + finally: + TemporaryPath.unlink(missing_ok=True) + + +def TomlValue(Value: Any) -> str: + """One value in TOML syntax, escaped properly (newlines and all).""" + return tomlkit.item(Value).as_string() + + +def TagType(Definition: dict[str, Any]) -> str: + """"Text", "Number", ... as written in TagTypes, whatever the case in the file.""" + Raw = str(Definition.get("Type", "")).strip().casefold() + return next((Name for Name in TagTypes if Name.casefold() == Raw), "") + + +def Bound(Definition: dict[str, Any], Name: str) -> Any: + """Minimum or Maximum (also accepting the old Min/Max spelling), or None.""" + Value = Definition.get(Name, Definition.get(Name[:3])) + if isinstance(Value, bool) or not isinstance(Value, (int, float)): + return None + return Value + + +def FormatValue(Value: Any, Definition: dict[str, Any] | None = None) -> str: + """A Book value for display: ratings as symbols, booleans as Yes/No.""" + if Definition and TagType(Definition) == "Rating" and isinstance(Value, int) and not isinstance(Value, bool): + return TagCore.FormatRating(Value, Definition) + if isinstance(Value, bool): + return "Yes" if Value else "No" + if isinstance(Value, list): + return ", ".join(str(Item) for Item in Value) + return str(Value) + + +# SaraTagConf.toml + +def LoadConf() -> tomlkit.TOMLDocument: + TagCore.EnsureConfigFiles() + try: + return tomlkit.parse(ConfPath.read_text(encoding="utf-8")) + except (OSError, tomlkit.exceptions.TOMLKitError) as Error: + raise DataError(f"Couldn't read {ConfPath.name}: {Error}") from Error + + +def ConfValue(Section: str, Key: str, Fallback: Any) -> Any: + try: + Table = LoadConf().get(Section, {}) + except DataError: + return Fallback + Value = Table.get(Key, Fallback) if isinstance(Table, dict) else Fallback + return Value.unwrap() if hasattr(Value, "unwrap") else Value + + +def SaveConfValues(Values: dict[str, dict[str, Any]]) -> None: + """{Section: {Key: Value}} into SaraTagConf.toml, keeping its comments.""" + Document = LoadConf() + for Section, Keys in Values.items(): + if Section not in Document: + Document[Section] = tomlkit.table() + for Key, Value in Keys.items(): + Document[Section][Key] = Value + WriteAtomically(ConfPath, tomlkit.dumps(Document)) + + +def ExpandUserPath(Text: str) -> Path: + return Path(os.path.expanduser(Text.strip())) diff --git a/Main.py b/Main.py new file mode 100644 index 0000000..b3249e9 --- /dev/null +++ b/Main.py @@ -0,0 +1,109 @@ +"""SaraTag: tag files with Books (per-file sidecars) defined by Lexicons. +The window and navigation; each page lives in its own *Page.py.""" + +import flet as ft + +from BrowsePage import BrowsePage +from Core import AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, EffectiveSettings, LoadConfig, Palette +from LexiconsPage import LexiconsPage +from LibrariesPage import LibrariesPage +from Library import TagCore +from RecordsPage import RecordsPage +from Settings import ShowSettingsDialog +from UpdatePage import UpdatePage +from Widgets import App + +Destinations = [ + ("Browse", ft.Icons.PHOTO_LIBRARY_OUTLINED, ft.Icons.PHOTO_LIBRARY), + ("Records", ft.Icons.FOLDER_SPECIAL_OUTLINED, ft.Icons.FOLDER_SPECIAL), + ("Libraries", ft.Icons.COLLECTIONS_BOOKMARK_OUTLINED, ft.Icons.COLLECTIONS_BOOKMARK), + ("Lexicons", ft.Icons.MENU_BOOK_OUTLINED, ft.Icons.MENU_BOOK), + ("Update", ft.Icons.SYNC_OUTLINED, ft.Icons.SYNC), +] +Names = [Name for Name, _, _ in Destinations] + + +def Main(Page: ft.Page) -> None: + TagCore.EnsureConfigFiles() + Config = LoadConfig() + Appearance = EffectiveSettings(Config) + ApplyAppearance(Appearance) # before anything reads Palette.X + + Page.title = AppName + Page.theme_mode = ft.ThemeMode.DARK + Page.theme = BuildTheme() + Page.dark_theme = BuildTheme() + Page.fonts = {BundledFontFamily: "fonts/Fixedsys_Excelsior_Regular.ttf"} + Page.window.title_bar_hidden = Appearance.HideWindowTitleBar + Page.window.icon = str(AssetsDir / "Icon.png") + Page.padding = 0 + Page.bgcolor = Palette.Base + Page.window.width = 1280 + Page.window.height = 820 + Page.window.min_width = 900 + Page.window.min_height = 560 + + TheApp = App(Page, Config) + Body = ft.Container(expand=True) + Pages: dict[str, ft.Control] = {} + State = {"Current": ""} + + def Navigate(Name: str, **Arguments) -> None: + if Name not in Pages: + return + Previous = Pages.get(State["Current"]) + if Previous is not None: + Previous.Leave() + State["Current"] = Name + Rail.selected_index = Names.index(Name) + Body.content = Pages[Name] + Page.update() + Pages[Name].Refresh(**Arguments) + + TheApp.Navigate = Navigate + Pages.update({ + "Browse": BrowsePage(TheApp), + "Records": RecordsPage(TheApp), + "Libraries": LibrariesPage(TheApp), + "Lexicons": LexiconsPage(TheApp), + "Update": UpdatePage(TheApp), + }) + + Rail = ft.NavigationRail( + selected_index=0, + label_type=ft.NavigationRailLabelType.ALL, + bgcolor=Palette.Mantle, + indicator_color=Palette.Surface0, + min_width=88, + destinations=[ft.NavigationRailDestination(icon=Icon, selected_icon=Selected, label=Name) + for Name, Icon, Selected in Destinations], + on_change=lambda e: Navigate(Names[e.control.selected_index]), + ) + SettingsButton = ft.IconButton( + icon=ft.Icons.SETTINGS_OUTLINED, + icon_color=Palette.Mauve, + tooltip="Settings", + # SaraTagConf.toml may have changed (hidden directories, ...), so reload the page. + on_click=lambda e: ShowSettingsDialog(TheApp, lambda: Navigate(State["Current"])), + ) + + Page.add(ft.Column([ + ft.Container( + content=ft.Row([ + ft.Image(src="Icon.png", width=28, height=28, filter_quality=ft.FilterQuality.NONE), + ft.Text(AppName, size=24, color=Palette.Mauve), + ft.Container(expand=True), + SettingsButton, + ], spacing=10), + padding=ft.Padding.symmetric(horizontal=16, vertical=8), + bgcolor=Palette.Mantle, + border=ft.Border.only(bottom=ft.BorderSide(1, Palette.Surface1)), + ), + ft.Row([Rail, ft.VerticalDivider(width=1, color=Palette.Surface1), Body], expand=True, spacing=0, + vertical_alignment=ft.CrossAxisAlignment.STRETCH), + ], expand=True, spacing=0)) + Navigate(Config.StartPage if Config.StartPage in Pages else "Browse") + + +if __name__ == "__main__": + ft.run(Main, assets_dir=str(AssetsDir)) diff --git a/Packaging/Launch.sh b/Packaging/Launch.sh new file mode 100755 index 0000000..514065b --- /dev/null +++ b/Packaging/Launch.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Desktop-entry launcher: resolves the venv relative to this script rather +# than the caller's cwd, since .desktop Exec= lines are invoked with no +# guaranteed working directory. +cd "$(dirname "$(readlink -f "$0")")/.." || exit 1 +exec .venv/bin/python Main.py "$@" diff --git a/Query.py b/Query.py new file mode 100644 index 0000000..bf3d2ca --- /dev/null +++ b/Query.py @@ -0,0 +1,221 @@ +"""The Browse page's filter and sort. + +Filter: whitespace-separated conditions that must all match, e.g. + *Beach* name:*Beach* ext:png type:image size:>=5MB modified:>=2026-07-01 + Colour:Orange tag.Rating:>=2 Name:!=? +Text compares case-insensitively with shell wildcards (* ? [..]); numbers, +sizes and dates also take > >= < <= != (= is the default). + +Sort: a chain of criteria (Name, Modified, Accessed, Created, FileType, +FileSize, Record, Tag:), the first one deciding most. Files without +a value for a criterion always go last. Directories come before files. +""" + +import os +import re +from dataclasses import dataclass, field +from datetime import datetime +from fnmatch import fnmatchcase +from functools import cmp_to_key +from pathlib import Path +from typing import Any + +from Books import Book +from Library import FormatValue, TagType, Unset + +ImageExtensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"} +VideoExtensions = {".mp4", ".mkv", ".webm", ".mov", ".avi", ".m4v", ".wmv", ".flv", ".mpg", ".mpeg"} +Operators = (">=", "<=", "!=", ">", "<", "=") +SortNames = { + "Name": "Name", "Modified": "Modified", "Accessed": "Accessed", "Created": "Created (changed)", + "FileType": "File type", "FileSize": "File size", "Record": "Record", +} + + +@dataclass +class Entry: + Path: Path + IsDirectory: bool + Stat: os.stat_result | None = None + Book: Book | None = None + BookError: str = "" + RecordName: str = "" # which Record it's from, when browsing a Library + Extra: dict[str, Any] = field(default_factory=dict) + + @property + def Kind(self) -> str: + if self.IsDirectory: + return "directory" + Suffix = self.Path.suffix.lower() + return "image" if Suffix in ImageExtensions else "video" if Suffix in VideoExtensions else "file" + + def TagValue(self, TagName: str) -> Any: + """The value, matched case-insensitively, or None if unset.""" + if self.Book is None: + return None + Wanted = TagName.casefold() + for Key, Value in self.Book.Tags.items(): + if Key.casefold() == Wanted: + return None if Value == Unset else Value + return None + + def TagDefinition(self, TagName: str) -> dict[str, Any]: + if self.Book is None: + return {} + Wanted = TagName.casefold() + return next((Definition for Key, Definition in self.Book.Definitions.items() if Key.casefold() == Wanted), {}) + + +def NaturalKey(Text: str) -> tuple: + return tuple((0, int(Part)) if Part.isdigit() else (1, Part) for Part in re.split(r"(\d+)", Text.casefold()) if Part) + + +# Filter + +def ParseFilter(Text: str) -> list[tuple[str, str, str]]: + """[(field, operator, value)]. Raises ValueError with a readable message.""" + Conditions = [] + for Token in Text.split(): + FieldName, RawValue = Token.split(":", 1) if ":" in Token else ("name", Token) + if not FieldName: + raise ValueError(f"'{Token}' is missing a field name before the colon") + Operator = next((Candidate for Candidate in Operators if RawValue.startswith(Candidate)), "=") + Value = RawValue[len(Operator):] if RawValue.startswith(Operator) else RawValue + if not Value: + raise ValueError(f"'{Token}' needs a value") + Conditions.append((FieldName.strip(), Operator, Value.strip())) + return Conditions + + +def _CompareText(Actual: str, Operator: str, Expected: str) -> bool: + Actual, Expected = Actual.casefold(), Expected.casefold() + if Operator in ("=", "!="): + return fnmatchcase(Actual, Expected) == (Operator == "=") + return {">": Actual > Expected, "<": Actual < Expected, ">=": Actual >= Expected, "<=": Actual <= Expected}[Operator] + + +def _CompareNumbers(Actual: float, Operator: str, Expected: float) -> bool: + return {"=": Actual == Expected, "!=": Actual != Expected, ">": Actual > Expected, "<": Actual < Expected, + ">=": Actual >= Expected, "<=": Actual <= Expected}[Operator] + + +def _ParseSize(Text: str) -> float: + Text = Text.strip().casefold() + for Unit, Multiplier in (("tb", 1024 ** 4), ("gb", 1024 ** 3), ("mb", 1024 ** 2), ("kb", 1024), ("b", 1)): + if Text.endswith(Unit): + return float(Text[:-len(Unit)].strip()) * Multiplier + return float(Text) + + +def _ParseBoolean(Text: str) -> bool | None: + Text = Text.casefold() + return True if Text in ("true", "yes", "on", "1") else False if Text in ("false", "no", "off", "0") else None + + +def Matches(Item: Entry, Conditions: list[tuple[str, str, str]]) -> bool: + return all(_MatchesOne(Item, *Condition) for Condition in Conditions) + + +def _MatchesOne(Item: Entry, FieldName: str, Operator: str, Expected: str) -> bool: + Field = FieldName.casefold() + if Field in ("name", "filename"): + return _CompareText(Item.Path.name, Operator, Expected) + if Field in ("extension", "ext"): + return _CompareText(Item.Path.suffix.lstrip("."), Operator, Expected.lstrip(".")) + if Field == "type": + return _CompareText(Item.Kind, Operator, Expected) + if Field == "record": + return _CompareText(Item.RecordName, Operator, Expected) + if Field in ("size", "modified", "accessed", "created"): + if Item.Stat is None: + return False + try: + if Field == "size": + return _CompareNumbers(Item.Stat.st_size, Operator, _ParseSize(Expected)) + Times = {"modified": Item.Stat.st_mtime, "accessed": Item.Stat.st_atime, "created": Item.Stat.st_ctime} + return _CompareNumbers(Times[Field], Operator, datetime.fromisoformat(Expected).timestamp()) + except ValueError: + return False + + TagName = FieldName[4:] if Field.startswith("tag.") else FieldName + if Item.Book is None: + return False + Raw = next((Value for Key, Value in Item.Book.Tags.items() if Key.casefold() == TagName.casefold()), None) + if Raw is None: + return False + if Raw == Unset or isinstance(Raw, str): + return _CompareText(str(Raw), Operator, Expected) + if isinstance(Raw, bool): + Wanted = _ParseBoolean(Expected) + return Wanted is not None and Operator in ("=", "!=") and (Raw == Wanted) == (Operator == "=") + if isinstance(Raw, (int, float)): + try: + return _CompareNumbers(float(Raw), Operator, float(Expected)) + except ValueError: + return _CompareText(str(Raw), Operator, Expected) + return _CompareText(FormatValue(Raw), Operator, Expected) + + +# Sort + +def CriterionLabel(Criterion: str) -> str: + return f"Tag: {Criterion[4:]}" if Criterion.startswith("Tag:") else SortNames.get(Criterion, Criterion) + + +def _CriterionValue(Item: Entry, Criterion: str) -> tuple[bool, Any]: + """(missing, comparable value).""" + Stat = Item.Stat + if Criterion == "Name": + return False, NaturalKey(Item.Path.name) + if Criterion in ("Modified", "Accessed", "Created", "FileSize"): + if Stat is None: + return True, 0 + return False, {"Modified": Stat.st_mtime, "Accessed": Stat.st_atime, "Created": Stat.st_ctime, + "FileSize": Stat.st_size}[Criterion] + if Criterion == "FileType": + return False, NaturalKey("" if Item.IsDirectory else Item.Path.suffix.lstrip(".")) + if Criterion == "Record": + return not Item.RecordName, NaturalKey(Item.RecordName) + if Criterion.startswith("Tag:"): + TagName = Criterion[4:] + Value = Item.TagValue(TagName) + if Value is None: + return True, 0 + Definition = Item.TagDefinition(TagName) + Kind = TagType(Definition) + if Kind == "Boolean": + return False, int(bool(Value)) + if Kind in ("Number", "Rating"): + try: + return False, float(Value) + except (TypeError, ValueError): + return True, 0 + if Kind == "List" and isinstance(Definition.get("Values"), list): + Values = Definition["Values"] + return False, Values.index(Value) if Value in Values else len(Values) + return False, NaturalKey(str(Value)) + return False, NaturalKey(Item.Path.name) + + +def _Compare(Left: Any, Right: Any) -> int: + try: + return (Left > Right) - (Left < Right) + except TypeError: + return _Compare(str(Left), str(Right)) + + +def SortEntries(Items: list[Entry], Criteria: list[str], Descending: bool) -> list[Entry]: + Direction = -1 if Descending else 1 + + def Compare(Left: Entry, Right: Entry) -> int: + for Criterion in Criteria: + LeftMissing, LeftValue = _CriterionValue(Left, Criterion) + RightMissing, RightValue = _CriterionValue(Right, Criterion) + if LeftMissing != RightMissing: + return 1 if LeftMissing else -1 # missing values last, either direction + if not LeftMissing and (Result := _Compare(LeftValue, RightValue)): + return Result * Direction + return _Compare(NaturalKey(Left.Path.name), NaturalKey(Right.Path.name)) * Direction + + Key = cmp_to_key(Compare) + return sorted((Item for Item in Items if Item.IsDirectory), key=Key) + sorted((Item for Item in Items if not Item.IsDirectory), key=Key) diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ac7064 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# SaraTag + +Tag your files without touching them, built with Flet. Every file gets a **Book**, a hidden sidecar +(`..book.toml`) with its tag values. The tags themselves are defined in **Lexicons**: +which tags exist, their type (Text, Number, Rating, List, Boolean), defaults and limits. + +A **Record** is a directory you register with SaraTag, with its Lexicon. Subdirectories can get their own +Lexicon, which inherits from the one above it. A **Library** groups Records so they can be browsed as one list. + +SaraFem and SaraView read Books through `.saratag/Core.py`, so that file stays where it is, stdlib-only, +with its function names unchanged. + +## Pages + +- **Browse**: Records and Libraries on the left, files in the middle, the preview and tags on the right. + The filter takes conditions like `*beach* ext:png Rating:>=2 size:>5MB modified:>=2026-07-01` (hover the `?` + for all of them). Sorting chains criteria (Name, dates, size, type, Record, any tag) in the order you click them. + Tick files to edit their shared tags at once; mixed values show as mixed until set. Click an image preview + for a fullscreen view (Left/Right step through). Videos open in `[Media] VideoPlayer`. + **Right-click** a file (Open, View Fullscreen, Rename (the Book follows), Create Its Book, Edit Its Lexicon, + Copy Path), a directory (Open, Add a Lexicon / Edit Its Lexicon) or the background (Tick All, Create Books + for Ticked Files, ...). + Keys: Up/Down, Enter opens, Space ticks, F2 renames, Ctrl+A ticks all, Backspace goes up, Escape. +- **Records**: add, rename (the name or the directory on disk), enable/disable, change the Lexicon, unregister. + Below, the subdirectory tree: purple has a Lexicon everywhere, yellow partly, grey not at all. Add or remove a + subdirectory's Lexicon, or tag a whole subtree from its right-click menu. +- **Libraries**: make, edit and delete Libraries, with each Record's size. +- **Lexicons**: every Lexicon in `Lexicons/` and inside Record directories. Set Inherit/Source, add, rename, + delete, reorder and retype tags, edit their properties and List values. Inherited tags are shown as Inherited, + Overridden (only the changed properties are written, Revert drops them) or Excluded. +- **Update**: bring Books in line with their Lexicons, for every Record or one: add missing Books and tags, + clean unused tags, reset to defaults, regenerate, or repair Parent links. Preview only runs it without writing. + +The gear opens **Settings**, laid out like SaraFem's, SaraSuite's and SaraSort's: the shared SaraSuite +appearance and export/import, SaraTag's own settings, and `SaraTagConf.toml`'s Browse, Lexicon and History options. + +## Where things live + +| Path | What | +| --- | --- | +| `~/.config/SaraSuite/Config.toml` | shared SaraSuite settings (theme, font, title bar) | +| `~/.config/SaraSuite/SaraTag/Config.toml` | SaraTag's own GUI settings | +| `SaraTagConf.toml` | SaraTag's data settings (portable, next to the program) | +| `.SaraTagRecords.toml` | Records and Libraries | +| `.SaraTagHistory.toml` | where each Record/Library was left, recent files, updater runs | +| `Lexicons/` | Lexicons (not in git) | + +## Running + +`python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`, then `Packaging/Launch.sh` +(or `.venv/bin/python Main.py`; `SaraTag.py` still works too). + +## Files + +| File | What | +| --- | --- | +| `Main.py` | window, navigation | +| `Core.py` | palette/theme, SaraTag's GUI settings, export/import (same as the other SaraSuite programs) | +| `Library.py` | loads `.saratag/Core.py`; TOML and SaraTagConf.toml helpers | +| `Books.py` | reading, writing and validating Books; renaming files with their Book | +| `Records.py` | Records, Libraries and directory Lexicons | +| `Lexicons.py` | editing a Lexicon (inheritance, overrides, types) | +| `Updater.py` | the Update page's modes | +| `Query.py` | Browse's filter and sort | +| `History.py` | `.SaraTagHistory.toml` | +| `TagEditor.py` | the tag panel | +| `Widgets.py` | the shared App context, right-click menus, small controls | +| `Settings.py` | the Settings dialog | +| `*Page.py` | one per page | diff --git a/Records.py b/Records.py new file mode 100644 index 0000000..cddecb2 --- /dev/null +++ b/Records.py @@ -0,0 +1,426 @@ +"""Records, Libraries and directory Lexicons. + +A Record is a registered collection directory with its Lexicon (the tag +definitions). A Library groups several Records so they can be browsed as one +list. Both live in .SaraTagRecords.toml, edited with tomlkit so comments and +layout survive. Subdirectories of a Record get tagged by giving them their +own Lexicon, which inherits from the one above it. +""" + +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import tomlkit + +from Library import ConfValue, DataError, DataDir, LexiconsDir, RecordsPath, TagCore, TomlValue, WriteAtomically + +Home = Path.home().resolve() + + +@dataclass +class Record: + Index: int # position among the file's [[Record]] tables + Name: str + Directory: Path + LexiconPath: Path + Enabled: bool + Raw: dict[str, Any] + + @property + def Problem(self) -> str: + if not self.Directory.is_dir(): + return "Its directory is missing." + if not self.LexiconPath.is_file(): + return "Its Lexicon is missing." + return "" + + +@dataclass +class LibraryEntry: + Index: int + Name: str + Members: list[Path] # Record directories + Enabled: bool + + +def LoadDocument() -> tomlkit.TOMLDocument: + TagCore.EnsureConfigFiles() + try: + Document = tomlkit.parse(RecordsPath.read_text(encoding="utf-8")) + except (OSError, tomlkit.exceptions.TOMLKitError) as Error: + raise DataError(f"Couldn't read {RecordsPath.name}: {Error}") from Error + # Very old files used "Records = []" instead of [[Record]] tables. + if "Records" in Document and not Document["Records"]: + del Document["Records"] + return Document + + +def SaveDocument(Document: tomlkit.TOMLDocument) -> None: + try: + # A blank line before every table, like the file was always written. + Text = re.sub(r"(? list: + Tables = Document.get(Name) + return list(Tables) if Tables is not None else [] + + +def _AppendTable(Document: tomlkit.TOMLDocument, Name: str, Values: dict[str, Any]) -> None: + if Name not in Document: + Document[Name] = tomlkit.aot() + Table = tomlkit.table() + for Key, Value in Values.items(): + Table[Key] = Value + Document[Name].append(Table) + + +def _BaseAndRelative(TargetPath: Path) -> tuple[str, str]: + """Paths under home are stored relative to it, as the old Manager did.""" + try: + return str(Home), str(TargetPath.resolve().relative_to(Home)) + except ValueError: + return "", str(TargetPath.resolve()) + + +def _Plain(Value: Any) -> Any: + return Value.unwrap() if hasattr(Value, "unwrap") else Value + + +def ListRecords(Document: tomlkit.TOMLDocument | None = None) -> tuple[list[Record], list[str]]: + """Every [[Record]], enabled or not, plus problems reading them.""" + Document = Document if Document is not None else LoadDocument() + Records: list[Record] = [] + Errors: list[str] = [] + for Index, Raw in enumerate(_Tables(Document, "Record")): + Raw = _Plain(Raw) + if not isinstance(Raw, dict): + Errors.append(f"Record {Index + 1} isn't a table.") + continue + try: + Directory = TagCore.ResolveRecordPath(Raw) + LexiconPath = TagCore.ResolveRecordLexiconPath(Raw) + except ValueError as Error: + Errors.append(f"Record {Index + 1}: {Error}") + continue + Name = str(Raw.get("Name") or Directory.name) + Records.append(Record(Index, Name, Directory, LexiconPath, bool(Raw.get("Enabled", True)), Raw)) + return Records, Errors + + +def ListLibraries(Document: tomlkit.TOMLDocument | None = None) -> list[LibraryEntry]: + Document = Document if Document is not None else LoadDocument() + Libraries: list[LibraryEntry] = [] + for Index, Raw in enumerate(_Tables(Document, "Library")): + Raw = _Plain(Raw) + if not isinstance(Raw, dict): + continue + Base = Path(str(Raw.get("Base") or DataDir)).expanduser() + Base = Base if Base.is_absolute() else DataDir / Base + Members = [] + for Value in Raw.get("Records", []) or []: + if isinstance(Value, str) and Value.strip(): + MemberPath = Path(Value).expanduser() + Members.append((MemberPath if MemberPath.is_absolute() else Base / MemberPath).resolve()) + Name = str(Raw.get("Name") or "Unnamed library").strip() + Libraries.append(LibraryEntry(Index, Name, Members, bool(Raw.get("Enabled", True)))) + return Libraries + + +def FindRecord(Directory: Path) -> Record | None: + Target = Directory.resolve() + return next((TheRecord for TheRecord in ListRecords()[0] if TheRecord.Directory == Target), None) + + +# Records + +def SuggestedLexiconPath(Directory: Path) -> Path: + """Where a new Record's Lexicon goes: Lexicons/{Source}_{DirectoryName}.toml, + Source being the parent directory's name (the existing set's naming).""" + return LexiconsDir / TagCore.FormatLexiconFileName(Directory) + + +def SharedLexiconFor(Directory: Path) -> Path | None: + """A shared Lexicon for the Record's group, e.g. Lexicons/Pets.toml for ~/Pets/*.""" + Candidate = LexiconsDir / f"{TagCore.GetLexiconSourceName(Directory)}.toml" + return Candidate if Candidate.is_file() else None + + +def NewLexiconText(Name: str, SourcePath: Path | None, LexiconPath: Path) -> str: + Lines = ["# SaraTag Lexicon", "", "[Lexicon]", "ConfigVersion = 1", f"Inherit = {'true' if SourcePath else 'false'}"] + if SourcePath: + # [Lexicon] RelativeSourcePaths in SaraTagConf.toml: relative keeps + # working when the whole tree moves, absolute when only one part does. + if ConfValue("Lexicon", "RelativeSourcePaths", True): + Source = os.path.relpath(SourcePath, LexiconPath.parent) + Source = Source if Source.startswith(".") else "./" + Source + else: + Source = str(SourcePath.resolve()) + Lines.append(f"Source = {TomlValue(Source)}") + Lines.append(f"Name = {TomlValue(Name)}") + return "\n".join(Lines) + "\n" + + +def AddRecord(Directory: Path, Name: str, LexiconPath: Path | None = None) -> Record: + """Registers a directory. Without LexiconPath, a new Lexicon is made in + Lexicons/ (inheriting the group's shared one, if there is one), unless + that file already exists, in which case it's used as is.""" + Directory = Directory.resolve() + if not Directory.is_dir(): + raise DataError(f"{Directory} isn't a directory.") + Document = LoadDocument() + if any(TheRecord.Directory == Directory for TheRecord in ListRecords(Document)[0]): + raise DataError(f"{Directory.name} is already a Record.") + if LexiconPath is None: + LexiconPath = SuggestedLexiconPath(Directory) + if not LexiconPath.exists(): + LexiconsDir.mkdir(parents=True, exist_ok=True) + WriteAtomically(LexiconPath, NewLexiconText(Name or Directory.name, SharedLexiconFor(Directory), LexiconPath)) + Base, RelativePath = _BaseAndRelative(Directory) + Values: dict[str, Any] = {"Name": Name.strip() or Directory.name} + if Base: + Values["Base"] = Base + try: + LexiconText = str(LexiconPath.resolve().relative_to(Base)) + except ValueError: + LexiconText = str(LexiconPath.resolve()) + else: + LexiconText = str(LexiconPath.resolve()) + Values.update({"Path": RelativePath, "Lexicon": LexiconText, "Enabled": True}) + _AppendTable(Document, "Record", Values) + SaveDocument(Document) + return FindRecord(Directory) + + +def _RecordTable(Document: tomlkit.TOMLDocument, Index: int): + Tables = Document.get("Record") + if Tables is None or Index >= len(Tables): + raise DataError("That Record isn't in .SaraTagRecords.toml any more; reload.") + return Tables[Index] + + +def SetRecordValues(Index: int, **Values: Any) -> None: + """Name, Enabled, ... on one [[Record]].""" + Document = LoadDocument() + Table = _RecordTable(Document, Index) + for Key, Value in Values.items(): + Table[Key] = Value + SaveDocument(Document) + + +def SetRecordLexicon(Index: int, LexiconPath: Path) -> None: + Document = LoadDocument() + Table = _RecordTable(Document, Index) + Base = str(_Plain(Table.get("Base", "")) or "") + try: + Table["Lexicon"] = str(LexiconPath.resolve().relative_to(Base)) if Base else str(LexiconPath.resolve()) + except ValueError: + Table["Lexicon"] = str(LexiconPath.resolve()) + SaveDocument(Document) + + +def RemoveRecord(Index: int) -> None: + """Unregisters a Record and drops it from Libraries. Its files, Books and + Lexicon stay where they are.""" + Document = LoadDocument() + TheRecord = next((Item for Item in ListRecords(Document)[0] if Item.Index == Index), None) + del Document["Record"][Index] + if TheRecord is not None: + _ReplaceLibraryMember(Document, TheRecord.Directory, None) + SaveDocument(Document) + + +def _ReplaceLibraryMember(Document: tomlkit.TOMLDocument, Old: Path, New: Path | None) -> None: + for Library in ListLibraries(Document): + if Old not in Library.Members: + continue + Members = [New if Member == Old else Member for Member in Library.Members] + _WriteLibraryMembers(Document["Library"][Library.Index], [Member for Member in Members if Member is not None]) + + +def RenameRecordDirectory(Index: int, NewDirectoryName: str) -> Path: + """Renames a Record's directory on disk, and its Lexicon when that lives + inside the directory, then updates the Record and Libraries. Books keep + their old Parent until "Repair Book parents" runs, which the caller does.""" + Document = LoadDocument() + TheRecord = next((Item for Item in ListRecords(Document)[0] if Item.Index == Index), None) + if TheRecord is None: + raise DataError("That Record isn't in .SaraTagRecords.toml any more; reload.") + NewDirectoryName = NewDirectoryName.strip() + if not NewDirectoryName or "/" in NewDirectoryName or NewDirectoryName.startswith("."): + raise DataError("That isn't a valid directory name.") + OldPath = TheRecord.Directory + NewPath = OldPath.with_name(NewDirectoryName) + if NewPath == OldPath: + return OldPath + if NewPath.exists(): + raise DataError(f"'{NewDirectoryName}' already exists.") + try: + OldPath.rename(NewPath) + except OSError as Error: + raise DataError(f"Couldn't rename the directory: {Error}") from Error + Table = Document["Record"][Index] + Base = str(_Plain(Table.get("Base", "")) or "") + try: + Table["Path"] = str(NewPath.relative_to(Base)) if Base else str(NewPath) + except ValueError: + Table["Path"] = str(NewPath) + if TheRecord.LexiconPath.parent == OldPath: + try: + Table["Lexicon"] = str((NewPath / TheRecord.LexiconPath.name).relative_to(Base)) if Base else str(NewPath / TheRecord.LexiconPath.name) + except ValueError: + Table["Lexicon"] = str(NewPath / TheRecord.LexiconPath.name) + _ReplaceLibraryMember(Document, OldPath, NewPath) + try: + SaveDocument(Document) + except DataError: + NewPath.rename(OldPath) + raise + return NewPath + + +# Libraries + +def _WriteLibraryMembers(Table, Members: list[Path]) -> None: + """Members as paths relative to their common parent (stored as Base).""" + if not Members: + Table["Records"] = tomlkit.array() + return + Common = Path(os.path.commonpath([str(Member) for Member in Members])) + if Common in Members: # every entry must stay a non-empty relative path + Common = Common.parent + Table["Base"] = str(Common) + Array = tomlkit.array() + Array.multiline(True) + for Member in Members: + Array.append(str(Member.relative_to(Common))) + Table["Records"] = Array + + +def CreateLibrary(Name: str, Members: list[Path]) -> None: + Document = LoadDocument() + Names = {Library.Name.casefold() for Library in ListLibraries(Document)} + Name = Name.strip() or "New library" + if Name.casefold() in Names: + raise DataError(f"There's already a Library called {Name}.") + _AppendTable(Document, "Library", {"Name": Name}) + Table = Document["Library"][-1] + _WriteLibraryMembers(Table, Members) + Table["Enabled"] = True + SaveDocument(Document) + + +def SaveLibrary(Index: int, Name: str, Members: list[Path], Enabled: bool) -> None: + Document = LoadDocument() + Name = Name.strip() or "Unnamed library" + if any(Library.Name.casefold() == Name.casefold() and Library.Index != Index for Library in ListLibraries(Document)): + raise DataError(f"There's already a Library called {Name}.") + Table = Document["Library"][Index] + Table["Name"] = Name + _WriteLibraryMembers(Table, Members) + Table["Enabled"] = Enabled + SaveDocument(Document) + + +def DeleteLibrary(Index: int) -> None: + Document = LoadDocument() + del Document["Library"][Index] + SaveDocument(Document) + + +# Directory Lexicons (tagging a Record's subdirectories) + +def LexiconFor(Directory: Path) -> Path | None: + """The Lexicon a directory uses (a Record's own, or one inside the + directory), or None if it isn't tagged.""" + LexiconPath = TagCore.ResolveLexiconForDirectory(Directory) + return LexiconPath if LexiconPath.is_file() else None + + +def InheritedLexiconFor(Directory: Path, Stop: Path | None = None) -> Path | None: + """The nearest tagged ancestor's Lexicon (up to Stop, a Record directory).""" + Current = Directory.resolve().parent + while True: + Found = LexiconFor(Current) + if Found: + return Found + if Current == Stop or Current.parent == Current: + return None + Current = Current.parent + + +def CreateDirectoryLexicon(Directory: Path, Stop: Path | None = None) -> Path: + """A Lexicon inside Directory, so its files can get Books. With + [Lexicon] AutoInheritance on, it inherits every tag from the Lexicon above + it; add or exclude tags in it afterwards.""" + if LexiconFor(Directory): + raise DataError(f"{Directory.name} already has a Lexicon.") + Source = InheritedLexiconFor(Directory, Stop) if ConfValue("Lexicon", "AutoInheritance", True) else None + LexiconPath = TagCore.GetPreferredLexiconPath(Directory, Source) + try: + WriteAtomically(LexiconPath, NewLexiconText(Directory.name, Source, LexiconPath)) + except OSError as Error: + raise DataError(f"Couldn't create the Lexicon: {Error}") from Error + return LexiconPath + + +def RemoveDirectoryLexicon(Directory: Path) -> None: + """Deletes the Lexicon inside a subdirectory. Never a Record's own one.""" + if FindRecord(Directory): + raise DataError("That's a Record; unregister it instead.") + LexiconPath = TagCore.GetLexiconPath(Directory) + if not LexiconPath.is_file(): + raise DataError(f"{Directory.name} has no Lexicon of its own.") + try: + LexiconPath.unlink() + except OSError as Error: + raise DataError(f"Couldn't remove the Lexicon: {Error}") from Error + + +def SubDirectories(Directory: Path) -> list[Path]: + try: + return sorted((Entry for Entry in Directory.iterdir() if Entry.is_dir() and not Entry.name.startswith(".")), + key=lambda Entry: Entry.name.casefold()) + except OSError: + return [] + + +def DirectoryState(Directory: Path) -> str: + """"Tagged", "Partial" (some of it, or of what's below it, isn't) or "Untagged".""" + Own = LexiconFor(Directory) is not None + TaggedBelow = UntaggedBelow = False + for Root, Names, _ in os.walk(Directory, onerror=lambda Error: None): + Names[:] = [Name for Name in Names if not Name.startswith(".")] + if Path(Root) == Directory: + continue + if TagCore.GetLexiconPath(Path(Root)).is_file(): + TaggedBelow = True + else: + UntaggedBelow = True + if TaggedBelow and UntaggedBelow: + break + if Own: + return "Partial" if UntaggedBelow else "Tagged" + return "Partial" if TaggedBelow else "Untagged" + + +def DirectorySize(Directory: Path) -> tuple[int, int]: + """(bytes, files) under a directory, Books and other hidden files excluded.""" + Total = Count = 0 + for Root, Names, Files in os.walk(Directory, onerror=lambda Error: None): + Names[:] = [Name for Name in Names if not Name.startswith(".")] + for Name in Files: + if Name.startswith("."): + continue + try: + Total += (Path(Root) / Name).stat().st_size + Count += 1 + except OSError: + pass + return Total, Count diff --git a/RecordsPage.py b/RecordsPage.py new file mode 100644 index 0000000..8d2eb06 --- /dev/null +++ b/RecordsPage.py @@ -0,0 +1,388 @@ +"""Records: register directories as Records, rename, enable or unregister +them, pick their Lexicon, and tag their subdirectories (give a subdirectory +its own Lexicon, which inherits from the one above it).""" + +import os +from pathlib import Path +from typing import Optional + +import flet as ft + +from Core import ChipButton, DisplayPath, Heading, Note, Palette +from Library import DataError, LexiconsDir, TagCore, WriteAtomically +from Records import ( + AddRecord, CreateDirectoryLexicon, DirectoryState, ListRecords, Record, RemoveDirectoryLexicon, RemoveRecord, + NewLexiconText, RenameRecordDirectory, SetRecordLexicon, SetRecordValues, SharedLexiconFor, SubDirectories, +) +from Updater import UpdateRecord +from Widgets import App, Badge, EmptyState, MenuItem, Panel, PathField, Plural, RightClickHost, SectionTitle, TextInput + +StateColors = {"Tagged": Palette.Mauve, "Partial": Palette.Yellow, "Untagged": Palette.Overlay0} +StateHelp = { + "Tagged": "Has a Lexicon, and so does everything below it.", + "Partial": "Some of it (or of what's below it) has no Lexicon.", + "Untagged": "No Lexicon here or below.", +} + + +class RecordsPage(ft.Container): + def __init__(self, TheApp: App): + super().__init__(expand=True) + self.App = TheApp + self.Records: list[Record] = [] + self.Selected: Optional[Path] = None + self.Expanded: set[Path] = set() + self.States: dict[Path, str] = {} + + self.List = ft.ListView(spacing=4, expand=True, padding=8) + Left = ft.Container( + ft.Column([ + ft.Container(SectionTitle("Records", [ChipButton("Add", lambda e: self.AddDialog(), ft.Icons.ADD)]), + padding=ft.Padding.only(left=12, right=8, top=8)), + self.List, + ], spacing=0, expand=True), + width=340, bgcolor=Palette.Mantle, border=ft.Border.only(right=ft.BorderSide(1, Palette.Surface1)), + ) + self.Details = ft.Container(expand=True, padding=16) + self.Menu = RightClickHost(self._MenuItems, self.Details) + self.content = ft.Row([Left, self.Menu], spacing=0, expand=True, vertical_alignment=ft.CrossAxisAlignment.STRETCH) + + def Refresh(self, Select: str = "") -> None: + self.page.on_keyboard_event = None + try: + self.Records, Errors = ListRecords() + except DataError as Error: + self.Records, Errors = [], [str(Error)] + for Error in Errors: + self.App.Notify(Error, "error") + if Select: + self.Selected = Path(Select) + if self.Selected not in {Item.Directory for Item in self.Records}: + self.Selected = self.Records[0].Directory if self.Records else None + self.States.clear() + self._Build() + + def Leave(self) -> None: + pass + + def _Current(self) -> Optional[Record]: + return next((Item for Item in self.Records if Item.Directory == self.Selected), None) + + def _Build(self) -> None: + self.List.controls = [self._Card(Item) for Item in self.Records] or [ + Note("No Records yet. Add a directory to start tagging its files.")] + self.Details.content = self._DetailView(self._Current()) + self.update() + + def _Card(self, Item: Record) -> ft.Control: + Selected = Item.Directory == self.Selected + Badges: list[ft.Control] = [] + if not Item.Enabled: + Badges.append(Badge("disabled", Palette.Overlay0)) + if Item.Problem: + Badges.append(Badge("problem", Palette.Red)) + return ft.Container( + ft.Column([ + ft.Row([ft.Icon(ft.Icons.FOLDER_SPECIAL_OUTLINED, size=18, color=Palette.Mauve if Item.Enabled else Palette.Overlay0), + ft.Text(Item.Name, size=14, expand=True, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS, + color=Palette.Text if Item.Enabled else Palette.Overlay0), *Badges], spacing=8), + Note(DisplayPath(str(Item.Directory)), Size=11), + ], spacing=2, tight=True), + padding=10, border_radius=6, ink=True, + bgcolor=Palette.Surface0 if Selected else None, + border=ft.Border.all(1, Palette.Mauve if Selected else Palette.Surface0), + on_click=lambda e, Item=Item: self._Select(Item.Directory), + ) + + def _Select(self, Directory: Path) -> None: + self.Selected = Directory + self._Build() + + # The selected Record + + def _DetailView(self, Item: Optional[Record]) -> ft.Control: + if Item is None: + return EmptyState("No Record selected.", "Add one with the Add button.", ft.Icons.FOLDER_SPECIAL_OUTLINED) + Info = ft.Column([ + ft.Row([Heading(Item.Name), ft.IconButton(ft.Icons.EDIT_OUTLINED, icon_size=18, tooltip="Rename (display name)", + on_click=lambda e: self.RenameDialog(Item))]), + self._InfoRow("Directory", DisplayPath(str(Item.Directory)), Item.Directory.is_dir()), + self._InfoRow("Lexicon", DisplayPath(str(Item.LexiconPath)), Item.LexiconPath.is_file()), + ], spacing=4) + if Item.Problem: + Info.controls.append(ft.Row([ft.Icon(ft.Icons.WARNING_AMBER, color=Palette.Red, size=16), + ft.Text(Item.Problem, color=Palette.Red, size=13)])) + Actions = ft.Row([ + ChipButton("Browse", lambda e: self.App.Navigate("Browse", Record=str(Item.Directory)), ft.Icons.FOLDER_OPEN, + Disabled=not Item.Enabled or bool(Item.Problem)), + ChipButton("Edit Lexicon", lambda e: self.App.Navigate("Lexicons", Open=str(Item.LexiconPath)), ft.Icons.EDIT_NOTE, + Disabled=not Item.LexiconPath.is_file()), + ChipButton("Create missing Lexicon", lambda e: self.CreateRecordLexicon(Item), ft.Icons.NOTE_ADD_OUTLINED) + if not Item.LexiconPath.is_file() else ft.Container(), + ChipButton("Update", lambda e: self.App.Navigate("Update", Scope=str(Item.Directory)), ft.Icons.SYNC), + ChipButton("Change Lexicon", lambda e: self.LexiconDialog(Item), ft.Icons.SWAP_HORIZ), + ChipButton("Rename directory", lambda e: self.RenameDirectoryDialog(Item), ft.Icons.DRIVE_FILE_RENAME_OUTLINE, + Disabled=not Item.Directory.is_dir()), + ChipButton("Disable" if Item.Enabled else "Enable", lambda e: self.ToggleEnabled(Item), + ft.Icons.TOGGLE_ON_OUTLINED if Item.Enabled else ft.Icons.TOGGLE_OFF_OUTLINED), + ChipButton("Unregister", lambda e: self.Unregister(Item), ft.Icons.DELETE_OUTLINE, Accent=Palette.Red), + ], wrap=True, spacing=8, run_spacing=8) + + Tree = ft.Column(spacing=0) + if Item.Directory.is_dir(): + self._FillTree(Tree, Item, Item.Directory, 0) + Legend = ft.Row([ft.Row([ft.Icon(ft.Icons.FOLDER, size=14, color=Color), Note(Name)], spacing=4, tooltip=StateHelp[Name]) + for Name, Color in StateColors.items()], spacing=16) + return ft.Column([ + Info, Actions, ft.Divider(height=1, color=Palette.Surface1), + SectionTitle("Subdirectories", [Legend]), + Note("A subdirectory's files get Books once it has a Lexicon. A new one inherits every tag from the " + "Lexicon above it; edit it to add or exclude tags. Right-click a row for more."), + Panel(Tree, padding=6) if Tree.controls else Note("No subdirectories."), + ], spacing=12, scroll=ft.ScrollMode.AUTO, expand=True) + + @staticmethod + def _InfoRow(Label: str, Value: str, Exists: bool) -> ft.Row: + return ft.Row([ft.Text(Label, size=12, color=Palette.Overlay0, width=80), + ft.Text(Value, size=13, selectable=True, color=Palette.Text if Exists else Palette.Red, expand=True)]) + + def _State(self, Directory: Path) -> str: + if Directory not in self.States: + self.States[Directory] = DirectoryState(Directory) + return self.States[Directory] + + def _FillTree(self, Tree: ft.Column, Item: Record, Directory: Path, Depth: int) -> None: + for Child in SubDirectories(Directory): + State = self._State(Child) + HasChildren = bool(SubDirectories(Child)) + Open = Child in self.Expanded + OwnLexicon = TagCore.GetLexiconPath(Child) + if OwnLexicon.is_file(): + Action = ft.Row([ + ft.IconButton(ft.Icons.EDIT_NOTE, icon_size=18, tooltip="Edit its Lexicon", + on_click=lambda e, P=OwnLexicon: self.App.Navigate("Lexicons", Open=str(P))), + ft.IconButton(ft.Icons.LABEL_OFF_OUTLINED, icon_size=18, icon_color=Palette.Red, tooltip="Remove its Lexicon", + on_click=lambda e, C=Child: self.Untag(C)), + ], spacing=0) + else: + Action = ft.IconButton(ft.Icons.NEW_LABEL_OUTLINED, icon_size=18, icon_color=Palette.Mauve, + tooltip="Add a Lexicon (tag it)", on_click=lambda e, C=Child: self.Tag(Item, C)) + Row = ft.Container( + ft.Row([ + ft.Container(width=Depth * 20), + ft.IconButton(ft.Icons.EXPAND_MORE if Open else ft.Icons.CHEVRON_RIGHT, icon_size=16, + on_click=lambda e, C=Child: self._ToggleExpand(C), visible=HasChildren), + ft.Container(width=40, visible=not HasChildren), + ft.Icon(ft.Icons.FOLDER, size=18, color=StateColors[State], tooltip=StateHelp[State]), + ft.Text(Child.name, size=13, expand=True, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS), + Action, + ], spacing=4, vertical_alignment=ft.CrossAxisAlignment.CENTER), + height=38, + ) + Tree.controls.append(ft.GestureDetector( + content=Row, + on_secondary_tap_down=lambda e, Target=(Item, Child): self.Menu.Request(Target, e), + )) + if Open: + self._FillTree(Tree, Item, Child, Depth + 1) + + def _ToggleExpand(self, Directory: Path) -> None: + if Directory in self.Expanded: + self.Expanded.discard(Directory) + else: + self.Expanded.add(Directory) + self.Details.content = self._DetailView(self._Current()) + self.update() + + def _MenuItems(self, Target) -> list[ft.PopupMenuItem]: + if Target is None: + return [] + Item, Directory = Target + Items = [MenuItem("Browse Here", lambda: self.App.Navigate("Browse", Record=str(Item.Directory), Directory=str(Directory)), + ft.Icons.FOLDER_OPEN)] + OwnLexicon = TagCore.GetLexiconPath(Directory) + if OwnLexicon.is_file(): + Items += [MenuItem("Edit Its Lexicon", lambda: self.App.Navigate("Lexicons", Open=str(OwnLexicon)), ft.Icons.EDIT_NOTE), + MenuItem("Remove Its Lexicon", lambda: self.Untag(Directory), ft.Icons.LABEL_OFF_OUTLINED)] + else: + Items.append(MenuItem("Add a Lexicon (Tag It)", lambda: self.Tag(Item, Directory), ft.Icons.NEW_LABEL_OUTLINED)) + Items += [ + MenuItem("Tag It and Everything Below", lambda: self.TagTree(Item, Directory), ft.Icons.ACCOUNT_TREE_OUTLINED), + MenuItem("Open Folder", lambda: self.App.OpenPath(Directory), ft.Icons.FOLDER_OPEN_OUTLINED), + MenuItem("Copy Path", lambda: self.App.CopyText(str(Directory), "the path"), ft.Icons.COPY), + ] + return Items + + # Actions + + def _Saved(self, Text: str) -> None: + self.App.Notify(Text, "success") + self.Refresh() + + def AddDialog(self) -> None: + DirectoryRow, DirectoryField = PathField(self.App, "Directory") + NameField = TextInput(self.App, Label="Name (shown in SaraTag; the directory's name if empty)") + Choice = ft.RadioGroup(ft.Column([ + ft.Radio(value="New", label="New Lexicon in Lexicons/ (named {Source}_{DirectoryName}.toml)"), + ft.Radio(value="Existing", label="An existing Lexicon file"), + ], spacing=0), value="New") + LexiconRow, LexiconField = PathField(self.App, "Lexicon file", str(LexiconsDir), Directory=False, Extensions=["toml"]) + LexiconRow.visible = False + + def ChoiceChanged(e) -> None: + LexiconRow.visible = Choice.value == "Existing" + LexiconRow.update() + + Choice.on_change = ChoiceChanged + + def Add(e) -> None: + Text = (DirectoryField.value or "").strip() + if not Text: + self.App.Notify("Pick a directory.", "warning") + return + Directory = Path(Text).expanduser() + LexiconPath = None + if Choice.value == "Existing": + LexiconPath = Path((LexiconField.value or "").strip()).expanduser() + if not LexiconPath.is_file(): + self.App.Notify("That Lexicon file doesn't exist.", "warning") + return + try: + New = AddRecord(Directory, NameField.value or "", LexiconPath) + except (DataError, OSError) as Error: + self.App.Notify(str(Error), "error") + return + self.App.Close() + self.Selected = New.Directory if New else None + self._Saved(f"Added {New.Name if New else Directory.name}. Run Update to give its files Books.") + + self.App.ShowDialog("Add a Record", ft.Column([ + DirectoryRow, NameField, Note("Its Lexicon (the tag definitions):"), Choice, LexiconRow, + Note("A new Lexicon inherits from Lexicons/.toml when that exists, " + "e.g. Lexicons/Pets.toml for ~/Pets/Cats."), + ], tight=True, spacing=10), [ + ChipButton("Cancel", lambda e: self.App.Close()), + ChipButton("Add", Add, ft.Icons.ADD), + ], Width=600) + + def RenameDialog(self, Item: Record) -> None: + def Done(Name: str) -> None: + if not Name.strip(): + return + try: + SetRecordValues(Item.Index, Name=Name.strip()) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + self._Saved(f"Renamed to {Name.strip()}.") + + self.App.Ask("Rename Record", "Name", Item.Name, Done, Help="Only the name SaraTag shows; the directory keeps its name.") + + def RenameDirectoryDialog(self, Item: Record) -> None: + def Done(NewName: str) -> None: + try: + NewPath = RenameRecordDirectory(Item.Index, NewName) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + if NewPath == Item.Directory: + return + Fixed = UpdateRecord(NewPath, None, "RepairParents", False) + self.Selected = NewPath + self._Saved(f"Renamed the directory to {NewPath.name}; repaired {Plural(Fixed.ParentsRepaired, 'Book parent')}.") + + self.App.Ask("Rename directory", "New directory name", Item.Directory.name, Done, "Rename", + "Renames the folder on disk. Books' Parent links are repaired afterwards, and Libraries follow.") + + def LexiconDialog(self, Item: Record) -> None: + Row, Field = PathField(self.App, "Lexicon file", str(Item.LexiconPath), Directory=False, Extensions=["toml"]) + + def Save(e) -> None: + LexiconPath = Path((Field.value or "").strip()).expanduser() + if not LexiconPath.is_file(): + self.App.Notify("That Lexicon file doesn't exist.", "warning") + return + try: + SetRecordLexicon(Item.Index, LexiconPath) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + self.App.Close() + self._Saved(f"{Item.Name} now uses {LexiconPath.name}. Run Update to bring its Books in line.") + + self.App.ShowDialog("Change Lexicon", ft.Column([Row, Note("Books keep their values; Update adds the new Lexicon's tags.")], + tight=True), [ + ChipButton("Cancel", lambda e: self.App.Close()), ChipButton("Save", Save)]) + + def CreateRecordLexicon(self, Item: Record) -> None: + try: + Item.LexiconPath.parent.mkdir(parents=True, exist_ok=True) + WriteAtomically(Item.LexiconPath, NewLexiconText(Item.Name, SharedLexiconFor(Item.Directory), Item.LexiconPath)) + except OSError as Error: + self.App.Notify(f"Couldn't create it: {Error}", "error") + return + self._Saved(f"Created {Item.LexiconPath.name}.") + + def ToggleEnabled(self, Item: Record) -> None: + try: + SetRecordValues(Item.Index, Enabled=not Item.Enabled) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + self._Saved(f"{Item.Name} {'disabled' if Item.Enabled else 'enabled'}.") + + def Unregister(self, Item: Record) -> None: + def Yes() -> None: + try: + RemoveRecord(Item.Index) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + self.Selected = None + self._Saved(f"Unregistered {Item.Name}.") + + self.App.Confirm("Unregister Record", f"Stop tracking {Item.Name}? Its files, Books and Lexicon stay where they are, " + "and it's taken out of any Library.", Yes, "Unregister") + + def Tag(self, Item: Record, Directory: Path) -> None: + try: + LexiconPath = CreateDirectoryLexicon(Directory, Item.Directory) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + self.States.clear() + self.Expanded.add(Directory.parent) + self.App.Notify(f"Created {LexiconPath.name}. Run Update to give its files Books.", "success") + self.Details.content = self._DetailView(self._Current()) + self.update() + + def TagTree(self, Item: Record, Directory: Path) -> None: + Made = 0 + for Root, Names, _ in os.walk(Directory): + Names[:] = sorted(Name for Name in Names if not Name.startswith(".")) + if not TagCore.GetLexiconPath(Path(Root)).is_file(): + try: + CreateDirectoryLexicon(Path(Root), Item.Directory) + Made += 1 + except DataError as Error: + self.App.Notify(str(Error), "error") + break + self.States.clear() + self.App.Notify(f"Created {Plural(Made, 'Lexicon')}. Run Update to give their files Books.", "success") + self.Details.content = self._DetailView(self._Current()) + self.update() + + def Untag(self, Directory: Path) -> None: + def Yes() -> None: + try: + RemoveDirectoryLexicon(Directory) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + self.States.clear() + self.App.Notify(f"Removed {Directory.name}'s Lexicon. Its Books stay until you delete them.", "success") + self.Details.content = self._DetailView(self._Current()) + self.update() + + self.App.Confirm("Remove Lexicon", f"Delete {Directory.name}'s own Lexicon? Its tag definitions (and any overrides " + "in it) are lost; the files' Books aren't touched.", Yes) diff --git a/SaraTag.py b/SaraTag.py new file mode 100755 index 0000000..3c2d132 --- /dev/null +++ b/SaraTag.py @@ -0,0 +1,10 @@ +"""SaraTag's old entry point, kept so existing launchers still work. The app +itself starts from Main.py.""" + +import flet as ft + +from Core import AssetsDir +from Main import Main + +if __name__ == "__main__": + ft.run(Main, assets_dir=str(AssetsDir)) diff --git a/SaraTagConf.toml b/SaraTagConf.toml new file mode 100755 index 0000000..97e9f0b --- /dev/null +++ b/SaraTagConf.toml @@ -0,0 +1,39 @@ +# SaraTag's main portable configuration file. +# Paths in this file are interpreted relative to the SaraTag directory +# unless they are absolute. + +[SaraTag] +ConfigVersion = 1 +ApplicationVersion = "0.1.0-beta" + +[Files] +RecordsFile = ".SaraTagRecords.toml" +BookSuffix = ".book.toml" + +[Explorer] +HideUntaggedDirectories = true +HideTagFiles = true +ShowInvalidEntries = true +ShowImagePreviews = true + +[History] +Enabled = true +MaxFileHistory = 30 +MaxDirectoryHistory = 20 +MaxUpdateHistory = 20 + +RememberFilter = true +RememberSorting = true +RememberLastDirectory = true +RememberLastSelectedFile = true +RememberRecentFiles = true +RememberUpdaterMode = true +RememberUpdates = true + +[Lexicon] +AutoInheritance = true +RelativeSourcePaths = false +LexiconFilePattern = "{Source}_{DirectoryName}.toml" + +[Media] +VideoPlayer = "cosmic-player" diff --git a/Settings.py b/Settings.py new file mode 100644 index 0000000..3f96634 --- /dev/null +++ b/Settings.py @@ -0,0 +1,286 @@ +"""Settings dialog: the shared SaraSuite settings (with export/import), +SaraTag's own GUI settings, and SaraTagConf.toml (Explorer, Lexicons, +History). Laid out like SaraFem's, SaraSuite's and SaraSort's Settings (a +category list on the left, the chosen category on the right).""" + +import subprocess +from pathlib import Path +from typing import Callable + +import flet as ft +import tomlkit + +from Core import ( + AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, Home, + ImportSettings, LoadSharedSettings, Palette, SharedSettings, +) +from Library import ConfPath, ConfValue, DataDir, DataError, SaveConfValues +from Widgets import App + +DialogWidth = 720 +DialogHeight = 540 +CategoryListWidth = 150 +RestartNote = "Theme, font and title bar changes apply the next time SaraTag starts." +StartPages = ["Browse", "Records", "Libraries", "Lexicons", "Update"] + + +def _Note(Text: str) -> ft.Text: + return ft.Text(Text, color=Palette.Overlay0, size=12) + + +def _Fonts() -> list[str]: + """Installed font families (fontconfig), with the bundled font first.""" + try: + Result = subprocess.run( + ["fc-list", ":", "family"], capture_output=True, text=True, timeout=5, env=ChildEnvironment(), + ) + Families = {Line.split(",")[0].strip() for Line in Result.stdout.splitlines() if Line.strip()} + except (OSError, subprocess.SubprocessError): + Families = set() + Families.discard(BundledFontFamily) + return [BundledFontFamily] + sorted(Families, key=str.lower) + + +def _NumberField(Label: str, Value: int) -> ft.TextField: + return ft.TextField(label=Label, value=str(Value), keyboard_type=ft.KeyboardType.NUMBER, dense=True) + + +def _ReadInt(Field: ft.TextField, Fallback: int, Minimum: int) -> int: + try: + return max(Minimum, int(Field.value)) + except (TypeError, ValueError): + return Fallback # keep the previous value rather than crash on bad input + + +def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None: + Page = TheApp.Page + Config: AppConfig = TheApp.Config + Themes = AvailableThemes() + Fonts = _Fonts() + + def ThemePicker(Value: str) -> ft.Dropdown: + Names = Themes if Value in Themes else [Value] + Themes + return ft.Dropdown(value=Value, label="Theme", options=[ft.DropdownOption(key=Name, text=Name) for Name in Names]) + + def FontPicker(Value: str) -> ft.Dropdown: + # Type to filter the long list. + Names = Fonts if Value in Fonts else [Value] + Fonts + return ft.Dropdown( + value=Value, label="Font", editable=True, enable_filter=True, menu_height=320, + options=[ft.DropdownOption(key=Name, text=Name) for Name in Names], + ) + + # SaraSuite: ~/.config/SaraSuite/Config.toml, read by every SaraSuite program. + Shared = LoadSharedSettings(Config) + UseSharedSwitch = ft.Switch(label="SaraTag uses these shared settings", value=Config.UseSharedSettings) + SharedTheme = ThemePicker(Shared.Theme) + SharedFont = FontPicker(Shared.FontFamily) + SharedTitleBar = ft.Switch(label="Hide window title bar", value=Shared.HideWindowTitleBar) + SettingsFileField = ft.TextField(value=str(Home / "SaraTag-Settings.toml"), label="Settings file path", dense=True) + + # SaraTag's own appearance, only used with shared settings off. + OwnTheme = ThemePicker(Config.Theme) + OwnFont = FontPicker(Config.FontFamily) + OwnTitleBar = ft.Switch(label="Hide window title bar", value=Config.HideWindowTitleBar) + OwnAppearance = ft.Column( + [_Note("SaraTag's own appearance, used when shared settings are off"), OwnTheme, OwnFont, OwnTitleBar], + spacing=12, + disabled=Config.UseSharedSettings, + opacity=0.5 if Config.UseSharedSettings else 1, + ) + + def OnUseSharedChanged(e) -> None: + OwnAppearance.disabled = UseSharedSwitch.value + OwnAppearance.opacity = 0.5 if UseSharedSwitch.value else 1 + OwnAppearance.update() + + UseSharedSwitch.on_change = OnUseSharedChanged + + StartPage = ft.Dropdown( + label="Start page", value=Config.StartPage if Config.StartPage in StartPages else "Browse", + options=[ft.DropdownOption(key=Name, text=Name) for Name in StartPages], + ) + PanelWidth = _NumberField("Browse side panel width", Config.PreviewWidth) + + # SaraTagConf.toml, the portable config next to SaraTag's data. + def Switch(Section: str, Key: str, Label: str, Fallback: bool = True) -> ft.Switch: + return ft.Switch(label=Label, value=bool(ConfValue(Section, Key, Fallback)), data=(Section, Key)) + + def Number(Section: str, Key: str, Label: str, Fallback: int) -> ft.TextField: + Field = _NumberField(Label, int(ConfValue(Section, Key, Fallback))) + Field.data = (Section, Key, Fallback) + return Field + + ConfSwitches = [ + Switch("Explorer", "HideUntaggedDirectories", "Hide directories with no Lexicon in or below them"), + Switch("Explorer", "HideTagFiles", "Hide a directory's own Lexicon file from its listing"), + Switch("Explorer", "ShowInvalidEntries", "Show files whose Book is broken or has invalid values"), + Switch("Explorer", "ShowImagePreviews", "Show image previews"), + Switch("Lexicon", "AutoInheritance", "New subdirectory Lexicons inherit from the one above"), + Switch("Lexicon", "RelativeSourcePaths", "Write Source paths relative to the Lexicon"), + Switch("History", "Enabled", "Keep history (.SaraTagHistory.toml)"), + Switch("History", "RememberFilter", "Remember each collection's filter"), + Switch("History", "RememberSorting", "Remember each collection's sorting"), + Switch("History", "RememberLastDirectory", "Remember the last directory"), + Switch("History", "RememberLastSelectedFile", "Remember the last selected file"), + Switch("History", "RememberRecentFiles", "Remember recently opened files"), + Switch("History", "RememberUpdaterMode", "Remember the Update page's last choices"), + Switch("History", "RememberUpdates", "Remember which Books updates changed"), + ] + ConfNumbers = [ + Number("History", "MaxFileHistory", "Recent files kept", 30), + Number("History", "MaxDirectoryHistory", "Collections remembered", 20), + Number("History", "MaxUpdateHistory", "Updated Books remembered", 20), + ] + VideoPlayer = ft.TextField(label="Video player (empty: the default app)", value=str(ConfValue("Media", "VideoPlayer", "")), dense=True) + PatternField = ft.TextField(label="Lexicon file name pattern", value=str(ConfValue("Lexicon", "LexiconFilePattern", "{Source}_{DirectoryName}.toml")), + dense=True) + + def ConfControls(Section: str) -> list[ft.Control]: + return [Control for Control in ConfSwitches + ConfNumbers if Control.data[0] == Section] + + def OnExport(e) -> None: + PathText = (SettingsFileField.value or "").strip() + if not PathText: + return + try: + ExportSettings(Config, Path(PathText).expanduser()) + except OSError as Error: + TheApp.Notify(f"Couldn't export settings: {Error}", "error") + return + TheApp.Notify("Settings exported.", "success") + + def OnImport(e) -> None: + PathText = (SettingsFileField.value or "").strip() + if not PathText: + return + try: + ImportSettings(Config, Path(PathText).expanduser()) + except (OSError, tomlkit.exceptions.TOMLKitError) as Error: + TheApp.Notify(f"Couldn't import settings: {Error}", "error") + return + # The open dialog still shows the pre-import values; closing it + # rather than leaving a stale Save that would overwrite the import. + Page.pop_dialog() + OnSaved() + TheApp.Notify("Settings imported. Reopen Settings to see them; restart for Theme/Font/Title bar.", "success") + + Categories: dict[str, ft.Control] = { + "SaraSuite": ft.Column( + [ + _Note("Shared by every SaraSuite program (~/.config/SaraSuite/Config.toml). " + "Other programs pick changes up the next time they start."), + UseSharedSwitch, + ft.Divider(height=1, color=Palette.Surface1), + SharedTheme, + SharedFont, + SharedTitleBar, + ft.Divider(height=1, color=Palette.Surface1), + _Note("Export/import settings as one file: this tab's shared settings plus SaraTag's own " + "(General). Same format as SaraFem's and SaraSort's."), + SettingsFileField, + ft.Row([ + ft.OutlinedButton(content="Export", style=ChipButtonStyle(), on_click=OnExport), + ft.OutlinedButton(content="Import", style=ChipButtonStyle(), on_click=OnImport), + ]), + ], + spacing=12, scroll=ft.ScrollMode.AUTO, + ), + "General": ft.Column( + [StartPage, PanelWidth, ft.Divider(height=1, color=Palette.Surface1), OwnAppearance], + spacing=12, scroll=ft.ScrollMode.AUTO, + ), + "Browse": ft.Column( + [_Note(f"Stored in SaraTagConf.toml ({DataDir})."), *ConfControls("Explorer"), VideoPlayer], + spacing=8, scroll=ft.ScrollMode.AUTO, + ), + "Lexicons": ft.Column( + [_Note("Stored in SaraTagConf.toml."), *ConfControls("Lexicon"), PatternField, + _Note("{Source} is the parent Lexicon's name (or the parent directory's), {DirectoryName} the directory's.")], + spacing=8, scroll=ft.ScrollMode.AUTO, + ), + "History": ft.Column( + [_Note("Stored in SaraTagConf.toml; the history itself in .SaraTagHistory.toml."), *ConfControls("History")], + spacing=8, scroll=ft.ScrollMode.AUTO, + ), + } + + DetailArea = ft.Container(Categories["General"], expand=True, padding=ft.Padding.only(left=12)) + CategoryTiles: dict[str, ft.ListTile] = {} + + def SelectCategory(Name: str) -> None: + for CategoryName, Tile in CategoryTiles.items(): + Tile.selected = CategoryName == Name + DetailArea.content = Categories[Name] + DetailArea.update() + CategoryList.update() + + for Name in Categories: + CategoryTiles[Name] = ft.ListTile( + title=Name, + selected=Name == "General", + selected_tile_color=Palette.Surface1, + selected_color=Palette.Mauve, + on_click=lambda e, n=Name: SelectCategory(n), + ) + CategoryList = ft.Column(list(CategoryTiles.values()), spacing=0, scroll=ft.ScrollMode.AUTO) + + def Save(e) -> None: + Config.UseSharedSettings = UseSharedSwitch.value + Config.Theme = OwnTheme.value or Config.Theme + Config.FontFamily = OwnFont.value or Config.FontFamily + Config.HideWindowTitleBar = OwnTitleBar.value + Config.StartPage = StartPage.value or Config.StartPage + Config.PreviewWidth = _ReadInt(PanelWidth, Config.PreviewWidth, 240) + Config.Save() + SharedSettings( + Theme=SharedTheme.value, + FontFamily=SharedFont.value or Shared.FontFamily, + HideWindowTitleBar=SharedTitleBar.value, + ).Save() + Values: dict[str, dict] = {} + for Control in ConfSwitches: + Values.setdefault(Control.data[0], {})[Control.data[1]] = bool(Control.value) + for Control in ConfNumbers: + Section, Key, Fallback = Control.data + Values.setdefault(Section, {})[Key] = _ReadInt(Control, int(ConfValue(Section, Key, Fallback)), 0) + Values.setdefault("Media", {})["VideoPlayer"] = (VideoPlayer.value or "").strip() + Pattern = (PatternField.value or "").strip() + if "{DirectoryName}" in Pattern and Pattern.endswith(".toml"): + Values["Lexicon"]["LexiconFilePattern"] = Pattern + elif Pattern: + TheApp.Notify("The Lexicon pattern needs {DirectoryName} and must end in .toml; kept the old one.", "warning") + try: + SaveConfValues(Values) + except (DataError, OSError) as Error: + TheApp.Notify(f"Couldn't save {ConfPath.name}: {Error}", "error") + return + Page.pop_dialog() + OnSaved() + TheApp.Notify("Settings saved.", "success") + + Page.show_dialog(ft.AlertDialog( + title="Settings", + content=ft.Column( + [ + ft.Row( + [ + ft.Container( + CategoryList, width=CategoryListWidth, bgcolor=Palette.Surface0, border_radius=4, padding=4, + ), + DetailArea, + ], + expand=True, + vertical_alignment=ft.CrossAxisAlignment.STRETCH, + ), + ft.Divider(height=1, color=Palette.Surface1), + _Note(RestartNote), + ], + width=DialogWidth, + height=DialogHeight, + ), + actions=[ + ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: Page.pop_dialog()), + ft.OutlinedButton(content="Save", style=ChipButtonStyle(), on_click=Save), + ], + )) diff --git a/TagEditor.py b/TagEditor.py new file mode 100644 index 0000000..f9cf193 --- /dev/null +++ b/TagEditor.py @@ -0,0 +1,236 @@ +"""The tag panel on the Browse page: every tag of the selected file(s), each +with a control that fits its Type. Changes are written to the Books straight +away. With several files selected, only tags their Lexicons share are shown, +and tags whose values differ show as mixed until set.""" + +from typing import Any, Callable + +import flet as ft + +from Books import BuildSelection, Book, InitialValue, Mixed, ParseNumber, SetTagValue, ValidateValue +from Core import Note, Palette +from Library import Bound, DataError, FormatValue, TagType, Unset +from Widgets import App, Plural, TextInput + +MixedText = "(mixed)" + + +class TagEditor(ft.Column): + def __init__(self, TheApp: App, OnChanged: Callable[[list[Book]], None]): + super().__init__(spacing=10, scroll=ft.ScrollMode.AUTO, expand=True) + self.App = TheApp + self.OnChanged = OnChanged + self.Books: list[Book] = [] + self.ShowBooks([], Update=False) + + def ShowBooks(self, Books: list[Book], Header: str = "", Update: bool = True) -> None: + self.Books = Books + self.Selection = BuildSelection(Books) + self.controls = self._Build(Header) + if Update: + self.update() + + def _Build(self, Header: str) -> list[ft.Control]: + if not self.Books: + return [Note(Header or "Select a file with a Book to edit its tags.")] + Controls: list[ft.Control] = [] + if Header: + Controls.append(ft.Text(Header, color=Palette.Mauve, size=14, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS)) + Errors = {TheBook.Error for TheBook in self.Books if TheBook.Error} + for Error in Errors: + Controls.append(ft.Row([ft.Icon(ft.Icons.WARNING_AMBER, color=Palette.Red, size=16), + ft.Text(Error, color=Palette.Red, size=12, expand=True)])) + if not self.Selection.Definitions: + if len(self.Books) > 1 and not Errors: + Controls.append(Note("These files' Lexicons share no tags.")) + for Name, Definition in self.Selection.Definitions.items(): + Controls.append(self._TagRow(Name, Definition, self.Selection.Values.get(Name, Unset))) + if self.Selection.Extra: + Controls.append(ft.Divider(height=1, color=Palette.Surface1)) + Controls.append(Note("Not in the Lexicon (Clean unused tags on the Update page removes them):")) + for Name, Value in self.Selection.Extra.items(): + Controls.append(ft.Row([ft.Text(Name, size=12, color=Palette.Overlay0, width=110), + ft.Text(FormatValue(Value), size=12, selectable=True, expand=True)])) + return Controls + + # One tag + + def _TagRow(self, Name: str, Definition: dict[str, Any], Value: Any) -> ft.Control: + Kind = TagType(Definition) + Required = bool(Definition.get("Required")) + Label = ft.Text(Name + (" *" if Required else ""), size=12, color=Palette.Mauve if Required else Palette.Text, + tooltip=(Definition.get("Description") or None)) + Problem = None if Value is Mixed else ValidateValue(Name, Value, Definition) + + if Definition.get("ForceDefault"): + Editor: ft.Control = ft.Row([ + ft.Icon(ft.Icons.LOCK_OUTLINE, size=14, color=Palette.Overlay0), + ft.Text(FormatValue(Definition.get("Default"), Definition), size=13), + ], tooltip="ForceDefault: every update sets this to its Default.") + elif Kind == "Boolean": + Editor = self._Boolean(Name, Definition, Value) + elif Kind == "List": + Editor = self._List(Name, Definition, Value) + elif Kind == "Rating": + Editor = self._Rating(Name, Definition, Value) + elif Kind in ("Number", "Text"): + Editor = self._Field(Name, Definition, Value, Kind) + else: + Editor = ft.Text(f"Unknown type {Definition.get('Type')!r}: {FormatValue(Value)}", color=Palette.Red, size=12) + + Reset = ft.IconButton( + ft.Icons.RESTART_ALT, icon_size=16, icon_color=Palette.Overlay0, tooltip="Back to its default", + on_click=lambda e: self._Set(Name, InitialValue(Definition)), + visible=not Definition.get("ForceDefault"), + ) + Rows: list[ft.Control] = [ft.Row([Label, ft.Container(expand=True), Reset], height=24)] + Rows.append(Editor) + if Problem: + Rows.append(ft.Text(Problem, size=11, color=Palette.Red)) + return ft.Column(Rows, spacing=2) + + def _Set(self, Name: str, Value: Any) -> None: + Definition = self.Selection.Definitions[Name] + Problem = ValidateValue(Name, Value, Definition) + if Problem and not (Value == Unset and Definition.get("Required")): + self.App.Notify(Problem, "error") + self.ShowBooks(self.Books, self._Header()) + return + try: + Changed = SetTagValue(self.Books, Name, Value) + except DataError as Error: + self.App.Notify(str(Error), "error") + return + if Changed and len(self.Books) > 1: + self.App.Notify(f"Set {Name} on {Plural(Changed, 'file')}.", "success") + self.ShowBooks(self.Books, self._Header()) + if Changed: + self.OnChanged(self.Books) + + def _Header(self) -> str: + return self.Books[0].FilePath.name if len(self.Books) == 1 else f"{len(self.Books)} files selected" + + def _Boolean(self, Name: str, Definition: dict[str, Any], Value: Any) -> ft.Control: + Known = isinstance(Value, bool) + return ft.Checkbox( + label="Yes" if Value is True else "No" if Value is False else MixedText if Value is Mixed else "Not set", + value=Value if Known else None, + tristate=not Known, + on_change=lambda e: self._Set(Name, not Value if Known else True), + ) + + def _List(self, Name: str, Definition: dict[str, Any], Value: Any) -> ft.Control: + Values = [Item for Item in Definition.get("Values", []) if isinstance(Item, (str, int, float))] + Options = [ft.DropdownOption(key=str(Item), text=str(Item)) for Item in Values] + if not Definition.get("Required"): + Options.insert(0, ft.DropdownOption(key=Unset, text="? (not set)")) + Current = str(Value) if Value is not Mixed and (Value in Values or Value == Unset) else None + ByKey = {str(Item): Item for Item in Values} | {Unset: Unset} + return ft.Dropdown( + value=Current, options=Options, dense=True, width=260, + hint_text=MixedText if Value is Mixed else (FormatValue(Value) if Current is None else None), + on_select=lambda e: self._Set(Name, ByKey.get(e.control.value, Unset)), + ) + + def _Rating(self, Name: str, Definition: dict[str, Any], Value: Any) -> ft.Control: + Minimum = Bound(Definition, "Minimum") + Minimum = Minimum if isinstance(Minimum, int) and Minimum >= 0 else 0 + Maximum = Bound(Definition, "Maximum") + Filled = Definition.get("FilledSymbol") or "★" + Empty = Definition.get("EmptySymbol") or "" + Current = Value if isinstance(Value, int) and not isinstance(Value, bool) else None + Controls: list[ft.Control] = [] + if isinstance(Maximum, int) and 0 < Maximum <= 20: + for Point in range(1, Maximum + 1): + Lit = Current is not None and Point <= Current + Symbol = Filled if Lit or not Empty else Empty + Controls.append(ft.Container( + ft.Text(Symbol, size=20, color=Palette.Mauve if Lit else Palette.Surface1), + on_click=lambda e, Point=Point: self._Set(Name, max(Minimum, Point - 1) if Current == Point else max(Minimum, Point)), + tooltip=f"{Point}", padding=ft.Padding.symmetric(horizontal=1), + )) + else: # no sensible Maximum: a number with - and + + Controls += [ + ft.IconButton(ft.Icons.REMOVE, icon_size=16, + on_click=lambda e: self._Set(Name, max(Minimum, (Current or Minimum) - 1))), + ft.Text(FormatValue(Current, Definition) if Current is not None else "?", size=16), + ft.IconButton(ft.Icons.ADD, icon_size=16, + on_click=lambda e: self._Set(Name, (Current if Current is not None else Minimum - 1) + 1)), + ] + if Value is Mixed: + Controls.append(Note(MixedText)) + elif Current is None: + Controls.append(Note("not set" if Value == Unset else FormatValue(Value))) + if not Definition.get("Required") and Value != Unset: + Controls.append(ft.IconButton(ft.Icons.CLEAR, icon_size=14, icon_color=Palette.Overlay0, + tooltip="Not set (?)", on_click=lambda e: self._Set(Name, Unset))) + return ft.Row(Controls, spacing=0, wrap=True) + + def _Field(self, Name: str, Definition: dict[str, Any], Value: Any, Kind: str) -> ft.Control: + Shown = "" if Value is Mixed or Value == Unset else FormatValue(Value) + Hint = MixedText if Value is Mixed else "? (not set)" + # No expand here: this sits in a scrolling Column, where a vertical flex breaks the layout. + Field = TextInput(self.App, Value=Shown, Hint=Hint, + keyboard_type=ft.KeyboardType.NUMBER if Kind == "Number" else ft.KeyboardType.TEXT) + Field.multiline = Kind == "Text" + Field.shift_enter = True # Enter saves, Shift+Enter adds a line + State = {"Saved": Shown} + + def Commit(e=None) -> None: + Text = Field.value or "" + if Text == State["Saved"]: + return + if Kind == "Text": + NewValue: Any = Text if Text.strip() else Unset + elif not Text.strip(): + NewValue = Unset + else: + try: + NewValue = ParseNumber(Text) + except ValueError: + Field.error = "Not a number" + Field.update() + return + State["Saved"] = Text + self._Set(Name, NewValue) + + Field.on_submit = Commit + OldBlur = Field.on_blur + + def Blur(e) -> None: + OldBlur(e) + Commit() + + Field.on_blur = Blur + Hints: list[str] = [] + if Kind == "Number": + Minimum, Maximum = Bound(Definition, "Minimum"), Bound(Definition, "Maximum") + if Minimum is not None or Maximum is not None: + Hints.append(f"{Minimum if Minimum is not None else ''} … {Maximum if Maximum is not None else ''}") + Controls: list[ft.Control] = [Field] + if Hints: + Controls.append(Note(Hints[0], Size=11)) + return ft.Column(Controls, spacing=2) + + +def TagSummary(TheBook: Book | None, Limit: int = 6) -> str: + """"Colour: Orange · Rating: ★★☆ · ..." for a list row.""" + if TheBook is None: + return "" + Parts = [] + for Name, Value in TheBook.Tags.items(): + if Value == Unset or Value == "": + continue + Parts.append(f"{Name}: {FormatValue(Value, TheBook.Definitions.get(Name))}") + if len(Parts) >= Limit: + break + return " · ".join(Parts) + + +def BookProblems(TheBook: Book | None) -> list[str]: + if TheBook is None: + return [] + if TheBook.Error: + return [TheBook.Error] + return [Problem for Name, Definition in TheBook.Definitions.items() + if (Problem := ValidateValue(Name, TheBook.Metadata.get(Name, Unset), Definition))] diff --git a/UpdatePage.py b/UpdatePage.py new file mode 100644 index 0000000..25608da --- /dev/null +++ b/UpdatePage.py @@ -0,0 +1,153 @@ +"""Update: bring Books in line with their Lexicons, for every Record or one. +"Preview only" runs the same thing without writing, to see what would change.""" + +from pathlib import Path + +import flet as ft + +from Core import ChipButton, DisplayPath, Heading, Note, Palette +from History import RecordUpdaterRun, UpdaterState +from Library import DataError +from Records import ListRecords, Record +from Updater import DestructiveModes, Modes, UpdateRecord, UpdateResult +from Widgets import App, Panel, Plural, SectionTitle + +AllRecords = "All" + + +class UpdatePage(ft.Container): + def __init__(self, TheApp: App): + super().__init__(expand=True, padding=16) + self.App = TheApp + self.Records: list[Record] = [] + self.Running = False + + State = UpdaterState() + self.ModePicker = ft.RadioGroup( + ft.Column([ + ft.Column([ft.Radio(value=Name, label=Label), ft.Container(Note(Help), padding=ft.Padding.only(left=48))], spacing=0) + for Name, (Label, Help) in Modes.items() + ], spacing=6), + value=State.get("LastMode") if State.get("LastMode") in Modes else "Expand", + ) + self.DryRun = ft.Switch(label="Preview only (change nothing, just show what would change)", + value=bool(State.get("LastDryRun", False))) + self.ScopePicker = ft.Dropdown(label="Which Records", width=360, dense=True) + self.LastScope = State.get("LastScope", "") + self.RunButton = ChipButton("Run", lambda e: self.Run(), ft.Icons.PLAY_ARROW) + self.Progress = ft.Row([ft.ProgressRing(width=16, height=16), Note("")], visible=False) + self.Results = ft.Column(spacing=12) + Options = ft.Column([ + Heading("Update Books"), + Note("Only directories with a Lexicon are touched. Files never change; only their Books do."), + Panel(self.ModePicker), + self.DryRun, + ft.Row([self.ScopePicker, self.RunButton, self.Progress], spacing=12, vertical_alignment=ft.CrossAxisAlignment.CENTER), + ], spacing=12, width=560) + self.content = ft.Column([Options, ft.Divider(height=1, color=Palette.Surface1), self.Results], + spacing=16, scroll=ft.ScrollMode.AUTO, expand=True) + + def Refresh(self, Scope: str = "") -> None: + self.page.on_keyboard_event = None + try: + self.Records = [Item for Item in ListRecords()[0] if Item.Enabled] + except DataError as Error: + self.App.Notify(str(Error), "error") + self.Records = [] + Options = [ft.DropdownOption(key=AllRecords, text=f"All enabled Records ({len(self.Records)})")] + Options += [ft.DropdownOption(key=str(Item.Directory), text=Item.Name) for Item in self.Records] + self.ScopePicker.options = Options + Wanted = Scope or self.ScopePicker.value or self.LastScope + Keys = {Option.key for Option in Options} + self.ScopePicker.value = Wanted if Wanted in Keys else AllRecords + self.update() + + def Leave(self) -> None: + pass + + def _Chosen(self) -> list[Record]: + if self.ScopePicker.value == AllRecords: + return self.Records + return [Item for Item in self.Records if str(Item.Directory) == self.ScopePicker.value] + + def Run(self) -> None: + if self.Running: + return + Mode = self.ModePicker.value or "Expand" + Chosen = self._Chosen() + if not Chosen: + self.App.Notify("There are no enabled Records to update.", "warning") + return + if Mode in DestructiveModes and not self.DryRun.value: + Label = Modes[Mode][0] + self.App.Confirm(f"{Label}?", f"{Modes[Mode][1]} This rewrites Books in {Plural(len(Chosen), 'Record')} and " + "can't be undone. Tip: run it with Preview only first.", lambda: self._Start(Mode, Chosen), + "Run", Accent=Palette.Yellow) + return + self._Start(Mode, Chosen) + + def _Start(self, Mode: str, Chosen: list[Record]) -> None: + self.Running = True + self.RunButton.disabled = True + self.Progress.visible = True + self.Results.controls = [] + self.update() + self.page.run_thread(self._Work, Mode, bool(self.DryRun.value), Chosen) + + def _Work(self, Mode: str, DryRun: bool, Chosen: list[Record]) -> None: + Label: ft.Text = self.Progress.controls[1] + Results: list[tuple[Record, UpdateResult]] = [] + + def Progress(Directory: Path) -> None: + Label.value = DisplayPath(str(Directory)) + Label.update() + + try: + for Item in Chosen: + Results.append((Item, UpdateRecord(Item.Directory, Item.LexiconPath, Mode, DryRun, Progress))) + Changed = [BookPath for _, Result in Results for BookPath in Result.ChangedBooks] + RecordUpdaterRun(Mode, DryRun, AllRecords if len(Chosen) > 1 else str(Chosen[0].Directory), Changed) + finally: + self.Running = False + self.RunButton.disabled = False + self.Progress.visible = False + self._ShowResults(Mode, DryRun, Results) + + def _ShowResults(self, Mode: str, DryRun: bool, Results: list[tuple[Record, UpdateResult]]) -> None: + Total = UpdateResult(Path("/"), Mode, DryRun) + for _, Result in Results: + Total.Merge(Result) + Verb = "would change" if DryRun else "changed" + Headline = f"{Modes[Mode][0]}{' (preview)' if DryRun else ''}: {Plural(len(Total.ChangedBooks), 'Book')} {Verb}." + Rows = [ft.Row([ft.Text(Name, size=13, width=200), ft.Text(str(Count), size=13, + color=Palette.Red if Count and Name in ("Errors", "Invalid values") else Palette.Text)]) + for Name, Count in Total.Summary() if Count or Name in ("Files scanned", "Books created", "Books updated")] + Controls: list[ft.Control] = [ + SectionTitle(Headline), + Panel(ft.Column(Rows, spacing=2), width=560), + ] + if len(Results) > 1: + PerRecord = [ft.Row([ft.Text(Item.Name, size=13, expand=True), + Note(f"{len(Result.ChangedBooks)} {Verb}" + (f", {Plural(len(Result.Errors), 'error')}" if Result.Errors else ""))]) + for Item, Result in Results] + Controls.append(self._Expander("Per Record", PerRecord)) + for Title, Items, Color in ( + ("Errors", Total.Errors, Palette.Red), + ("Invalid values", Total.InvalidValues, Palette.Yellow), + ("Orphaned Books (their file is gone)", [str(Item) for Item in Total.OrphanedBooks], Palette.Peach), + (f"Books that {Verb}", [str(Item) for Item in Total.ChangedBooks], Palette.Text), + ): + if Items: + Controls.append(self._Expander(f"{Title} ({len(Items)})", [ + ft.Text(DisplayPath(Text), size=12, color=Color, selectable=True) for Text in Items[:500] + ] + ([Note(f"…and {len(Items) - 500} more")] if len(Items) > 500 else []), Open=Color == Palette.Red)) + self.Results.controls = Controls + self.update() + self.App.Notify(Headline, "error" if Total.Errors else "success") + + @staticmethod + def _Expander(Title: str, Controls: list[ft.Control], Open: bool = False) -> ft.Control: + return ft.ExpansionTile( + title=ft.Text(Title, size=14), expanded=Open, controls=[ft.Container(ft.Column(Controls, spacing=2), padding=12)], + collapsed_icon_color=Palette.Mauve, icon_color=Palette.Mauve, + ) diff --git a/Updater.py b/Updater.py new file mode 100644 index 0000000..e0f61ce --- /dev/null +++ b/Updater.py @@ -0,0 +1,218 @@ +"""Bringing Books in line with their Lexicons: create missing Books, add new +tags, clean removed ones, reset values, rebuild, or repair Parent links. +Only directories that have a Lexicon are touched, so SaraTag never invents +tags for unrelated subdirectories.""" + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Iterable + +from Books import FileForBook, InitialValue, Normalise, SerializeBook, ValidateValue +from Library import DataError, ReadToml, TagCore, WriteAtomically + +# Name: (label, what it does). The names are what .SaraTagHistory.toml stores. +Modes: dict[str, tuple[str, str]] = { + "Expand": ("Update all & add missing", "Creates missing Books and adds new tags. Keeps every value."), + "Clean": ("Clean unused tags", "Like the above, then removes tags the Lexicon no longer has."), + "Reset": ("Set all to defaults", "Sets every tag back to its default value."), + "Rebuild": ("Regenerate Books", "Rewrites every Book from the Lexicon: defaults only, unknown tags dropped."), + "RepairParents": ("Repair Book parents", "Only fixes each Book's Parent link (after moving or renaming directories)."), +} +DestructiveModes = {"Clean", "Reset", "Rebuild"} + + +@dataclass +class UpdateResult: + RootPath: Path + Mode: str + DryRun: bool + DirectoriesScanned: int = 0 + TaggedDirectories: int = 0 + FilesScanned: int = 0 + BooksCreated: int = 0 + BooksUpdated: int = 0 + TagsAdded: int = 0 + ValuesReset: int = 0 + TagsRemoved: int = 0 + ParentsRepaired: int = 0 + ChangedBooks: list[Path] = field(default_factory=list) + OrphanedBooks: list[Path] = field(default_factory=list) + InvalidValues: list[str] = field(default_factory=list) + Errors: list[str] = field(default_factory=list) + + def Merge(self, Other: "UpdateResult") -> None: + for Name in ("DirectoriesScanned", "TaggedDirectories", "FilesScanned", "BooksCreated", "BooksUpdated", + "TagsAdded", "ValuesReset", "TagsRemoved", "ParentsRepaired"): + setattr(self, Name, getattr(self, Name) + getattr(Other, Name)) + for Name in ("ChangedBooks", "OrphanedBooks", "InvalidValues", "Errors"): + getattr(self, Name).extend(getattr(Other, Name)) + + def Summary(self) -> list[tuple[str, int]]: + return [ + ("Directories scanned", self.DirectoriesScanned), + ("Tagged directories", self.TaggedDirectories), + ("Files scanned", self.FilesScanned), + ("Books created", self.BooksCreated), + ("Books updated", self.BooksUpdated), + ("Tags added", self.TagsAdded), + ("Values reset", self.ValuesReset), + ("Tags removed", self.TagsRemoved), + ("Parents repaired", self.ParentsRepaired), + ("Orphaned Books", len(self.OrphanedBooks)), + ("Invalid values", len(self.InvalidValues)), + ("Errors", len(self.Errors)), + ] + + +def _Directories(RootPath: Path) -> Iterable[Path]: + """A Record and its non-hidden subdirectories.""" + for DirectoryText, Names, _ in os.walk(RootPath): + Names[:] = sorted(Name for Name in Names if not Name.startswith(".")) + yield Path(DirectoryText) + + +def _OrdinaryFiles(Directory: Path) -> list[Path]: + return sorted((Entry for Entry in Directory.iterdir() if Entry.is_file() and not Entry.name.startswith(".")), + key=lambda Entry: Entry.name.casefold()) + + +def _Write(BookPath: Path, Metadata: dict[str, Any], Result: UpdateResult) -> bool: + if Result.DryRun: + return True + try: + WriteAtomically(BookPath, SerializeBook(Metadata)) + return True + except OSError as Error: + Result.Errors.append(f"Couldn't write {BookPath}: {Error}") + return False + + +def _Synchronise(Existing: dict[str, Any], Parent: str, Definitions: dict[str, dict[str, Any]], Mode: str, + Result: UpdateResult, FilePath: Path) -> tuple[dict[str, Any], bool]: + """The Book's new contents and whether anything changed.""" + Updated = dict(Existing) + if Updated.get("Parent") != Parent: + Updated["Parent"] = Parent + Result.ParentsRepaired += 1 + + if Mode == "Rebuild": + Rebuilt = {"Parent": Parent} | {Name: InitialValue(Definition) for Name, Definition in Definitions.items()} + Result.TagsRemoved += len(set(Existing) - set(Rebuilt)) + Result.ValuesReset += sum(1 for Name in Definitions if Name in Existing and Existing[Name] != Rebuilt[Name]) + Result.TagsAdded += sum(1 for Name in Definitions if Name not in Existing) + return Rebuilt, Rebuilt != Existing + + for Name, Definition in Definitions.items(): + if Definition.get("ForceDefault"): + if "Default" not in Definition: + Result.InvalidValues.append(f"{FilePath}: {Name} has ForceDefault but no Default.") + elif Updated.get(Name) != Definition["Default"]: + Result.ValuesReset += Name in Updated + Result.TagsAdded += Name not in Updated + Updated[Name] = Definition["Default"] + continue + if Mode == "Reset": + Initial = InitialValue(Definition) + if Updated.get(Name) != Initial: + Result.ValuesReset += 1 + Updated[Name] = Initial + elif Name not in Updated: + Updated[Name] = InitialValue(Definition) + Result.TagsAdded += 1 + + if Mode == "Clean": + Obsolete = [Key for Key in Updated if Key != "Parent" and Key not in Definitions] + for Key in Obsolete: + del Updated[Key] + Result.TagsRemoved += len(Obsolete) + + for Name, Definition in Definitions.items(): + if Name in Updated: + Problem = ValidateValue(Name, Updated[Name], Definition) + if Problem: + Result.InvalidValues.append(f"{FilePath}: {Problem}") + return Updated, Updated != Existing + + +def UpdateDirectory(Directory: Path, Mode: str, DryRun: bool, LexiconPath: Path | None = None) -> UpdateResult: + """One directory, with its own Lexicon (or LexiconPath, a Record's).""" + Directory = Directory.resolve() + Result = UpdateResult(Directory, Mode, DryRun, DirectoriesScanned=1) + LexiconPath = LexiconPath or TagCore.GetLexiconPath(Directory) + try: + Files = _OrdinaryFiles(Directory) + except OSError as Error: + Result.Errors.append(f"Couldn't read {Directory}: {Error}") + return Result + Parent = TagCore.GetBookParentValue(Directory) + + if Mode == "RepairParents": + for FilePath in Files: + BookPath = TagCore.GetBookPath(FilePath) + if not BookPath.is_file(): + continue + Result.FilesScanned += 1 + try: + Metadata = ReadToml(BookPath) + except DataError as Error: + Result.Errors.append(str(Error)) + continue + if Metadata.get("Parent") == Parent: + continue + Metadata = {"Parent": Parent} | {Key: Value for Key, Value in Metadata.items() if Key != "Parent"} + if _Write(BookPath, Metadata, Result): + Result.ParentsRepaired += 1 + Result.BooksUpdated += 1 + Result.ChangedBooks.append(BookPath) + return Result + + if not LexiconPath.is_file(): + return Result + Result.TaggedDirectories = 1 + try: + Definitions = {Name: Normalise(Definition) for Name, Definition in TagCore.ResolveLexiconTags(LexiconPath).items()} + except (OSError, ValueError) as Error: + Result.Errors.append(f"{LexiconPath.name}: {Error}") + return Result + + for Entry in Directory.iterdir(): + if TagCore.IsBookPath(Entry): + Owner = FileForBook(Entry) + if Owner is not None and not Owner.exists(): + Result.OrphanedBooks.append(Entry) + + for FilePath in Files: + Result.FilesScanned += 1 + BookPath = TagCore.GetBookPath(FilePath) + if not BookPath.is_file(): + Metadata = {"Parent": Parent} | {Name: InitialValue(Definition) for Name, Definition in Definitions.items()} + if _Write(BookPath, Metadata, Result): + Result.BooksCreated += 1 + Result.ChangedBooks.append(BookPath) + continue + try: + Existing = ReadToml(BookPath) + except DataError as Error: + Result.Errors.append(str(Error)) + continue + Updated, Changed = _Synchronise(Existing, Parent, Definitions, Mode, Result, FilePath) + if Changed and _Write(BookPath, Updated, Result): + Result.BooksUpdated += 1 + Result.ChangedBooks.append(BookPath) + return Result + + +def UpdateRecord(RootPath: Path, LexiconPath: Path | None, Mode: str, DryRun: bool, + Progress: Callable[[Path], None] | None = None) -> UpdateResult: + """A Record and every subdirectory with its own Lexicon.""" + RootPath = RootPath.resolve() + Result = UpdateResult(RootPath, Mode, DryRun) + if not RootPath.is_dir(): + Result.Errors.append(f"Missing directory: {RootPath}") + return Result + for Directory in _Directories(RootPath): + if Progress: + Progress(Directory) + Result.Merge(UpdateDirectory(Directory, Mode, DryRun, LexiconPath if Directory == RootPath else None)) + return Result diff --git a/Widgets.py b/Widgets.py new file mode 100644 index 0000000..ba9dc23 --- /dev/null +++ b/Widgets.py @@ -0,0 +1,241 @@ +"""What every page shares: the App context (config, notifications, dialogs, +opening files) and small reusable controls, including right-click menus that +work like SaraFem's and SaraSort's.""" + +import asyncio +import os +import shutil +import subprocess +import time +from pathlib import Path +from typing import Callable, Optional + +import flet as ft + +from Core import AppConfig, ChildEnvironment, ChipButton, Note, NotificationStyle, Palette +from Library import ConfValue + + +class App: + def __init__(self, Page: ft.Page, Config: AppConfig): + self.Page = Page + self.Config = Config + self.Picker = ft.FilePicker() # registers itself with the page + self.Navigate: Callable[..., None] = lambda Name, **Arguments: None # set by Main + # Text fields with focus; while any has it, list keyboard shortcuts stay off. + self.Typing = 0 + + def Notify(self, Text: str, Kind: str = "info") -> None: + Background, Accent = NotificationStyle(Kind) + self.Page.show_dialog(ft.SnackBar( + content=ft.Text(Text, color=Accent), + bgcolor=Background, + shape=ft.RoundedRectangleBorder(radius=4, side=ft.BorderSide(1, Accent)), + open=True, + )) + + def ShowDialog(self, Title: str, Content: ft.Control, Actions: list[ft.Control], Width: int = 560) -> ft.AlertDialog: + Dialog = ft.AlertDialog(title=Title, content=ft.Container(Content, width=Width), actions=Actions) + self.Page.show_dialog(Dialog) + return Dialog + + def Close(self) -> None: + self.Page.pop_dialog() + + def Confirm(self, Title: str, Text: str, OnYes: Callable[[], None], YesText: str = "Remove", Accent: str = "") -> None: + def Yes(e) -> None: + self.Close() + OnYes() + + self.ShowDialog(Title, ft.Text(Text), [ + ChipButton("Cancel", lambda e: self.Close()), + ChipButton(YesText, Yes, Accent=Accent or Palette.Red), + ], Width=460) + + def Ask(self, Title: str, Label: str, Value: str, OnOk: Callable[[str], None], OkText: str = "Save", + Help: str = "") -> None: + """A one-field dialog; Enter or the button submits.""" + Field = TextInput(self, Label=Label, Value=Value, Autofocus=True) + + def Ok(e=None) -> None: + self.Close() + OnOk(Field.value or "") + + Field.on_submit = Ok + self.ShowDialog(Title, ft.Column([Field] + ([Note(Help)] if Help else []), tight=True, spacing=8), [ + ChipButton("Cancel", lambda e: self.Close()), + ChipButton(OkText, Ok), + ], Width=460) + + def OpenPath(self, TargetPath: Path) -> None: + """Videos go to [Media] VideoPlayer from SaraTagConf.toml, everything + else to the default app.""" + from Query import VideoExtensions + + Command = ["xdg-open", str(TargetPath)] + if TargetPath.suffix.lower() in VideoExtensions: + Player = str(ConfValue("Media", "VideoPlayer", "") or "").strip() + if Player and shutil.which(Player.split()[0]): + Command = Player.split() + [str(TargetPath)] + elif Player: + self.Notify(f"Video player '{Player}' isn't installed; using the default app.", "warning") + try: + subprocess.Popen(Command, env=ChildEnvironment(), start_new_session=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError as Error: + self.Notify(f"Couldn't open {TargetPath.name}: {Error}", "error") + + def CopyText(self, Text: str, What: str) -> None: + async def Copy() -> None: + await ft.Clipboard().set(Text) + self.Notify(f"Copied {What}.", "success") + + self.Page.run_task(Copy) + + +def TextInput(TheApp: App, **Options) -> ft.TextField: + """A dense TextField that pauses list shortcuts while it has focus. + Label/Value/Autofocus/Expand/Width/Hint map to Flet's own names.""" + Names = {"Label": "label", "Value": "value", "Autofocus": "autofocus", "Expand": "expand", "Width": "width", + "Hint": "hint_text", "OnChange": "on_change", "OnSubmit": "on_submit", "Prefix": "prefix_icon"} + Field = ft.TextField(dense=True, **{Names.get(Key, Key): Value for Key, Value in Options.items()}) + OnFocus, OnBlur = Field.on_focus, Field.on_blur + + def Focus(e) -> None: + TheApp.Typing += 1 + if OnFocus: + OnFocus(e) + + def Blur(e) -> None: + TheApp.Typing = max(0, TheApp.Typing - 1) + if OnBlur: + OnBlur(e) + + Field.on_focus, Field.on_blur = Focus, Blur + return Field + + +def PathField(TheApp: App, Label: str, Value: str = "", Directory: bool = True, + Extensions: Optional[list[str]] = None) -> tuple[ft.Row, ft.TextField]: + """A path field with a Browse button. Returns (row, field).""" + Field = TextInput(TheApp, Label=Label, Value=Value, Expand=True) + + async def Browse(e) -> None: + if Directory: + Chosen = await TheApp.Picker.get_directory_path(dialog_title=Label, initial_directory=Field.value or None) + else: + Start = Path(os.path.expanduser(Field.value or "")).parent if Field.value else None + Files = await TheApp.Picker.pick_files( + dialog_title=Label, initial_directory=str(Start) if Start else None, + allowed_extensions=Extensions, file_type=ft.FilePickerFileType.CUSTOM if Extensions else ft.FilePickerFileType.ANY, + ) + Chosen = Files[0].path if Files else None + if Chosen: + Field.value = Chosen + Field.update() + + return ft.Row([Field, ft.IconButton(ft.Icons.FOLDER_OPEN_OUTLINED, tooltip="Browse", on_click=Browse)]), Field + + +class RightClickHost(ft.ContextMenu): + """One shared popup host per page, opened programmatically so a + right-click resolves to exactly one menu (the innermost target's), the + same way SaraFem and SaraSort do it. Wrap the page's content in it and + call Request(Target, Event) from on_secondary_tap_down handlers; Items + turns a target (None for the background) into menu items.""" + + def __init__(self, Items: Callable[[object], list[ft.PopupMenuItem]], Content: ft.Control): + # Built-in triggers can't be switched off (Flet reads a None trigger as + # its default), so LONG_PRESS with no per-button items keeps them inert. + super().__init__( + content=Content, + expand=True, + secondary_trigger=ft.ContextMenuTrigger.LONG_PRESS, + tertiary_trigger=ft.ContextMenuTrigger.LONG_PRESS, + on_select=self._Closed, + on_dismiss=self._Closed, + ) + self.ItemsFor = Items + self.MenuOpen = False + self.OpenedAt = 0.0 + self.Pending: Optional[tuple[object, ft.Offset]] = None + + def Request(self, Target: object, Event: ft.TapEvent) -> None: + if self.MenuOpen: + # Content can't be clicked while the menu's barrier covers it, so + # getting here means the menu is gone and its close event is late. + if time.monotonic() - self.OpenedAt < 0.5: + return + self.MenuOpen = False + First = self.Pending is None + if First or Target is not None: + self.Pending = (Target, Event.global_position) + if First: + self.page.run_task(self._OpenPending) + + async def _OpenPending(self) -> None: + await asyncio.sleep(0.08) + Target, Position = self.Pending + self.Pending = None + Items = self.ItemsFor(Target) + if not Items: + return + # open() shows `items`, and a method call doesn't flush property + # changes, so the items are sent first. + self.items = Items + self.update() + self.MenuOpen = True + self.OpenedAt = time.monotonic() + try: + await self.open(global_position=Position) + except Exception: + self.MenuOpen = False + raise + + def _Closed(self, Event: ft.ContextMenuDismissEvent) -> None: + # button is None only for menus opened through open(); the inert + # built-in triggers report "secondary"/"tertiary". + if Event.button is None: + self.MenuOpen = False + + +def MenuItem(Text: str, OnClick: Callable[[], None], Icon=None) -> ft.PopupMenuItem: + return ft.PopupMenuItem(content=Text, icon=Icon, on_click=lambda e: OnClick()) + + +def Panel(Content: ft.Control, **Options) -> ft.Container: + """A Mantle box with a thin border, like SaraFem's side panels.""" + return ft.Container(Content, bgcolor=Palette.Mantle, border=ft.Border.all(1, Palette.Surface1), border_radius=6, + padding=Options.pop("padding", 12), **Options) + + +def SectionTitle(Text: str, Trailing: Optional[list[ft.Control]] = None) -> ft.Row: + return ft.Row([ft.Text(Text, color=Palette.Mauve, size=16), ft.Container(expand=True), *(Trailing or [])], + vertical_alignment=ft.CrossAxisAlignment.CENTER) + + +def Badge(Text: str, Color: str) -> ft.Container: + return ft.Container(ft.Text(Text, size=11, color=Color), border=ft.Border.all(1, Color), border_radius=4, + padding=ft.Padding.symmetric(horizontal=6, vertical=1)) + + +def EmptyState(Text: str, Detail: str = "", Icon=None) -> ft.Control: + Controls: list[ft.Control] = [ft.Icon(Icon, size=40, color=Palette.Surface1)] if Icon else [] + Controls += [ft.Text(Text, color=Palette.Overlay0, size=16, text_align=ft.TextAlign.CENTER)] + if Detail: + Controls.append(Note(Detail)) + return ft.Container(ft.Column(Controls, horizontal_alignment=ft.CrossAxisAlignment.CENTER, tight=True), + alignment=ft.Alignment.CENTER, expand=True) + + +def HumanSize(NumBytes: float) -> str: + Size = float(NumBytes) + for Unit in ("B", "KB", "MB", "GB", "TB"): + if Size < 1024 or Unit == "TB": + return f"{Size:.0f} {Unit}" if Unit == "B" else f"{Size:.1f} {Unit}" + Size /= 1024 + return f"{Size:.1f} TB" + + +def Plural(Count: int, Word: str, PluralWord: str = "") -> str: + return f"{Count} {Word if Count == 1 else (PluralWord or Word + 's')}" diff --git a/assets/Icon.png b/assets/Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1b35b8e73e2b6f9c71b47c3692e89b9b7d266ae7 GIT binary patch literal 832 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJD{c25__kcv5P?=199b(C;O zym5zZp3{WVD^AR-Cu+R9A+#dFl=lJ?n@!(@2lLzA;wm;9FImrn$ zh!pVV{L5F&YsxI<)UUB)uDSm4ht;q4`oDJq8}z7Zm>*ed&sl9%m zKVSXy@v!&&+NAk-cHM%1^B8`A1KaOSn%+Nh2d=#O&hjtrzVEcd>qygEBL2U=z+z7S qJ>iC_%id?sAR(GaNs=J7H>UbOTxnY{=itSOAg-sYpUXO@geCwXIl~12 literal 0 HcmV?d00001 diff --git a/assets/fonts/Fixedsys_Excelsior_Regular.ttf b/assets/fonts/Fixedsys_Excelsior_Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a3dbddd04ebcc0fc6b13d8b4c2090eacc1e8dbd1 GIT binary patch literal 580724 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.0,<2.0 +flet-desktop>=1.0,<2.0 # the desktop client; otherwise Flet fetches it on first launch and quits once +tomlkit