Files
SaraTag/Books.py
T
2026-10-09 14:21:42 +02:00

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