SaraTag, a Flet GUI for tagging files.

This commit is contained in:
sarah committed 2026-10-08 19:03:17 +02:00
commit 845d6c6ee6
27 files changed
+6035

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
.venv/
__pycache__/
*.pyc
.directory
Lexicons/
build/
.flet/
dist/
.SaraTagRecords.toml
.SaraTagHistory.toml
+988
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Internal SaraTag implementation modules."""
+237
View File
@@ -0,0 +1,237 @@
"""Books: the per-file sidecars (.<file name>.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
+795
View File
@@ -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,
)
+308
View File
@@ -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/<ThemeName>.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
+132
View File
@@ -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)
+373
View File
@@ -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
+476
View File
@@ -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.")
+190
View File
@@ -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)
+120
View File
@@ -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()))
+109
View File
@@ -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))
+6
View File
@@ -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 "$@"
+221
View File
@@ -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:<name>), 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)
+69
View File
@@ -0,0 +1,69 @@
# SaraTag
Tag your files without touching them, built with Flet. Every file gets a **Book**, a hidden sidecar
(`.<file name>.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 |
+426
View File
@@ -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"(?<!\n\n)^(\[\[)", r"\n\1", tomlkit.dumps(Document), flags=re.MULTILINE)
WriteAtomically(RecordsPath, Text)
except OSError as Error:
raise DataError(f"Couldn't save {RecordsPath.name}: {Error}") from Error
def _Tables(Document: tomlkit.TOMLDocument, Name: str) -> 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
+388
View File
@@ -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/<parent directory>.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)
Executable
+10
View File
@@ -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))
+39
View File
@@ -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"
+286
View File
@@ -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),
],
))
+236
View File
@@ -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))]
+153
View File
@@ -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,
)
+218
View File
@@ -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
+241
View File
@@ -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')}"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

Binary file not shown.
+3
View File
@@ -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