"""Books: the per-file sidecars (..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