mirror of
https://git.srhtnin.garden/sarah/SaraTag.git
synced 2026-10-14 18:38:11 +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
+10
@@ -0,0 +1,10 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.directory
|
||||
Lexicons/
|
||||
build/
|
||||
.flet/
|
||||
dist/
|
||||
.SaraTagRecords.toml
|
||||
.SaraTagHistory.toml
|
||||
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."""
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.")
|
||||
@@ -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
@@ -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()))
|
||||
@@ -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))
|
||||
Executable
+6
@@ -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 "$@"
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
Executable
+39
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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')}"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 832 B |
Binary file not shown.
@@ -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
|
||||
Reference in new issue
Block a user