mirror of
https://git.srhtnin.garden/sarah/SaraTag.git
synced 2026-10-13 18:28:16 +02:00
238 lines
9.8 KiB
Python
238 lines
9.8 KiB
Python
"""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 "\\" 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
|