mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-13 18:28:18 +02:00
SaraSort finds configs and history files where SaraSortd 7.2 keeps them (DirConfDir, [DirConfs]), and Settings > SaraSortd moves the existing ones between "inside each directory" and one folder. Imports keep [DirConfs] entries with moved directories, and Warnings flag a config folder inside an input or a SaraSortd older than 7.2. Co-Authored-By: Claude Opus 5.5 <[email protected]>
444 lines
21 KiB
Python
444 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 _EntryOf(self, ConfPath: Path) -> tuple[str, str]:
|
|
"""(Kind, directory) of a config in the list. The directory is what
|
|
the list shows, since a config can live outside its directory."""
|
|
for _, Items in self._Entries():
|
|
for Label, ItemPath, Kind in Items:
|
|
if ItemPath == ConfPath:
|
|
return Kind, Label
|
|
return "Global", ""
|
|
|
|
def _KindOf(self, ConfPath: Path) -> str:
|
|
return self._EntryOf(ConfPath)[0]
|
|
|
|
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, DirPath = self._EntryOf(ConfPath)
|
|
TheSetup = self.TheSetup
|
|
OutputStyle = Kind in ("Out", "OutTemplate") or (
|
|
Kind == "In" and DirPath in TheSetup.Outputs and TheSetup.SharesConf(DirPath)
|
|
)
|
|
OutputPath = DirPath 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)
|