"""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.")