mirror of
https://git.srhtnin.garden/sarah/SaraTag.git
synced 2026-10-13 18:28:16 +02:00
SaraTag, a Flet GUI for tagging files.
This commit is contained in:
commit
845d6c6ee6
27 files changed
+6035
No files matched your search
Executable
+988
@@ -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
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
"""Internal SaraTag implementation modules."""
|
||||
Reference in new issue
Block a user