Files
SaraSort/ConfigPage.py
T
sarahandClaude Opus 5.5 f7ed23edf9 SaraSort: Flet GUI for configuring SaraSortd
Flowchart of inputs/outputs with editable arrows, service page (status,
controls, autostart, version, logs), config editor (form + raw), templates,
tools (warnings, filename tester, failed files) and settings with
SaraFem-compatible export/import. AppImage build and a SaraSuite manifest
in Packaging/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:23:36 +02:00

439 lines
21 KiB
Python

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