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