commit 845d6c6ee616911500df62542aedd742ceccb2e0 Author: SRHTNIN Date: Thu Oct 8 19:03:17 2026 +0200 SaraTag, a Flet GUI for tagging files. 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 0000000..1b35b8e Binary files /dev/null and b/assets/Icon.png differ diff --git a/assets/fonts/Fixedsys_Excelsior_Regular.ttf b/assets/fonts/Fixedsys_Excelsior_Regular.ttf new file mode 100644 index 0000000..a3dbddd Binary files /dev/null and b/assets/fonts/Fixedsys_Excelsior_Regular.ttf differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f282f7b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flet>=1.0,<2.0 +flet-desktop>=1.0,<2.0 # the desktop client; otherwise Flet fetches it on first launch and quits once +tomlkit