"""Every SaraSortd config in a list on the left (GlobalConf.toml first, then the two templates, then each directory's own); the chosen one opens on the right as a form or as raw TOML. Saving the form re-reads the file and only writes the values you changed, since SaraSortd itself keeps updating NextNum, NextChar and LastFile in output configs while it runs. """ from pathlib import Path from typing import Any, Callable, Optional import flet as ft import tomlkit from tomlkit.items import AoT from Core import ChipButton, ChipButtonStyle, DisplayPath, Heading, Note, Palette from Sortd import Setup, SwitchKeys, ReadToml, WriteToml from Widgets import App, EmptyState, NameField ListWidth = 270 Help = { "Title": "Don't change: SaraSortd uses it to tell configs apart.", "Version": "Config version. The first number has to match SaraSortd's.", "SafeMode": "Stop SaraSortd when a config has an error.", "SilentMode": "Don't print what SaraSortd does to the terminal / journal.", "SortTimer": "Seconds between checks of the input directories.", "DotFiles": "Also sort files whose names start with \".\".", "Logging": "Write log files to LogDir.", "OverwriteDirConf": "Overwrite directory configs with the templates on every start.", "NextNum": "Variable: the next free number (or, in [[Files]], the next number to use).", "NextChar": "Variable: the next free letter (or, in [[Files]], the next letter to use).", "Parent": "Variable: the parent directory's name.", "OrgFileName": "Variable: the sorted file's original name, without extension.", "OrgFileType": "Variable: the sorted file's original extension, e.g. .png.", "Year": "Variable: the current year.", "Month": "Variable: the current month.", "Day": "Variable: the current day.", "Hour": "Variable: the current hour.", "Minute": "Variable: the current minute.", "Second": "Variable: the current second.", "VarCall": "The two characters around %Name% lookups.", "RootDir": "A shorthand other paths can use as %RootDir%. Add more keys like it.", "InputDir": "Directories whose files get sorted. One per line.", "OutputDir": "Directories files get sorted into. One per line.", "LogDir": "Where SaraSortd writes its logs.", "FailedDir": "Where files go that no output takes.", "LogFileName": "Name for log files.", "InDirConfName": "Name of input directories' config files.", "OutDirConfName": "Name of output directories' config files.", "FileHistoryName": "Name of the history file used when an input sorts into itself.", "LastFile": "Updated by SaraSortd: the last file sorted here.", "ParentDir": "Updated by SaraSortd: this directory's name.", "ValidInputDirs": "Input directories allowed to sort here; * and ? are wildcards. One per line. " "In a [[Files]] entry: only for that entry (empty = every allowed input).", "FileLimit": "Most files this directory may hold (0 = no limit).", "DeleteOrg": "Move files here (off: copy them).", "Unzip": "Archive types to unpack here, e.g. zip, tar.gz. One per line.", "SortDirs": "Also sort directories (they're zipped first).", "Pattern": "Which files this entry is for; * and ? are wildcards.", "NewFileName": "The sorted file's new name.", "CaseSensitive": "Match Pattern case-sensitively.", "Overwrite": "Let a file with the same name be overwritten.", } ListKeys = {"InputDir", "OutputDir", "ValidInputDirs", "Unzip"} class FormBuilder: """Turns TOML data into controls and remembers how to read each value back.""" def __init__(self, TheSetup: Optional[Setup], OutputPath: str, EntryDefaults: dict, OutputStyle: bool): self.TheSetup = TheSetup self.OutputPath = OutputPath self.EntryDefaults = EntryDefaults self.OutputStyle = OutputStyle self.Getters: dict[tuple, tuple[Callable[[], Any], Any]] = {} self.ArrayEditors: dict[tuple, "ArrayEditor"] = {} def Field(self, KeyPath: tuple, Key: str, Value: Any, InEntry: bool = False) -> ft.Control: Tip = Help.get(Key, "") if Key in SwitchKeys and isinstance(Value, int) and not isinstance(Value, bool) and Value in (0, 1): Control = ft.Switch(label=Key, value=Value == 1) self.Getters[KeyPath] = (lambda: 1 if Control.value else 0, Value) elif isinstance(Value, bool): Control = ft.Switch(label=Key, value=Value) self.Getters[KeyPath] = (lambda: bool(Control.value), Value) elif isinstance(Value, (int, float)): Control = ft.TextField(label=Key, value=str(Value), dense=True, width=200, keyboard_type=ft.KeyboardType.NUMBER) Kind = type(Value) def ReadNumber(Control=Control, Kind=Kind, Value=Value): try: return Kind(Control.value) except (TypeError, ValueError): return Value self.Getters[KeyPath] = (ReadNumber, Value) elif isinstance(Value, list) or Key in ListKeys: Items = Value if isinstance(Value, list) else [] Control = ft.TextField(label=Key, value="\n".join(str(Item) for Item in Items), multiline=True, min_lines=1, max_lines=8, dense=True) self.Getters[KeyPath] = ( lambda: [Line.strip() for Line in (Control.value or "").splitlines() if Line.strip()], Value, ) elif Key == "NewFileName": Control = NameField(self.TheSetup, str(Value), self.OutputPath, self.EntryDefaults) self.Getters[KeyPath] = (lambda: Control.value, Value) else: Control = ft.TextField(label=Key, value=str(Value), dense=True) self.Getters[KeyPath] = (lambda: Control.value or "", Value) if Tip: return ft.Column([Control, Note(Tip, 11)], spacing=2) return Control def Table(self, Data: dict, Prefix: tuple = ()) -> list[ft.Control]: Controls: list[ft.Control] = [] Sections: list[ft.Control] = [] for Key, Value in Data.items(): KeyPath = Prefix + (Key,) if isinstance(Value, dict): Sections.append(ft.Text(f"[{'.'.join(KeyPath)}]", color=Palette.Mauve, size=16)) Sections.extend(self.Table(Value, KeyPath)) elif isinstance(Value, list) and Value and all(isinstance(Item, dict) for Item in Value) or (Key == "Files" and isinstance(Value, list)): Editor = ArrayEditor(self, KeyPath, Value) self.ArrayEditors[KeyPath] = Editor Sections.append(Editor) else: Controls.append(self.Field(KeyPath, Key, Value)) return Controls + Sections def NewEntry(self) -> dict: if self.OutputStyle: Entry = {"Pattern": "*"} Entry.update({Key: Value for Key, Value in self.EntryDefaults.items() if Key != "Pattern"}) return Entry return {"Pattern": "*", "CaseSensitive": 0} class ArrayEditor(ft.Column): """[[Files]]: one card per entry, with add, remove and reorder.""" def __init__(self, Builder: FormBuilder, KeyPath: tuple, Entries: list[dict]): super().__init__(spacing=8) self.Builder = Builder self.KeyPath = KeyPath self.Original = [dict(Entry) for Entry in Entries] self.Entries = [dict(Entry) for Entry in Entries] self.Readers: list[Callable[[], dict]] = [] self._Build() def _Build(self) -> None: self.Readers = [] Cards = [] for Index, Entry in enumerate(self.Entries): Local = FormBuilder(self.Builder.TheSetup, self.Builder.OutputPath, Entry, self.Builder.OutputStyle) Fields = [] Keys = list(Entry.keys()) if self.Builder.OutputStyle and "ValidInputDirs" not in Keys: Keys.append("ValidInputDirs") # optional per entry (7.1) for Key in Keys: Fields.append(Local.Field((Key,), Key, Entry.get(Key, []), InEntry=True)) def Reader(Local=Local, Entry=Entry) -> dict: Result = {} for (Key,), (Getter, Old) in Local.Getters.items(): Value = Getter() if Key == "ValidInputDirs" and Value == [] and "ValidInputDirs" not in Entry: continue if Key == "ValidInputDirs" and Value == []: continue # empty: the entry takes files from every allowed input Result[Key] = Value return Result self.Readers.append(Reader) Cards.append(ft.Container( ft.Column([ ft.Row([ ft.Text(f"[[{'.'.join(self.KeyPath)}]] #{Index + 1}", color=Palette.Mauve), ft.Container(expand=True), ft.IconButton(ft.Icons.ARROW_UPWARD, tooltip="Move up (earlier entries match first)", disabled=Index == 0, on_click=lambda e, Index=Index: self._Move(Index, -1)), ft.IconButton(ft.Icons.ARROW_DOWNWARD, tooltip="Move down", disabled=Index == len(self.Entries) - 1, on_click=lambda e, Index=Index: self._Move(Index, 1)), ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_color=Palette.Red, tooltip="Remove entry", on_click=lambda e, Index=Index: self._Remove(Index)), ]), *Fields, ], spacing=8), padding=12, border=ft.Border.all(1, Palette.Surface1), border_radius=6, bgcolor=Palette.Mantle, )) self.controls = [ ft.Text(f"[[{'.'.join(self.KeyPath)}]]", color=Palette.Mauve, size=16), *Cards, ChipButton("Add entry", lambda e: self._Add(), ft.Icons.ADD), ] def _Snapshot(self) -> None: self.Entries = [Reader() for Reader in self.Readers] def _Add(self) -> None: self._Snapshot() self.Entries.append(self.Builder.NewEntry()) self._Build() self.update() def _Remove(self, Index: int) -> None: self._Snapshot() del self.Entries[Index] self._Build() self.update() def _Move(self, Index: int, Step: int) -> None: self._Snapshot() Other = Index + Step self.Entries[Index], self.Entries[Other] = self.Entries[Other], self.Entries[Index] self._Build() self.update() def Value(self) -> list[dict]: return [Reader() for Reader in self.Readers] class ConfigPage(ft.Row): def __init__(self, TheApp: App): super().__init__(expand=True, spacing=0, vertical_alignment=ft.CrossAxisAlignment.STRETCH) self.App = TheApp self.TheSetup: Optional[Setup] = None self.Current: Optional[Path] = None self.Mode = "Form" self.Builder: Optional[FormBuilder] = None self.RawField: Optional[ft.TextField] = None self.List = ft.ListView(spacing=0, padding=6, expand=True) self.Editor = ft.Container(expand=True) self.controls = [ ft.Container( ft.Column([ ft.Container(Heading("Configs"), padding=ft.Padding.only(left=10, top=12, bottom=6)), self.List, ], spacing=0), width=ListWidth, bgcolor=Palette.Mantle, ), ft.VerticalDivider(width=1, color=Palette.Surface1), self.Editor, ] def _Entries(self) -> list[tuple[str, list[tuple[str, Path, str]]]]: """(group, [(label, path, kind)])""" TheSetup = self.TheSetup Groups = [("SaraSortd", [("GlobalConf.toml", TheSetup.GlobalPath, "Global")])] Groups.append(("Templates", [ ("InputDirConf.toml", TheSetup.TemplatePath("In"), "InTemplate"), ("OutputDirConf.toml", TheSetup.TemplatePath("Out"), "OutTemplate"), ])) Seen: set[Path] = set() for Title, Kind, Paths in (("Inputs", "In", TheSetup.Inputs), ("Outputs", "Out", TheSetup.Outputs)): Items = [] for DirPath in dict.fromkeys(Paths): ConfPath = TheSetup.ConfFile(DirPath, Kind) if ConfPath in Seen: continue Seen.add(ConfPath) Items.append((DirPath, ConfPath, Kind)) Groups.append((Title, Items)) return Groups def Refresh(self, Open: Optional[str] = None) -> None: TheSetup, Error = self.App.LoadSetup() self.TheSetup = TheSetup if TheSetup is None: self.List.controls = [] self.Editor.content = EmptyState("Can't read SaraSortd's config.", Error) self.update() return if Open: self.Current = Path(Open) elif self.Current is None: self.Current = TheSetup.GlobalPath self._BuildList() self._OpenCurrent() self.update() def _BuildList(self) -> None: Controls = [] for Group, Items in self._Entries(): Controls.append(ft.Container(Note(Group), padding=ft.Padding.only(left=10, top=10, bottom=2))) if not Items: Controls.append(ft.Container(Note("none", 11), padding=ft.Padding.only(left=16))) for Label, ConfPath, Kind in Items: Missing = not ConfPath.exists() # Directories: their name, with the path below it. IsDir = Kind in ("In", "Out") Controls.append(ft.ListTile( title=ft.Text(Path(Label).name if IsDir else Label, size=13, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS, color=Palette.Overlay0 if Missing else None), subtitle=ft.Text("not made yet" if Missing else (DisplayPath(Label) if IsDir else ConfPath.name), size=11, color=Palette.Overlay0, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS), tooltip=str(ConfPath), dense=True, selected=ConfPath == self.Current, selected_tile_color=Palette.Surface0, selected_color=Palette.Mauve, disabled=Missing, on_click=lambda e, ConfPath=ConfPath: self._Select(ConfPath), )) self.List.controls = Controls def _Select(self, ConfPath: Path) -> None: self.Current = ConfPath self._BuildList() self._OpenCurrent() self.update() def _KindOf(self, ConfPath: Path) -> str: for _, Items in self._Entries(): for _, ItemPath, Kind in Items: if ItemPath == ConfPath: return Kind return "Global" def _OpenCurrent(self) -> None: ConfPath = self.Current if ConfPath is None or not ConfPath.exists(): self.Editor.content = EmptyState("Pick a config on the left.") return try: Text = ConfPath.read_text(encoding="utf-8") Data = tomlkit.parse(Text).unwrap() except (OSError, tomlkit.exceptions.TOMLKitError) as Error: Data = None Text = ConfPath.read_text(encoding="utf-8", errors="replace") if ConfPath.exists() else "" self.Mode = "Raw" self.App.Notify(f"{ConfPath.name} isn't valid TOML ({Error}); showing it raw.", "warning") Kind = self._KindOf(ConfPath) TheSetup = self.TheSetup OutputStyle = Kind in ("Out", "OutTemplate") or ( Kind == "In" and TheSetup.SharedConfName() and ConfPath.parent.as_posix() in TheSetup.Outputs ) OutputPath = ConfPath.parent.as_posix() if Kind in ("In", "Out") else "/Output" FormButton = ChipButton("Form", lambda e: self._SetMode("Form")) RawButton = ChipButton("Raw", lambda e: self._SetMode("Raw")) FormButton.style = ChipButtonStyle(Palette.Mauve if self.Mode == "Form" else Palette.Surface1) RawButton.style = ChipButtonStyle(Palette.Mauve if self.Mode == "Raw" else Palette.Surface1) FormButton.disabled = Data is None if self.Mode == "Form" and Data is not None: self.Builder = FormBuilder(TheSetup, OutputPath, TheSetup.TemplateFileDefaults(), OutputStyle) Body = ft.ListView(self.Builder.Table(Data), spacing=12, padding=16, expand=True) self.RawField = None else: self.Builder = None self.RawField = ft.TextField(value=Text, multiline=True, expand=True, text_size=13, border_color=Palette.Surface1, min_lines=20) Body = ft.Container(self.RawField, padding=16, expand=True) Notes = { "Global": "SaraSortd reads this when it starts" + (", so saving restarts it." if self.App.Config.AutoRestart else "; restart it after saving."), "InTemplate": "Template: copied into every new input directory.", "OutTemplate": "Template: copied into every new output directory. Its first [[Files]] entry is the default for new arrows.", "In": "Read by SaraSortd on every check; no restart needed.", "Out": "Read by SaraSortd on every check; no restart needed. SaraSortd updates NextNum, NextChar and LastFile itself.", } self.Editor.content = ft.Column([ ft.Container(ft.Column([ ft.Row([ ft.Text(DisplayPath(str(ConfPath)), color=Palette.Mauve, size=15, selectable=True, expand=True), FormButton, RawButton, ChipButton("Revert", lambda e: self._Select(ConfPath), ft.Icons.UNDO), ChipButton("Save", lambda e: self._Save(), ft.Icons.SAVE_OUTLINED), ]), Note(Notes.get(Kind, "")), ], spacing=4), padding=ft.Padding.only(left=16, right=16, top=12)), ft.Divider(height=1, color=Palette.Surface1), Body, ], spacing=6, expand=True) def _SetMode(self, Mode: str) -> None: self.Mode = Mode self._OpenCurrent() self.update() def _Save(self) -> None: ConfPath = self.Current try: if self.RawField is not None: Document = tomlkit.parse(self.RawField.value or "") WriteToml(ConfPath, Document) elif self.Builder is not None: self._SaveForm(ConfPath) except tomlkit.exceptions.TOMLKitError as Error: self.App.Notify(f"Not saved, it isn't valid TOML: {Error}", "error") return except OSError as Error: self.App.Notify(f"Couldn't save: {Error}", "error") return if self._KindOf(ConfPath) == "Global": self.App.AfterGlobalChange() else: self.App.Notify(f"Saved {ConfPath.name}.", "success") self.Refresh() def _SaveForm(self, ConfPath: Path) -> None: Document = ReadToml(ConfPath) # fresh: SaraSortd may have changed it meanwhile for KeyPath, (Getter, Old) in self.Builder.Getters.items(): New = Getter() if New == Old: continue Container = Document for Key in KeyPath[:-1]: Container = Container.setdefault(Key, tomlkit.table()) Container[KeyPath[-1]] = New for KeyPath, Editor in self.Builder.ArrayEditors.items(): New = Editor.Value() if New == Editor.Original: continue Container = Document for Key in KeyPath[:-1]: Container = Container.setdefault(Key, tomlkit.table()) Existing = Container.get(KeyPath[-1]) if Existing is not None and len(Existing) == len(New) and len(New) == len(Editor.Original): # Same entries: only touch changed keys, so SaraSortd's # NextNum/NextChar updates in between aren't lost. for Index, (Entry, Before) in enumerate(zip(New, Editor.Original)): Target = Existing[Index] for Key, Value in Entry.items(): if Before.get(Key) != Value: Target[Key] = Value for Key in set(Before) - set(Entry): if Key in Target: del Target[Key] else: Array = AoT([]) for Entry in New: Table = tomlkit.table() for Key, Value in Entry.items(): Table[Key] = Value Array.append(Table) Container[KeyPath[-1]] = Array WriteToml(ConfPath, Document)