mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-13 18:28:18 +02:00
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]>
This commit is contained in:
commit
f7ed23edf9
22 files changed
+3399
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
build/
|
||||||
|
.flet/
|
||||||
|
dist/
|
||||||
|
*.AppImage
|
||||||
+438
@@ -0,0 +1,438 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""Shared app-wide constants: paths, palette, theme, SaraSort's own config.
|
||||||
|
|
||||||
|
The palette, theming and shared settings mirror SaraSuite's and SaraFem's
|
||||||
|
Core.py and read the same ~/.config/SaraSuite files, so a theme picked for
|
||||||
|
one Sara program looks the same in all of them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
AppName = "SaraSort"
|
||||||
|
# Bundled in assets/fonts and always registered, so it works as FontFamily
|
||||||
|
# without being installed.
|
||||||
|
BundledFontFamily = "Fixedsys Excelsior"
|
||||||
|
ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme
|
||||||
|
|
||||||
|
Home = Path.home()
|
||||||
|
ConfigHome = Home / ".config"
|
||||||
|
DataHome = Home / ".local" / "share"
|
||||||
|
|
||||||
|
SharedConfigDir = ConfigHome / "SaraSuite"
|
||||||
|
ThemesDir = SharedConfigDir / "Themes"
|
||||||
|
SharedConfigPath = SharedConfigDir / "Config.toml"
|
||||||
|
ConfigDir = SharedConfigDir / AppName
|
||||||
|
ConfigPath = ConfigDir / "Config.toml"
|
||||||
|
LayoutPath = ConfigDir / "Layout.toml" # where flowchart boxes were dragged to
|
||||||
|
UserTemplatesDir = ConfigDir / "Templates"
|
||||||
|
|
||||||
|
# Inside a PyInstaller build, bundled files are unpacked to sys._MEIPASS.
|
||||||
|
BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
|
||||||
|
AssetsDir = BundleDir / "assets"
|
||||||
|
|
||||||
|
# Themes SaraFem seeds into ThemesDir. Only other themes travel with an
|
||||||
|
# export as full palettes (same rule as SaraFem's ExportSettings).
|
||||||
|
BuiltinThemeNames = {"CatppuccinMocha", "Forest", "Gruvbox"}
|
||||||
|
|
||||||
|
|
||||||
|
class Palette:
|
||||||
|
"""Live values for the active theme (see ApplyTheme). Same attribute names
|
||||||
|
and role mapping as SaraSuite's and SaraFem's Palette."""
|
||||||
|
|
||||||
|
Mauve = "#cba6f7"
|
||||||
|
Red = "#f38ba8"
|
||||||
|
Peach = "#fab387"
|
||||||
|
Yellow = "#f9e2af"
|
||||||
|
Green = "#a6e3a1"
|
||||||
|
Blue = "#89b4fa"
|
||||||
|
Text = "#cdd6f4"
|
||||||
|
White = "#cdd6f4"
|
||||||
|
Overlay0 = "#6c7086"
|
||||||
|
Surface1 = "#45475a"
|
||||||
|
Surface0 = "#313244"
|
||||||
|
Base = "#1e1e2e"
|
||||||
|
Mantle = "#181825"
|
||||||
|
Crust = "#11111b"
|
||||||
|
|
||||||
|
|
||||||
|
_ThemeRoleMap = {
|
||||||
|
"Background": "Base", "Surface": "Mantle", "SurfaceAlt": "Surface0", "Border": "Surface1",
|
||||||
|
"Text": "Text", "TextMuted": "Overlay0", "Black": "Crust", "White": "White",
|
||||||
|
"Red": "Red", "Orange": "Peach", "Yellow": "Yellow", "Green": "Green",
|
||||||
|
"Blue": "Blue", "Purple": "Mauve",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ApplyTheme(ThemeName: str) -> None:
|
||||||
|
"""Mutate Palette in place from ~/.config/SaraSuite/Themes/<ThemeName>.toml.
|
||||||
|
Keeps the built-in Catppuccin Mocha values if the file is missing. Must
|
||||||
|
run before any control reads Palette.X."""
|
||||||
|
ThemeFile = ThemesDir / f"{ThemeName}.toml"
|
||||||
|
if not ThemeFile.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
RoleValues = tomlkit.parse(ThemeFile.read_text()).unwrap().get("Palette", {})
|
||||||
|
except tomlkit.exceptions.TOMLKitError:
|
||||||
|
return
|
||||||
|
for RoleName, AttrName in _ThemeRoleMap.items():
|
||||||
|
if RoleName in RoleValues:
|
||||||
|
setattr(Palette, AttrName, RoleValues[RoleName])
|
||||||
|
|
||||||
|
|
||||||
|
def AvailableThemes() -> list[str]:
|
||||||
|
Names = {ThemePath.stem for ThemePath in ThemesDir.glob("*.toml")} if ThemesDir.is_dir() else set()
|
||||||
|
Names.add("CatppuccinMocha")
|
||||||
|
return sorted(Names)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppConfig:
|
||||||
|
# True: Theme, FontFamily and HideWindowTitleBar come from the shared
|
||||||
|
# ~/.config/SaraSuite/Config.toml. False: SaraSort's own values below.
|
||||||
|
UseSharedSettings: bool = True
|
||||||
|
Theme: str = "CatppuccinMocha"
|
||||||
|
FontFamily: str = BundledFontFamily
|
||||||
|
HideWindowTitleBar: bool = False
|
||||||
|
# Where SaraSuite installs SaraSortd: GlobalConf.toml and the two
|
||||||
|
# directory config templates live in SortdConfigDir, SaraSortd.py in
|
||||||
|
# SortdAppDir, and it runs as a systemd user unit.
|
||||||
|
SortdConfigDir: str = "~/.config/SaraSuite/SaraSortd"
|
||||||
|
SortdAppDir: str = "~/.local/share/SaraSuite/Programs/SaraSortd/App"
|
||||||
|
ServiceName: str = "SaraSortd.service"
|
||||||
|
# SaraSortd only reads GlobalConf.toml when it starts.
|
||||||
|
AutoRestart: bool = True
|
||||||
|
RefreshSeconds: int = 5 # status and log refresh on the Service page
|
||||||
|
LogLines: int = 500
|
||||||
|
LabelLength: int = 22 # arrow labels longer than this are shortened
|
||||||
|
StartPage: str = "Flow"
|
||||||
|
|
||||||
|
def Save(self) -> None:
|
||||||
|
ConfigDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ConfigPath.write_text(tomlkit.dumps(asdict(self)))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def GlobalConfPath(self) -> Path:
|
||||||
|
return Path(self.SortdConfigDir).expanduser() / "GlobalConf.toml"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def SortdScriptPath(self) -> Path:
|
||||||
|
return Path(self.SortdAppDir).expanduser() / "SaraSortd.py"
|
||||||
|
|
||||||
|
|
||||||
|
def LoadConfig() -> AppConfig:
|
||||||
|
if not ConfigPath.exists():
|
||||||
|
Config = AppConfig()
|
||||||
|
Config.Save()
|
||||||
|
return Config
|
||||||
|
try:
|
||||||
|
Data = tomlkit.parse(ConfigPath.read_text()).unwrap()
|
||||||
|
except tomlkit.exceptions.TOMLKitError:
|
||||||
|
return AppConfig()
|
||||||
|
KnownFields = set(AppConfig.__dataclass_fields__)
|
||||||
|
return AppConfig(**{Key: Value for Key, Value in Data.items() if Key in KnownFields})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SharedSettings:
|
||||||
|
"""~/.config/SaraSuite/Config.toml, shared by every SaraSuite program.
|
||||||
|
Same schema and fallback rules as SaraSuite's and SaraFem's."""
|
||||||
|
|
||||||
|
Theme: str = "CatppuccinMocha"
|
||||||
|
FontFamily: str = BundledFontFamily
|
||||||
|
HideWindowTitleBar: bool = False
|
||||||
|
|
||||||
|
def Save(self) -> None:
|
||||||
|
SharedConfigDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
SharedConfigPath.write_text(tomlkit.dumps(asdict(self)))
|
||||||
|
|
||||||
|
|
||||||
|
def _OwnSettings(Config: AppConfig) -> SharedSettings:
|
||||||
|
return SharedSettings(Config.Theme, Config.FontFamily, Config.HideWindowTitleBar)
|
||||||
|
|
||||||
|
|
||||||
|
def LoadSharedSettings(Config: AppConfig) -> SharedSettings:
|
||||||
|
"""The shared file, created from SaraSort's own values if missing."""
|
||||||
|
if not SharedConfigPath.exists():
|
||||||
|
Shared = _OwnSettings(Config)
|
||||||
|
Shared.Save()
|
||||||
|
return Shared
|
||||||
|
Result = _OwnSettings(Config) # fallback for keys the file doesn't have
|
||||||
|
try:
|
||||||
|
Data = tomlkit.parse(SharedConfigPath.read_text()).unwrap()
|
||||||
|
except tomlkit.exceptions.TOMLKitError:
|
||||||
|
return Result
|
||||||
|
for Key in SharedSettings.__dataclass_fields__:
|
||||||
|
if Key in Data:
|
||||||
|
setattr(Result, Key, Data[Key])
|
||||||
|
return Result
|
||||||
|
|
||||||
|
|
||||||
|
def EffectiveSettings(Config: AppConfig) -> SharedSettings:
|
||||||
|
return LoadSharedSettings(Config) if Config.UseSharedSettings else _OwnSettings(Config)
|
||||||
|
|
||||||
|
|
||||||
|
def ExportSettings(Config: AppConfig, DestPath: Path) -> None:
|
||||||
|
"""One portable file in the same format as SaraFem's export: the shared
|
||||||
|
settings under [SaraSuite] (with a custom theme's palette as ThemeData)
|
||||||
|
and SaraSort's own under [SaraSort]. Other programs ignore [SaraSort],
|
||||||
|
and SaraSort ignores their sections."""
|
||||||
|
Shared = LoadSharedSettings(Config)
|
||||||
|
SharedData = asdict(Shared)
|
||||||
|
if Shared.Theme not in BuiltinThemeNames:
|
||||||
|
ThemeFile = ThemesDir / f"{Shared.Theme}.toml"
|
||||||
|
if ThemeFile.exists():
|
||||||
|
try:
|
||||||
|
SharedData["ThemeData"] = tomlkit.parse(ThemeFile.read_text()).unwrap().get("Palette", {})
|
||||||
|
except tomlkit.exceptions.TOMLKitError:
|
||||||
|
pass
|
||||||
|
DestPath.write_text(tomlkit.dumps({"SaraSuite": SharedData, AppName: asdict(Config)}))
|
||||||
|
|
||||||
|
|
||||||
|
def ImportSettings(Config: AppConfig, SourcePath: Path) -> None:
|
||||||
|
"""Applies [SaraSuite] to the shared file and [SaraSort] onto Config, key
|
||||||
|
by key: keys the file doesn't mention stay as they are, unknown sections
|
||||||
|
and keys are skipped. Raises TOMLKitError on an unreadable file."""
|
||||||
|
Data = tomlkit.parse(SourcePath.read_text()).unwrap()
|
||||||
|
|
||||||
|
SharedData = Data.get("SaraSuite")
|
||||||
|
if isinstance(SharedData, dict):
|
||||||
|
ThemeData = SharedData.pop("ThemeData", None)
|
||||||
|
Merged = asdict(LoadSharedSettings(Config))
|
||||||
|
Merged.update({Key: Value for Key, Value in SharedData.items() if Key in SharedSettings.__dataclass_fields__})
|
||||||
|
ImportedTheme = Merged.get("Theme")
|
||||||
|
if isinstance(ThemeData, dict) and ImportedTheme and ImportedTheme not in BuiltinThemeNames:
|
||||||
|
ThemesDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(ThemesDir / f"{ImportedTheme}.toml").write_text(tomlkit.dumps({"Palette": ThemeData}))
|
||||||
|
SharedSettings(**Merged).Save()
|
||||||
|
|
||||||
|
OwnData = Data.get(AppName)
|
||||||
|
if isinstance(OwnData, dict):
|
||||||
|
for Key, Value in OwnData.items():
|
||||||
|
if Key in AppConfig.__dataclass_fields__:
|
||||||
|
setattr(Config, Key, Value)
|
||||||
|
Config.Save()
|
||||||
|
|
||||||
|
|
||||||
|
def ApplyAppearance(Settings: SharedSettings) -> None:
|
||||||
|
"""Theme colours and font; must run before anything reads Palette.X."""
|
||||||
|
global ActiveFontFamily
|
||||||
|
ApplyTheme(Settings.Theme)
|
||||||
|
ActiveFontFamily = Settings.FontFamily or BundledFontFamily
|
||||||
|
|
||||||
|
|
||||||
|
def ChildEnvironment() -> dict[str, str]:
|
||||||
|
"""The environment for child processes (systemctl, journalctl, xdg-open).
|
||||||
|
A PyInstaller build points LD_LIBRARY_PATH at its own bundled libraries;
|
||||||
|
system tools need the original back."""
|
||||||
|
Environment = dict(os.environ)
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
Original = Environment.pop("LD_LIBRARY_PATH_ORIG", None)
|
||||||
|
if Original is not None:
|
||||||
|
Environment["LD_LIBRARY_PATH"] = Original
|
||||||
|
else:
|
||||||
|
Environment.pop("LD_LIBRARY_PATH", None)
|
||||||
|
for Key in ("PYTHONHOME", "PYTHONPATH"):
|
||||||
|
Environment.pop(Key, None)
|
||||||
|
return Environment
|
||||||
|
|
||||||
|
|
||||||
|
def BuildTheme() -> ft.Theme:
|
||||||
|
ColorScheme = ft.ColorScheme(
|
||||||
|
primary=Palette.Mauve,
|
||||||
|
on_primary=Palette.Crust,
|
||||||
|
primary_container=Palette.Surface0,
|
||||||
|
on_primary_container=Palette.Mauve,
|
||||||
|
secondary=Palette.Mauve,
|
||||||
|
on_secondary=Palette.Crust,
|
||||||
|
secondary_container=Palette.Surface0,
|
||||||
|
on_secondary_container=Palette.Mauve,
|
||||||
|
surface=Palette.Base,
|
||||||
|
on_surface=Palette.Text,
|
||||||
|
on_surface_variant=Palette.Overlay0,
|
||||||
|
surface_container=Palette.Mantle,
|
||||||
|
surface_container_low=Palette.Mantle,
|
||||||
|
surface_container_high=Palette.Surface0,
|
||||||
|
surface_container_highest=Palette.Surface1,
|
||||||
|
outline=Palette.Surface1,
|
||||||
|
outline_variant=Palette.Surface1,
|
||||||
|
error=Palette.Red,
|
||||||
|
on_error=Palette.Crust,
|
||||||
|
)
|
||||||
|
return ft.Theme(
|
||||||
|
font_family=ActiveFontFamily,
|
||||||
|
color_scheme=ColorScheme,
|
||||||
|
use_material3=True,
|
||||||
|
scaffold_bgcolor=Palette.Base,
|
||||||
|
canvas_color=Palette.Base,
|
||||||
|
dialog_theme=ft.DialogTheme(
|
||||||
|
bgcolor=Palette.Mantle,
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=8, side=ft.BorderSide(1, Palette.Mauve)),
|
||||||
|
title_text_style=ft.TextStyle(color=Palette.Mauve, size=18, font_family=ActiveFontFamily),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ChipButtonStyle(Accent: str = "") -> ft.ButtonStyle:
|
||||||
|
"""SaraView's button look: SurfaceAlt fill, 1px accent border, small radius."""
|
||||||
|
return ft.ButtonStyle(
|
||||||
|
color=Palette.Text,
|
||||||
|
bgcolor=Palette.Surface0,
|
||||||
|
side=ft.BorderSide(1, Accent or Palette.Mauve),
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=4),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ChipButton(Text: str, OnClick, Icon=None, Accent: str = "", Disabled: bool = False) -> ft.OutlinedButton:
|
||||||
|
return ft.OutlinedButton(content=Text, icon=Icon, style=ChipButtonStyle(Accent), on_click=OnClick, disabled=Disabled)
|
||||||
|
|
||||||
|
|
||||||
|
def Note(Text: str, Size: int = 12) -> ft.Text:
|
||||||
|
return ft.Text(Text, color=Palette.Overlay0, size=Size)
|
||||||
|
|
||||||
|
|
||||||
|
def Heading(Text: str) -> ft.Text:
|
||||||
|
return ft.Text(Text, color=Palette.Mauve, size=20)
|
||||||
|
|
||||||
|
|
||||||
|
def DisplayPath(PathText: str) -> str:
|
||||||
|
"""/home/sarah/Sorting -> ~/Sorting, for labels."""
|
||||||
|
HomeText = str(Home)
|
||||||
|
if PathText == HomeText or PathText.startswith(HomeText + "/"):
|
||||||
|
return "~" + PathText[len(HomeText):]
|
||||||
|
return PathText
|
||||||
+560
@@ -0,0 +1,560 @@
|
|||||||
|
"""The flowchart: every input (red), output (blue) and both (purple)
|
||||||
|
directory as a box, with an arrow per input -> output pair labelled with the
|
||||||
|
file patterns that travel along it.
|
||||||
|
|
||||||
|
Boxes start in columns (inputs left, both middle, outputs right) and can be
|
||||||
|
dragged; dragged positions are kept in Layout.toml. In edit mode, clicking two
|
||||||
|
boxes connects them, and clicking an arrow's label edits or removes it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
import flet.canvas as cv
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from Core import ChipButton, ChipButtonStyle, DisplayPath, Heading, LayoutPath, Note, Palette
|
||||||
|
from Sortd import DirNode, Edge, Setup
|
||||||
|
from Templates import SaveUserTemplate, SortTemplate
|
||||||
|
from Widgets import App, DirField, EmptyState, ExpandPath, KindColor, NameField
|
||||||
|
|
||||||
|
NodeWidth = 210
|
||||||
|
NodeHeight = 58
|
||||||
|
ColumnGap = 330
|
||||||
|
RowGap = 96
|
||||||
|
Margin = 40
|
||||||
|
LabelHeight = 24
|
||||||
|
CharWidth = 7.5 # Fixedsys at size 12
|
||||||
|
|
||||||
|
|
||||||
|
def LoadLayout() -> dict[str, tuple[float, float]]:
|
||||||
|
try:
|
||||||
|
Data = tomlkit.parse(LayoutPath.read_text()).unwrap()
|
||||||
|
except (OSError, tomlkit.exceptions.TOMLKitError):
|
||||||
|
return {}
|
||||||
|
return {Key: (float(Value[0]), float(Value[1])) for Key, Value in Data.get("Positions", {}).items()
|
||||||
|
if isinstance(Value, list) and len(Value) == 2}
|
||||||
|
|
||||||
|
|
||||||
|
def SaveLayout(Positions: dict[str, tuple[float, float]]) -> None:
|
||||||
|
LayoutPath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
LayoutPath.write_text(tomlkit.dumps({"Positions": {Key: [round(X), round(Y)] for Key, (X, Y) in Positions.items()}}))
|
||||||
|
|
||||||
|
|
||||||
|
def ClipToBox(CenterX: float, CenterY: float, TowardX: float, TowardY: float) -> tuple[float, float]:
|
||||||
|
"""Where the line from a box's centre toward a point leaves the box."""
|
||||||
|
DX, DY = TowardX - CenterX, TowardY - CenterY
|
||||||
|
if DX == 0 and DY == 0:
|
||||||
|
return CenterX, CenterY
|
||||||
|
Scale = min(
|
||||||
|
(NodeWidth / 2) / abs(DX) if DX else math.inf,
|
||||||
|
(NodeHeight / 2) / abs(DY) if DY else math.inf,
|
||||||
|
)
|
||||||
|
return CenterX + DX * Scale, CenterY + DY * Scale
|
||||||
|
|
||||||
|
|
||||||
|
class FlowPage(ft.Column):
|
||||||
|
def __init__(self, TheApp: App):
|
||||||
|
super().__init__(expand=True, spacing=0)
|
||||||
|
self.App = TheApp
|
||||||
|
self.EditMode = False
|
||||||
|
self.Selected: Optional[str] = None
|
||||||
|
self.Positions: dict[str, tuple[float, float]] = {}
|
||||||
|
self.Nodes: dict[str, DirNode] = {}
|
||||||
|
self.EdgeList: list[Edge] = []
|
||||||
|
self.TheSetup: Optional[Setup] = None
|
||||||
|
self.NodeControls: dict[str, ft.GestureDetector] = {}
|
||||||
|
self.DragStart: dict = {}
|
||||||
|
|
||||||
|
self.EditButton = ChipButton("Edit mode", self._ToggleEdit, ft.Icons.EDIT_OUTLINED)
|
||||||
|
self.AddButton = ChipButton("Add directory", self._AddDirectoryDialog, ft.Icons.CREATE_NEW_FOLDER_OUTLINED)
|
||||||
|
self.AddButton.visible = False
|
||||||
|
self.Hint = Note("")
|
||||||
|
self.Canvas = cv.Canvas(shapes=[])
|
||||||
|
self.Board = ft.Stack([], width=800, height=600)
|
||||||
|
self.Body = ft.Container(expand=True)
|
||||||
|
|
||||||
|
def Legend(Text: str, Color: str) -> ft.Row:
|
||||||
|
return ft.Row([
|
||||||
|
ft.Container(width=14, height=14, bgcolor=ft.Colors.with_opacity(0.25, Color), border=ft.Border.all(2, Color), border_radius=3),
|
||||||
|
ft.Text(Text, size=12),
|
||||||
|
], spacing=6)
|
||||||
|
|
||||||
|
self.controls = [
|
||||||
|
ft.Container(
|
||||||
|
ft.Row([
|
||||||
|
Heading("Flow"),
|
||||||
|
ft.Container(width=12),
|
||||||
|
Legend("Input", Palette.Red), Legend("Output", Palette.Blue), Legend("Both", Palette.Mauve),
|
||||||
|
ft.Container(expand=True),
|
||||||
|
self.AddButton,
|
||||||
|
ChipButton("Tidy layout", self._ResetLayout, ft.Icons.AUTO_FIX_HIGH_OUTLINED),
|
||||||
|
ChipButton("Reload", lambda e: self.Refresh(), ft.Icons.REFRESH),
|
||||||
|
self.EditButton,
|
||||||
|
], vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||||
|
padding=ft.Padding.only(left=16, right=16, top=12, bottom=4),
|
||||||
|
),
|
||||||
|
ft.Container(self.Hint, padding=ft.Padding.only(left=16, bottom=8)),
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
self.Body,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Loading and layout
|
||||||
|
|
||||||
|
def Refresh(self) -> None:
|
||||||
|
TheSetup, Error = self.App.LoadSetup()
|
||||||
|
self.TheSetup = TheSetup
|
||||||
|
if TheSetup is None:
|
||||||
|
self.Body.content = EmptyState("Can't read SaraSortd's config.", f"{Error}\nCheck the paths in Settings.")
|
||||||
|
self.update()
|
||||||
|
return
|
||||||
|
self.Nodes = TheSetup.Nodes()
|
||||||
|
self.EdgeList = TheSetup.Edges()
|
||||||
|
self._Layout()
|
||||||
|
self._BuildBoard()
|
||||||
|
self.Body.content = ft.Column(
|
||||||
|
[ft.Row([self.Board], scroll=ft.ScrollMode.AUTO)],
|
||||||
|
scroll=ft.ScrollMode.AUTO, expand=True,
|
||||||
|
) if self.Nodes else EmptyState("No directories yet.", "Turn on Edit mode and add one, or install a template.")
|
||||||
|
self._UpdateHint()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _AutoPositions(self) -> dict[str, tuple[float, float]]:
|
||||||
|
Columns: dict[str, list[str]] = {"Input": [], "Both": [], "Output": []}
|
||||||
|
for PathText, Node in self.Nodes.items():
|
||||||
|
Columns[Node.Kind].append(PathText)
|
||||||
|
Used = [Kind for Kind in ("Input", "Both", "Output") if Columns[Kind]]
|
||||||
|
Result = {}
|
||||||
|
for ColumnIndex, Kind in enumerate(Used):
|
||||||
|
for RowIndex, PathText in enumerate(Columns[Kind]):
|
||||||
|
Result[PathText] = (Margin + ColumnIndex * ColumnGap, Margin + 30 + RowIndex * RowGap)
|
||||||
|
return Result
|
||||||
|
|
||||||
|
def _Layout(self) -> None:
|
||||||
|
Auto = self._AutoPositions()
|
||||||
|
Saved = LoadLayout()
|
||||||
|
self.Positions = {PathText: Saved.get(PathText, Auto[PathText]) for PathText in self.Nodes}
|
||||||
|
|
||||||
|
def _ResetLayout(self, e) -> None:
|
||||||
|
self.Positions = self._AutoPositions()
|
||||||
|
SaveLayout({})
|
||||||
|
self._BuildBoard()
|
||||||
|
self.Board.update()
|
||||||
|
|
||||||
|
def _BoardSize(self) -> tuple[float, float]:
|
||||||
|
if not self.Positions:
|
||||||
|
return 800, 600
|
||||||
|
Width = max(X for X, Y in self.Positions.values()) + NodeWidth + Margin + 80
|
||||||
|
Height = max(Y for X, Y in self.Positions.values()) + NodeHeight + Margin + 40
|
||||||
|
return max(Width, 800), max(Height, 500)
|
||||||
|
|
||||||
|
# Drawing
|
||||||
|
|
||||||
|
def _BuildBoard(self) -> None:
|
||||||
|
self.NodeControls = {PathText: self._NodeControl(Node) for PathText, Node in self.Nodes.items()}
|
||||||
|
self._Redraw(Update=False)
|
||||||
|
|
||||||
|
def _Redraw(self, Update: bool = True) -> None:
|
||||||
|
Shapes, Labels = self._EdgeDrawing()
|
||||||
|
self.Canvas.shapes = Shapes
|
||||||
|
Width, Height = self._BoardSize()
|
||||||
|
self.Canvas.width, self.Canvas.height = Width, Height
|
||||||
|
self.Board.width, self.Board.height = Width, Height
|
||||||
|
for PathText, Control in self.NodeControls.items():
|
||||||
|
Control.left, Control.top = self.Positions[PathText]
|
||||||
|
self.Board.controls = [self.Canvas] + Labels + list(self.NodeControls.values())
|
||||||
|
if Update:
|
||||||
|
self.Board.update()
|
||||||
|
|
||||||
|
def _NodeControl(self, Node: DirNode) -> ft.GestureDetector:
|
||||||
|
Color = KindColor(Node.Kind)
|
||||||
|
Selected = Node.Path == self.Selected
|
||||||
|
Name = os.path.basename(Node.Path) or Node.Path
|
||||||
|
Exists = os.path.isdir(Node.Path)
|
||||||
|
Box = ft.Container(
|
||||||
|
ft.Column([
|
||||||
|
ft.Row([
|
||||||
|
ft.Icon(ft.Icons.FOLDER_OUTLINED if Exists else ft.Icons.FOLDER_OFF_OUTLINED, color=Color, size=16),
|
||||||
|
ft.Text(Name, size=15, color=Palette.Text, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS, expand=True),
|
||||||
|
], spacing=6),
|
||||||
|
ft.Text(DisplayPath(os.path.dirname(Node.Path)), size=11, color=Palette.Overlay0, no_wrap=True,
|
||||||
|
overflow=ft.TextOverflow.ELLIPSIS),
|
||||||
|
], spacing=2, alignment=ft.MainAxisAlignment.CENTER),
|
||||||
|
width=NodeWidth, height=NodeHeight,
|
||||||
|
padding=ft.Padding.symmetric(horizontal=10, vertical=6),
|
||||||
|
bgcolor=ft.Colors.with_opacity(0.18, Color) if not Selected else ft.Colors.with_opacity(0.4, Color),
|
||||||
|
border=ft.Border.all(3 if Selected else 2, Palette.Yellow if Selected else Color),
|
||||||
|
border_radius=6,
|
||||||
|
tooltip=f"{Node.Path}\n{Node.Kind}" + ("" if Exists else "\n(doesn't exist yet)"),
|
||||||
|
)
|
||||||
|
return ft.GestureDetector(
|
||||||
|
content=Box,
|
||||||
|
mouse_cursor=ft.MouseCursor.CLICK,
|
||||||
|
drag_interval=16,
|
||||||
|
on_tap=lambda e, PathText=Node.Path: self._NodeClicked(PathText),
|
||||||
|
on_pan_start=lambda e, PathText=Node.Path: self._DragStart(PathText, e),
|
||||||
|
on_pan_update=lambda e, PathText=Node.Path: self._DragUpdate(PathText, e),
|
||||||
|
on_pan_end=lambda e: SaveLayout(self.Positions),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _DragStart(self, PathText: str, e) -> None:
|
||||||
|
self.DragStart = {"Pointer": (e.global_position.x, e.global_position.y), "Box": self.Positions[PathText]}
|
||||||
|
|
||||||
|
def _DragUpdate(self, PathText: str, e) -> None:
|
||||||
|
if not self.DragStart:
|
||||||
|
return
|
||||||
|
StartX, StartY = self.DragStart["Pointer"]
|
||||||
|
BoxX, BoxY = self.DragStart["Box"]
|
||||||
|
self.Positions[PathText] = (
|
||||||
|
max(0, BoxX + e.global_position.x - StartX),
|
||||||
|
max(0, BoxY + e.global_position.y - StartY),
|
||||||
|
)
|
||||||
|
self._Redraw()
|
||||||
|
|
||||||
|
def _EdgeDrawing(self) -> tuple[list[cv.Shape], list[ft.Control]]:
|
||||||
|
Shapes: list[cv.Shape] = []
|
||||||
|
Labels: list[ft.Control] = []
|
||||||
|
Segments: list = []
|
||||||
|
Pairs = {(FoundEdge.Input, FoundEdge.Output) for FoundEdge in self.EdgeList}
|
||||||
|
for FoundEdge in self.EdgeList:
|
||||||
|
if FoundEdge.Input not in self.Positions or FoundEdge.Output not in self.Positions:
|
||||||
|
continue
|
||||||
|
Color = Palette.Overlay0
|
||||||
|
Paint = ft.Paint(color=Color, stroke_width=2, style=ft.PaintingStyle.STROKE)
|
||||||
|
AX, AY = self.Positions[FoundEdge.Input]
|
||||||
|
BX, BY = self.Positions[FoundEdge.Output]
|
||||||
|
if FoundEdge.Input == FoundEdge.Output:
|
||||||
|
# Sorting into itself: a loop over the top of the box.
|
||||||
|
Left, Top = AX + NodeWidth * 0.3, AY
|
||||||
|
Right = AX + NodeWidth * 0.7
|
||||||
|
Shapes.append(cv.Path([
|
||||||
|
cv.Path.MoveTo(Left, Top),
|
||||||
|
cv.Path.CubicTo(Left - 10, Top - 50, Right + 10, Top - 50, Right, Top),
|
||||||
|
], paint=Paint))
|
||||||
|
Shapes.extend(self._ArrowHead(Right + 6, Top - 14, Right, Top, Color))
|
||||||
|
Labels.append(self._Label(FoundEdge, AX + NodeWidth / 2, Top - 46))
|
||||||
|
continue
|
||||||
|
ACX, ACY = AX + NodeWidth / 2, AY + NodeHeight / 2
|
||||||
|
BCX, BCY = BX + NodeWidth / 2, BY + NodeHeight / 2
|
||||||
|
# Two arrows between the same boxes (both ways) are pushed apart.
|
||||||
|
OffsetX = OffsetY = 0.0
|
||||||
|
if (FoundEdge.Output, FoundEdge.Input) in Pairs:
|
||||||
|
Length = math.hypot(BCX - ACX, BCY - ACY) or 1
|
||||||
|
OffsetX, OffsetY = -(BCY - ACY) / Length * 12, (BCX - ACX) / Length * 12
|
||||||
|
StartX, StartY = ClipToBox(ACX, ACY, BCX, BCY)
|
||||||
|
EndX, EndY = ClipToBox(BCX, BCY, ACX, ACY)
|
||||||
|
StartX, StartY, EndX, EndY = StartX + OffsetX, StartY + OffsetY, EndX + OffsetX, EndY + OffsetY
|
||||||
|
Shapes.append(cv.Line(StartX, StartY, EndX, EndY, paint=Paint))
|
||||||
|
Shapes.extend(self._ArrowHead(StartX, StartY, EndX, EndY, Color))
|
||||||
|
Segments.append((FoundEdge, StartX, StartY, EndX, EndY))
|
||||||
|
Labels.extend(self._PlaceLabels(Segments))
|
||||||
|
return Shapes, Labels
|
||||||
|
|
||||||
|
def _LabelText(self, FoundEdge: Edge) -> tuple[str, str, float]:
|
||||||
|
Full = ", ".join(FoundEdge.Patterns)
|
||||||
|
Limit = max(6, self.App.Config.LabelLength)
|
||||||
|
Short = Full if len(Full) <= Limit else Full[: Limit - 1] + "…"
|
||||||
|
return Short, Full, len(Short) * CharWidth + 18
|
||||||
|
|
||||||
|
def _PlaceLabels(self, Segments: list) -> list[ft.Control]:
|
||||||
|
"""Each label goes on its own arrow, slid along it until it's clear of
|
||||||
|
boxes, other labels and other arrows (or as clear as it gets)."""
|
||||||
|
Boxes = [(X, Y, X + NodeWidth, Y + NodeHeight) for X, Y in self.Positions.values()]
|
||||||
|
Placed: list[tuple[float, float, float, float]] = []
|
||||||
|
Labels = []
|
||||||
|
|
||||||
|
def Overlaps(A, B) -> bool:
|
||||||
|
return A[0] < B[2] and B[0] < A[2] and A[1] < B[3] and B[1] < A[3]
|
||||||
|
|
||||||
|
def Crossings(Rect, Own) -> int:
|
||||||
|
Count = 0
|
||||||
|
for Other in Segments:
|
||||||
|
if Other is Own:
|
||||||
|
continue
|
||||||
|
_, SX, SY, EX, EY = Other
|
||||||
|
if any(Rect[0] <= SX + (EX - SX) * Step / 24 <= Rect[2] and Rect[1] <= SY + (EY - SY) * Step / 24 <= Rect[3]
|
||||||
|
for Step in range(25)):
|
||||||
|
Count += 1
|
||||||
|
return Count
|
||||||
|
|
||||||
|
for Segment in Segments:
|
||||||
|
FoundEdge, SX, SY, EX, EY = Segment
|
||||||
|
_, _, Width = self._LabelText(FoundEdge)
|
||||||
|
Best = None
|
||||||
|
for Fraction in (0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75):
|
||||||
|
CX, CY = SX + (EX - SX) * Fraction, SY + (EY - SY) * Fraction
|
||||||
|
Rect = (CX - Width / 2 - 4, CY - LabelHeight / 2 - 2, CX + Width / 2 + 4, CY + LabelHeight / 2 + 2)
|
||||||
|
Score = 10 * sum(Overlaps(Rect, Other) for Other in Placed + Boxes) + Crossings(Rect, Segment)
|
||||||
|
if Best is None or Score < Best[0]:
|
||||||
|
Best = (Score, CX, CY, Rect)
|
||||||
|
if Score == 0:
|
||||||
|
break
|
||||||
|
Placed.append(Best[3])
|
||||||
|
Labels.append(self._Label(FoundEdge, Best[1], Best[2]))
|
||||||
|
return Labels
|
||||||
|
|
||||||
|
def _ArrowHead(self, FromX: float, FromY: float, ToX: float, ToY: float, Color: str) -> list[cv.Shape]:
|
||||||
|
Angle = math.atan2(ToY - FromY, ToX - FromX)
|
||||||
|
Size = 11
|
||||||
|
Points = [
|
||||||
|
(ToX - Size * math.cos(Angle - 0.45), ToY - Size * math.sin(Angle - 0.45)),
|
||||||
|
(ToX - Size * math.cos(Angle + 0.45), ToY - Size * math.sin(Angle + 0.45)),
|
||||||
|
]
|
||||||
|
return [cv.Path([
|
||||||
|
cv.Path.MoveTo(ToX, ToY), cv.Path.LineTo(*Points[0]), cv.Path.LineTo(*Points[1]), cv.Path.Close(),
|
||||||
|
], paint=ft.Paint(color=Color, style=ft.PaintingStyle.FILL))]
|
||||||
|
|
||||||
|
def _Label(self, FoundEdge: Edge, CenterX: float, CenterY: float) -> ft.Container:
|
||||||
|
Short, Full, Width = self._LabelText(FoundEdge)
|
||||||
|
return ft.Container(
|
||||||
|
ft.Text(Short, size=12, color=Palette.Text, no_wrap=True),
|
||||||
|
left=CenterX - Width / 2, top=CenterY - LabelHeight / 2, width=Width, height=LabelHeight,
|
||||||
|
alignment=ft.Alignment.CENTER,
|
||||||
|
bgcolor=Palette.Mantle, border=ft.Border.all(1, Palette.Mauve if self.EditMode else Palette.Surface1),
|
||||||
|
border_radius=4, tooltip=Full if Short != Full else None,
|
||||||
|
on_click=lambda e: self._EdgeDialog(FoundEdge),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Interaction
|
||||||
|
|
||||||
|
def _UpdateHint(self) -> None:
|
||||||
|
if not self.EditMode:
|
||||||
|
self.Hint.value = "Click a box for details, an arrow's label for all of its patterns. Drag boxes to move them."
|
||||||
|
elif self.Selected:
|
||||||
|
self.Hint.value = f"Now click the directory {os.path.basename(self.Selected)} should sort into (or it again to cancel)."
|
||||||
|
else:
|
||||||
|
self.Hint.value = "Edit mode: click a directory, then the one it should sort into. Click a label to change or remove an arrow."
|
||||||
|
self.Hint.color = Palette.Yellow if self.EditMode else Palette.Overlay0
|
||||||
|
|
||||||
|
def _ToggleEdit(self, e) -> None:
|
||||||
|
self.EditMode = not self.EditMode
|
||||||
|
self.Selected = None
|
||||||
|
self.EditButton.content = "Done" if self.EditMode else "Edit mode"
|
||||||
|
self.EditButton.style = ChipButtonStyle(Palette.Yellow if self.EditMode else "")
|
||||||
|
self.AddButton.visible = self.EditMode
|
||||||
|
self._UpdateHint()
|
||||||
|
if self.TheSetup:
|
||||||
|
self._BuildBoard()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _NodeClicked(self, PathText: str) -> None:
|
||||||
|
if not self.EditMode:
|
||||||
|
self._NodeDialog(PathText)
|
||||||
|
return
|
||||||
|
if self.Selected is None:
|
||||||
|
self.Selected = PathText
|
||||||
|
elif self.Selected == PathText:
|
||||||
|
self.Selected = None
|
||||||
|
else:
|
||||||
|
Source, self.Selected = self.Selected, None
|
||||||
|
self._ConnectDialog(Source, PathText)
|
||||||
|
self._UpdateHint()
|
||||||
|
self._BuildBoard()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _AfterChange(self, GlobalChanged: bool) -> None:
|
||||||
|
if GlobalChanged:
|
||||||
|
self.App.AfterGlobalChange()
|
||||||
|
self.Refresh()
|
||||||
|
|
||||||
|
def _ConnectDialog(self, InputPath: str, OutputPath: str, Preset: Optional[list[str]] = None) -> None:
|
||||||
|
TheSetup = self.TheSetup
|
||||||
|
Defaults = TheSetup.TemplateFileDefaults()
|
||||||
|
PatternField = ft.TextField(
|
||||||
|
label="File types (patterns), separated by commas", value=", ".join(Preset or []),
|
||||||
|
hint_text="*.png, *.jp*g", autofocus=True, dense=True,
|
||||||
|
)
|
||||||
|
Name = NameField(TheSetup, str(Defaults.get("NewFileName", "~^")), OutputPath, Defaults)
|
||||||
|
Move = ft.Switch(label="Move files (off: copy them)", value=True)
|
||||||
|
OutConf = TheSetup.LoadDirConf(OutputPath, "Out") or {}
|
||||||
|
Move.value = OutConf.get("DeleteOrg", 1) == 1
|
||||||
|
|
||||||
|
def Save(e) -> None:
|
||||||
|
Patterns = [Part.strip() for Part in (PatternField.value or "").split(",") if Part.strip()]
|
||||||
|
if not Patterns:
|
||||||
|
PatternField.error = "Add at least one pattern, e.g. *.png"
|
||||||
|
PatternField.update()
|
||||||
|
return
|
||||||
|
self.App.Close()
|
||||||
|
Overrides = {}
|
||||||
|
if Name.value and Name.value != Defaults.get("NewFileName"):
|
||||||
|
Overrides["NewFileName"] = Name.value
|
||||||
|
try:
|
||||||
|
Changed = TheSetup.Connect(InputPath, OutputPath, Patterns, Overrides, {"DeleteOrg": 1 if Move.value else 0})
|
||||||
|
except Exception as Error:
|
||||||
|
self.App.Notify(f"Couldn't connect: {Error}", "error")
|
||||||
|
return
|
||||||
|
self._AfterChange(Changed)
|
||||||
|
|
||||||
|
self.App.ShowDialog(
|
||||||
|
"Connect",
|
||||||
|
ft.Column([
|
||||||
|
ft.Text(f"{DisplayPath(InputPath)} -> {DisplayPath(OutputPath)}", color=Palette.Mauve, selectable=True),
|
||||||
|
PatternField,
|
||||||
|
Note("* is any text, ? one character. Each pattern becomes its own [[Files]] entry; the other "
|
||||||
|
"settings (NextNum, Overwrite...) come from OutputDirConf.toml."),
|
||||||
|
Name,
|
||||||
|
Move,
|
||||||
|
], tight=True, spacing=10),
|
||||||
|
[ChipButton("Cancel", lambda e: self.App.Close()), ChipButton("Connect", Save)],
|
||||||
|
Width=620,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _EdgeDialog(self, FoundEdge: Edge) -> None:
|
||||||
|
TheSetup = self.TheSetup
|
||||||
|
Rows = []
|
||||||
|
for Rule in FoundEdge.Rules:
|
||||||
|
Scope = "only from this input" if Rule.OwnInputs else "from every input this output accepts"
|
||||||
|
Row = [
|
||||||
|
ft.Text(Rule.Pattern, color=Palette.Mauve, width=150, selectable=True),
|
||||||
|
ft.Column([
|
||||||
|
ft.Text(f"named {Rule.NewFileName or '~^'}", size=12),
|
||||||
|
Note(Scope, 11),
|
||||||
|
], spacing=0, expand=True),
|
||||||
|
]
|
||||||
|
if self.EditMode:
|
||||||
|
Row.append(ft.IconButton(
|
||||||
|
ft.Icons.DELETE_OUTLINE, icon_color=Palette.Red, tooltip="Stop sending this pattern here",
|
||||||
|
on_click=lambda e, Index=Rule.Index: RemoveOne(Index),
|
||||||
|
))
|
||||||
|
Rows.append(ft.Row(Row))
|
||||||
|
|
||||||
|
def RemoveOne(Index: int) -> None:
|
||||||
|
self.App.Close()
|
||||||
|
Message = TheSetup.RemoveRule(FoundEdge.Input, FoundEdge.Output, Index)
|
||||||
|
if Message:
|
||||||
|
self.App.Notify(Message, "warning")
|
||||||
|
self.Refresh()
|
||||||
|
|
||||||
|
def RemoveAll() -> None:
|
||||||
|
Messages = TheSetup.Disconnect(FoundEdge.Input, FoundEdge.Output)
|
||||||
|
if Messages:
|
||||||
|
self.App.Notify(" ".join(Messages), "warning")
|
||||||
|
self.Refresh()
|
||||||
|
|
||||||
|
def AddMore(e) -> None:
|
||||||
|
self.App.Close()
|
||||||
|
self._ConnectDialog(FoundEdge.Input, FoundEdge.Output)
|
||||||
|
|
||||||
|
def SaveTemplate(e) -> None:
|
||||||
|
self.App.Close()
|
||||||
|
self._SaveTemplateDialog(FoundEdge)
|
||||||
|
|
||||||
|
Actions = [ChipButton("Save as template", SaveTemplate, ft.Icons.BOOKMARK_ADD_OUTLINED)]
|
||||||
|
if self.EditMode:
|
||||||
|
Actions += [
|
||||||
|
ChipButton("Add patterns", AddMore, ft.Icons.ADD),
|
||||||
|
ChipButton("Remove arrow", lambda e: (self.App.Close(), self.App.Confirm(
|
||||||
|
"Remove arrow", f"Stop sorting from {DisplayPath(FoundEdge.Input)} into {DisplayPath(FoundEdge.Output)}?", RemoveAll,
|
||||||
|
)), ft.Icons.LINK_OFF, Accent=Palette.Red),
|
||||||
|
]
|
||||||
|
Actions.append(ChipButton("Close", lambda e: self.App.Close()))
|
||||||
|
self.App.ShowDialog(
|
||||||
|
"Arrow",
|
||||||
|
ft.Column([
|
||||||
|
ft.Text(f"{DisplayPath(FoundEdge.Input)} -> {DisplayPath(FoundEdge.Output)}", color=Palette.Mauve, selectable=True),
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
ft.Column(Rows, scroll=ft.ScrollMode.AUTO, height=min(360, 48 * len(Rows) + 8), spacing=4),
|
||||||
|
Note("SaraSortd tries outputs in OutputDir order and patterns top to bottom; the first match wins."),
|
||||||
|
], tight=True, spacing=8),
|
||||||
|
Actions, Width=620,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _SaveTemplateDialog(self, FoundEdge: Edge) -> None:
|
||||||
|
NameInput = ft.TextField(label="Template name", value=os.path.basename(FoundEdge.Output), autofocus=True, dense=True)
|
||||||
|
Description = ft.TextField(label="Description", dense=True)
|
||||||
|
NewNames = {Rule.NewFileName for Rule in FoundEdge.Rules}
|
||||||
|
|
||||||
|
def Save(e) -> None:
|
||||||
|
if not (NameInput.value or "").strip():
|
||||||
|
return
|
||||||
|
self.App.Close()
|
||||||
|
OutConf = self.TheSetup.LoadDirConf(FoundEdge.Output, "Out") or {}
|
||||||
|
Settings = {Key: OutConf[Key] for Key in ("DeleteOrg", "Unzip") if Key in OutConf}
|
||||||
|
Saved = SaveUserTemplate(SortTemplate(
|
||||||
|
NameInput.value.strip(), Description.value or "", Patterns=FoundEdge.Patterns,
|
||||||
|
NewFileName=NewNames.pop() if len(NewNames) == 1 else "",
|
||||||
|
SuggestedOutput=DisplayPath(FoundEdge.Output), OutputSettings=Settings,
|
||||||
|
))
|
||||||
|
self.App.Notify(f"Saved {Saved.name} in Templates.", "success")
|
||||||
|
|
||||||
|
self.App.ShowDialog("Save as template", ft.Column([NameInput, Description], tight=True),
|
||||||
|
[ChipButton("Cancel", lambda e: self.App.Close()), ChipButton("Save", Save)], Width=440)
|
||||||
|
|
||||||
|
def _NodeDialog(self, PathText: str) -> None:
|
||||||
|
Node = self.Nodes[PathText]
|
||||||
|
TheSetup = self.TheSetup
|
||||||
|
Incoming = [FoundEdge for FoundEdge in self.EdgeList if FoundEdge.Output == PathText]
|
||||||
|
Outgoing = [FoundEdge for FoundEdge in self.EdgeList if FoundEdge.Input == PathText]
|
||||||
|
|
||||||
|
def Lines(Edges: list[Edge], Other: str) -> list[ft.Control]:
|
||||||
|
return [ft.Text(f"{os.path.basename(getattr(FoundEdge, Other))}: {', '.join(FoundEdge.Patterns)}", size=12, selectable=True)
|
||||||
|
for FoundEdge in Edges] or [Note("none")]
|
||||||
|
|
||||||
|
Kinds = (["In"] if Node.IsInput else []) + (["Out"] if Node.IsOutput else [])
|
||||||
|
ConfButtons = []
|
||||||
|
for Kind in dict.fromkeys(Kinds):
|
||||||
|
ConfPath = TheSetup.ConfFile(PathText, Kind)
|
||||||
|
if ConfPath.exists():
|
||||||
|
ConfButtons.append(ChipButton(
|
||||||
|
f"Edit {'input' if Kind == 'In' else 'output'} config" if len(set(TheSetup.ConfFile(PathText, K) for K in Kinds)) > 1 else "Edit config",
|
||||||
|
lambda e, ConfPath=ConfPath: (self.App.Close(), self.App.Navigate("Configs", Open=str(ConfPath))),
|
||||||
|
ft.Icons.TUNE,
|
||||||
|
))
|
||||||
|
if len(set(TheSetup.ConfFile(PathText, K) for K in Kinds)) == 1:
|
||||||
|
break
|
||||||
|
|
||||||
|
def Remove() -> None:
|
||||||
|
if TheSetup.RemoveDir(PathText):
|
||||||
|
Positions = LoadLayout()
|
||||||
|
Positions.pop(PathText, None)
|
||||||
|
SaveLayout(Positions)
|
||||||
|
self._AfterChange(True)
|
||||||
|
|
||||||
|
Actions = ConfButtons + [ChipButton("Open folder", lambda e: self.App.OpenFolder(PathText), ft.Icons.FOLDER_OPEN_OUTLINED)]
|
||||||
|
Actions.append(ChipButton("Remove", lambda e: (self.App.Close(), self.App.Confirm(
|
||||||
|
"Remove directory",
|
||||||
|
f"Take {DisplayPath(PathText)} out of SaraSortd? The folder, its files and its config stay on disk.", Remove,
|
||||||
|
)), ft.Icons.DELETE_OUTLINE, Accent=Palette.Red))
|
||||||
|
Actions.append(ChipButton("Close", lambda e: self.App.Close()))
|
||||||
|
self.App.ShowDialog(
|
||||||
|
os.path.basename(PathText) or PathText,
|
||||||
|
ft.Column([
|
||||||
|
ft.Text(PathText, selectable=True, color=KindColor(Node.Kind)),
|
||||||
|
Note(f"{Node.Kind}" + ("" if os.path.isdir(PathText) else " - doesn't exist yet")),
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
ft.Text("Receives", color=Palette.Mauve), *Lines(Incoming, "Input"),
|
||||||
|
ft.Text("Sends", color=Palette.Mauve), *Lines(Outgoing, "Output"),
|
||||||
|
], tight=True, spacing=6, scroll=ft.ScrollMode.AUTO),
|
||||||
|
Actions, Width=620,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _AddDirectoryDialog(self, e) -> None:
|
||||||
|
if not self.TheSetup:
|
||||||
|
return
|
||||||
|
Row, Field = DirField(self.App, "Directory")
|
||||||
|
Kind = ft.RadioGroup(ft.Row([
|
||||||
|
ft.Radio(value="InputDir", label="Input"), ft.Radio(value="OutputDir", label="Output"),
|
||||||
|
ft.Radio(value="Both", label="Both"),
|
||||||
|
]), value="InputDir")
|
||||||
|
|
||||||
|
def Add(e) -> None:
|
||||||
|
PathText = ExpandPath(Field.value or "")
|
||||||
|
if not PathText:
|
||||||
|
return
|
||||||
|
self.App.Close()
|
||||||
|
Keys = ["InputDir", "OutputDir"] if Kind.value == "Both" else [Kind.value]
|
||||||
|
try:
|
||||||
|
Changed = self.TheSetup.AddDirectory(PathText, Keys)
|
||||||
|
except OSError as Error:
|
||||||
|
self.App.Notify(f"Couldn't add it: {Error}", "error")
|
||||||
|
return
|
||||||
|
self._AfterChange(Changed)
|
||||||
|
|
||||||
|
self.App.ShowDialog("Add directory", ft.Column([
|
||||||
|
Row, Kind,
|
||||||
|
Note("Its config is made from InputDirConf.toml / OutputDirConf.toml, like SaraSortd does. "
|
||||||
|
"A new output takes every file (the template's \"*\") until you change it."),
|
||||||
|
], tight=True, spacing=10), [ChipButton("Cancel", lambda e: self.App.Close()), ChipButton("Add", Add)], Width=560)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""SaraSort: a GUI for configuring SaraSortd. It doesn't sort anything itself;
|
||||||
|
it reads and edits SaraSortd's configs and controls its systemd service."""
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from ConfigPage import ConfigPage
|
||||||
|
from Core import (
|
||||||
|
AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, EffectiveSettings, LoadConfig, Palette,
|
||||||
|
)
|
||||||
|
from FlowPage import FlowPage
|
||||||
|
from ServicePage import ServicePage
|
||||||
|
from SettingsPage import SettingsPage
|
||||||
|
from TemplatesPage import TemplatesPage
|
||||||
|
from ToolsPage import ToolsPage
|
||||||
|
from Widgets import App
|
||||||
|
|
||||||
|
Destinations = [
|
||||||
|
("Flow", ft.Icons.SCHEMA_OUTLINED, ft.Icons.SCHEMA),
|
||||||
|
("Service", ft.Icons.MONITOR_HEART_OUTLINED, ft.Icons.MONITOR_HEART),
|
||||||
|
("Configs", ft.Icons.TUNE_OUTLINED, ft.Icons.TUNE),
|
||||||
|
("Templates", ft.Icons.DASHBOARD_CUSTOMIZE_OUTLINED, ft.Icons.DASHBOARD_CUSTOMIZE),
|
||||||
|
("Tools", ft.Icons.BUILD_OUTLINED, ft.Icons.BUILD),
|
||||||
|
("Settings", ft.Icons.SETTINGS_OUTLINED, ft.Icons.SETTINGS),
|
||||||
|
]
|
||||||
|
Names = [Name for Name, _, _ in Destinations]
|
||||||
|
|
||||||
|
|
||||||
|
def Main(Page: ft.Page) -> None:
|
||||||
|
Config = LoadConfig()
|
||||||
|
Appearance = EffectiveSettings(Config)
|
||||||
|
ApplyAppearance(Appearance) # before anything reads Palette.X
|
||||||
|
|
||||||
|
Page.title = AppName
|
||||||
|
Page.theme_mode = ft.ThemeMode.DARK
|
||||||
|
Page.theme = BuildTheme()
|
||||||
|
Page.dark_theme = BuildTheme()
|
||||||
|
Page.fonts = {BundledFontFamily: "fonts/Fixedsys_Excelsior_Regular.ttf"}
|
||||||
|
Page.window.title_bar_hidden = Appearance.HideWindowTitleBar
|
||||||
|
Page.padding = 0
|
||||||
|
Page.bgcolor = Palette.Base
|
||||||
|
Page.window.width = 1180
|
||||||
|
Page.window.height = 780
|
||||||
|
Page.window.min_width = 760
|
||||||
|
Page.window.min_height = 520
|
||||||
|
|
||||||
|
TheApp = App(Page, Config)
|
||||||
|
Body = ft.Container(expand=True)
|
||||||
|
Pages: dict[str, ft.Control] = {}
|
||||||
|
State = {"Current": ""}
|
||||||
|
|
||||||
|
def Navigate(Name: str, **Arguments) -> None:
|
||||||
|
if Name not in Pages:
|
||||||
|
return
|
||||||
|
Previous = Pages.get(State["Current"])
|
||||||
|
if isinstance(Previous, ServicePage):
|
||||||
|
Previous.StopPolling()
|
||||||
|
State["Current"] = Name
|
||||||
|
Rail.selected_index = Names.index(Name)
|
||||||
|
Body.content = Pages[Name]
|
||||||
|
Page.update()
|
||||||
|
Shown = Pages[Name]
|
||||||
|
Shown.Refresh(**Arguments)
|
||||||
|
if isinstance(Shown, ServicePage):
|
||||||
|
Shown.StartPolling()
|
||||||
|
|
||||||
|
TheApp.Navigate = Navigate
|
||||||
|
Pages.update({
|
||||||
|
"Flow": FlowPage(TheApp),
|
||||||
|
"Service": ServicePage(TheApp),
|
||||||
|
"Configs": ConfigPage(TheApp),
|
||||||
|
"Templates": TemplatesPage(TheApp),
|
||||||
|
"Tools": ToolsPage(TheApp),
|
||||||
|
"Settings": SettingsPage(TheApp, OnSaved=lambda: None),
|
||||||
|
})
|
||||||
|
|
||||||
|
Rail = ft.NavigationRail(
|
||||||
|
selected_index=0,
|
||||||
|
label_type=ft.NavigationRailLabelType.ALL,
|
||||||
|
bgcolor=Palette.Mantle,
|
||||||
|
indicator_color=Palette.Surface0,
|
||||||
|
min_width=88,
|
||||||
|
destinations=[ft.NavigationRailDestination(icon=Icon, selected_icon=Selected, label=Name)
|
||||||
|
for Name, Icon, Selected in Destinations],
|
||||||
|
on_change=lambda e: Navigate(Names[e.control.selected_index]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def CloseWindow(e) -> None:
|
||||||
|
await Page.window.close()
|
||||||
|
|
||||||
|
TitleRow = [ft.WindowDragArea(
|
||||||
|
ft.Container(ft.Text(AppName, size=18, color=Palette.Mauve), padding=ft.Padding.only(left=16, top=6, bottom=6)),
|
||||||
|
expand=True,
|
||||||
|
)]
|
||||||
|
if Appearance.HideWindowTitleBar:
|
||||||
|
TitleRow += [
|
||||||
|
ft.IconButton(ft.Icons.MINIMIZE, icon_size=16, tooltip="Minimize",
|
||||||
|
on_click=lambda e: (setattr(Page.window, "minimized", True), Page.update())),
|
||||||
|
ft.IconButton(ft.Icons.CLOSE, icon_size=16, tooltip="Close", on_click=CloseWindow),
|
||||||
|
]
|
||||||
|
|
||||||
|
Page.add(ft.Column([
|
||||||
|
ft.Container(ft.Row(TitleRow, spacing=0), bgcolor=Palette.Mantle,
|
||||||
|
border=ft.Border.only(bottom=ft.BorderSide(1, Palette.Surface1))),
|
||||||
|
ft.Row([Rail, ft.VerticalDivider(width=1, color=Palette.Surface1), Body], expand=True, spacing=0,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.STRETCH),
|
||||||
|
], expand=True, spacing=0))
|
||||||
|
Navigate(Config.StartPage if Config.StartPage in Pages else "Flow")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ft.run(Main, assets_dir=str(AssetsDir))
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Builds dist/SaraSort-x86_64.AppImage: a PyInstaller one-file build (with
|
||||||
|
# the font and icon bundled) wrapped by appimagetool, the same way SaraSuite's
|
||||||
|
# and SaraFem's AppImages are made. Needs appimagetool on PATH.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$(readlink -f "$0")")/.."
|
||||||
|
|
||||||
|
[ -d .venv ] || python3 -m venv .venv
|
||||||
|
.venv/bin/pip install --quiet --disable-pip-version-check -r requirements.txt pyinstaller
|
||||||
|
|
||||||
|
# --collect-data flet: without it ft.Icons crashes when frozen.
|
||||||
|
.venv/bin/pyinstaller --noconfirm --clean --log-level WARN \
|
||||||
|
--onefile --windowed --name SaraSort --collect-data flet \
|
||||||
|
--add-data "$PWD/assets:assets" \
|
||||||
|
--distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \
|
||||||
|
"$PWD/Main.py"
|
||||||
|
|
||||||
|
AppDir=build/AppDir
|
||||||
|
rm -rf "$AppDir"
|
||||||
|
mkdir -p "$AppDir/usr/bin"
|
||||||
|
cp build/PyInstaller/dist/SaraSort "$AppDir/usr/bin/SaraSort"
|
||||||
|
ln -s usr/bin/SaraSort "$AppDir/AppRun"
|
||||||
|
cp assets/Icon.png "$AppDir/SaraSort.png"
|
||||||
|
ln -s SaraSort.png "$AppDir/.DirIcon"
|
||||||
|
cat > "$AppDir/SaraSort.desktop" << DESKTOP
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=SaraSort
|
||||||
|
Comment=Configure SaraSortd, the file sorting daemon
|
||||||
|
Exec=SaraSort
|
||||||
|
Icon=SaraSort
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;
|
||||||
|
DESKTOP
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
ARCH=x86_64 appimagetool --no-appstream "$AppDir" dist/SaraSort-x86_64.AppImage
|
||||||
|
echo "Built dist/SaraSort-x86_64.AppImage"
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Desktop-entry launcher: resolves the venv relative to this script rather
|
||||||
|
# than the caller's cwd, since .desktop Exec= lines are invoked with no
|
||||||
|
# guaranteed working directory.
|
||||||
|
cd "$(dirname "$(readlink -f "$0")")/.." || exit 1
|
||||||
|
exec .venv/bin/python Main.py "$@"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Ready for SaraSuite: copy this folder to SaraSuite/Programs/SaraSort/
|
||||||
|
# (Manifest.toml + Templates/). Same shape as SaraFem's manifest.
|
||||||
|
Name = "SaraSort"
|
||||||
|
Description = "A GUI for configuring SaraSortd: a flowchart of what goes where, its service and logs, configs and templates."
|
||||||
|
|
||||||
|
[Source]
|
||||||
|
Git = "{GitHost}/SaraSort"
|
||||||
|
Include = ["*.py", "requirements.txt", "assets/**"]
|
||||||
|
Exclude = ["Packaging"]
|
||||||
|
|
||||||
|
[Python]
|
||||||
|
RequirementsFile = "requirements.txt"
|
||||||
|
|
||||||
|
[[Templates]]
|
||||||
|
Source = "Launch.sh"
|
||||||
|
Target = "{AppDir}/Packaging/Launch.sh"
|
||||||
|
Executable = true
|
||||||
|
|
||||||
|
[[Templates]]
|
||||||
|
Source = "SaraSort.desktop"
|
||||||
|
Target = "{ApplicationsDir}/SaraSort.desktop"
|
||||||
|
|
||||||
|
# Made by SaraSort itself: Config.toml, Layout.toml, Templates/.
|
||||||
|
[[Leftovers]]
|
||||||
|
Path = "{ConfigDir}"
|
||||||
|
Kind = "Config"
|
||||||
|
Note = "SaraSort's settings, flowchart layout and your templates"
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Desktop-entry launcher: resolves the venv relative to this script rather
|
||||||
|
# than the caller's cwd, since .desktop Exec= lines are invoked with no
|
||||||
|
# guaranteed working directory.
|
||||||
|
cd "$(dirname "$(readlink -f "$0")")/.." || exit 1
|
||||||
|
exec .venv/bin/python Main.py "$@"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=SaraSort
|
||||||
|
Comment=Configure SaraSortd, the file sorting daemon
|
||||||
|
Exec={AppDir}/Packaging/Launch.sh
|
||||||
|
Icon={AppDir}/assets/Icon.png
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;
|
||||||
|
StartupNotify=true
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# SaraSort
|
||||||
|
|
||||||
|
A GUI for configuring [SaraSortd](https://git.srhtnin.garden/sarah/SaraSortd), built with Flet.
|
||||||
|
SaraSort doesn't sort anything itself: SaraSortd stays the backend, and SaraSort reads and edits its
|
||||||
|
configs and controls its systemd user service.
|
||||||
|
|
||||||
|
Needs SaraSortd **7.1** or newer for arrows with their own patterns (per-entry `ValidInputDirs`).
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
- **Flow**: every input (red), output (blue) and both (purple) directory as a box, with arrows labelled
|
||||||
|
with the file patterns that travel along them. Click a label to see all of its patterns. Boxes start in
|
||||||
|
columns and can be dragged (positions are kept in `Layout.toml`).
|
||||||
|
In **Edit mode**: click a directory, then the one it should sort into, and type the patterns. Each
|
||||||
|
pattern becomes its own `[[Files]]` entry in the output's config, with its own `ValidInputDirs`, and
|
||||||
|
the other settings come from `OutputDirConf.toml` (the template). Click a label to add or remove
|
||||||
|
patterns, remove the arrow, or save it as a template. Click a box to remove it or open its config.
|
||||||
|
- **Service**: running/stopped, start/stop/restart, start on login, SaraSortd's version (and the
|
||||||
|
SaraSuite install it came from), and its logs: its own log files or the systemd journal.
|
||||||
|
- **Configs**: `GlobalConf.toml`, the two templates and every directory's own config, as a form (with
|
||||||
|
help text, a live NewFileName preview and variable buttons) or as raw TOML. Saving only writes the
|
||||||
|
values you changed, so SaraSortd's own NextNum/NextChar/LastFile updates aren't overwritten.
|
||||||
|
- **Templates**: ready-made connections (Pictures, Videos, Music, Documents, Archives, Git repositories,
|
||||||
|
Logs, 3D printing, Installers, Backup copy) that only ask for an input and an output. Your own templates
|
||||||
|
are TOML files in `~/.config/SaraSuite/SaraSort/Templates/`.
|
||||||
|
- **Tools**: warnings about the setup (Unset values, version mismatches, outputs nothing reaches, inputs
|
||||||
|
without outputs, entries shadowed by a catch-all), a filename tester that shows where a file would go
|
||||||
|
and why, and the files in FailedDir with a retry button.
|
||||||
|
- **Settings**: the shared SaraSuite appearance, SaraSort's own settings, where SaraSortd lives, and
|
||||||
|
export/import in the same file format as SaraFem's (`[SaraSuite]` plus `[SaraSort]`).
|
||||||
|
|
||||||
|
After a change to `GlobalConf.toml` (new inputs or outputs) SaraSort restarts SaraSortd, since SaraSortd
|
||||||
|
only reads that file when it starts. That can be turned off in Settings.
|
||||||
|
|
||||||
|
## Where things live
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
| --- | --- |
|
||||||
|
| `~/.config/SaraSuite/Config.toml` | shared SaraSuite settings (theme, font, title bar) |
|
||||||
|
| `~/.config/SaraSuite/Themes/` | the themes |
|
||||||
|
| `~/.config/SaraSuite/SaraSort/Config.toml` | SaraSort's own settings |
|
||||||
|
| `~/.config/SaraSuite/SaraSort/Layout.toml` | where flowchart boxes were dragged to |
|
||||||
|
| `~/.config/SaraSuite/SaraSort/Templates/` | your own templates |
|
||||||
|
| `~/.config/SaraSuite/SaraSortd/` | SaraSortd's configs, as SaraSuite installs them (changeable in Settings) |
|
||||||
|
|
||||||
|
## Running and building
|
||||||
|
|
||||||
|
- From source: `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`, then `Packaging/Launch.sh`.
|
||||||
|
- AppImage: `Packaging/BuildAppImage.sh` builds `dist/SaraSort-x86_64.AppImage` (PyInstaller one-file
|
||||||
|
build + `appimagetool`, like SaraSuite's and SaraFem's).
|
||||||
|
- SaraSuite: `Packaging/SaraSuite/` is a ready manifest (`Manifest.toml` + `Templates/`) to copy into
|
||||||
|
SaraSuite's `Programs/SaraSort/`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What |
|
||||||
|
| --- | --- |
|
||||||
|
| `Main.py` | window, navigation |
|
||||||
|
| `Core.py` | paths, palette/theme, settings, export/import |
|
||||||
|
| `Sortd.py` | SaraSortd's configs: the graph, edits, the filename simulator, warnings |
|
||||||
|
| `Service.py` | systemctl and journalctl |
|
||||||
|
| `Templates.py` | built-in and your own templates |
|
||||||
|
| `Widgets.py` | the shared App context and small controls |
|
||||||
|
| `*Page.py` | one per page |
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
"""SaraSortd's systemd user unit: status, start/stop/restart, autostart, and
|
||||||
|
its journal. SaraSortd's own log files are read from its LogDir."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from Core import ChildEnvironment
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServiceStatus:
|
||||||
|
Active: str = "unknown" # active, inactive, failed, activating, ...
|
||||||
|
Sub: str = "" # running, dead, ...
|
||||||
|
Enabled: str = "unknown" # enabled, disabled, ...
|
||||||
|
Pid: str = ""
|
||||||
|
Since: str = ""
|
||||||
|
Found: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def Running(self) -> bool:
|
||||||
|
return self.Active in ("active", "activating", "reloading")
|
||||||
|
|
||||||
|
|
||||||
|
def _Run(Arguments: list[str], Timeout: int = 15) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(Arguments, capture_output=True, text=True, timeout=Timeout, env=ChildEnvironment())
|
||||||
|
|
||||||
|
|
||||||
|
def Status(Unit: str) -> ServiceStatus:
|
||||||
|
try:
|
||||||
|
Result = _Run(["systemctl", "--user", "show", Unit,
|
||||||
|
"-p", "LoadState,ActiveState,SubState,UnitFileState,MainPID,ActiveEnterTimestamp"])
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return ServiceStatus()
|
||||||
|
Values = dict(Line.split("=", 1) for Line in Result.stdout.splitlines() if "=" in Line)
|
||||||
|
return ServiceStatus(
|
||||||
|
Active=Values.get("ActiveState", "unknown"),
|
||||||
|
Sub=Values.get("SubState", ""),
|
||||||
|
Enabled=Values.get("UnitFileState", "") or "unknown",
|
||||||
|
Pid=Values.get("MainPID", "") if Values.get("MainPID") != "0" else "",
|
||||||
|
Since=Values.get("ActiveEnterTimestamp", ""),
|
||||||
|
Found=Values.get("LoadState") == "loaded",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def Control(Unit: str, Action: str) -> str:
|
||||||
|
"""Action: start, stop, restart, enable, disable. Returns "" or an error."""
|
||||||
|
try:
|
||||||
|
Result = _Run(["systemctl", "--user", Action, Unit])
|
||||||
|
except (OSError, subprocess.SubprocessError) as Error:
|
||||||
|
return str(Error)
|
||||||
|
return "" if Result.returncode == 0 else (Result.stderr.strip() or f"systemctl {Action} failed")
|
||||||
|
|
||||||
|
|
||||||
|
def RestartIfRunning(Unit: str) -> str:
|
||||||
|
"""For after a GlobalConf.toml change: SaraSortd only reads it at start."""
|
||||||
|
if not Status(Unit).Running:
|
||||||
|
return ""
|
||||||
|
return Control(Unit, "restart")
|
||||||
|
|
||||||
|
|
||||||
|
def Journal(Unit: str, Lines: int) -> list[str]:
|
||||||
|
try:
|
||||||
|
Result = _Run(["journalctl", "--user", "-u", Unit, "-n", str(Lines), "--no-pager", "-o", "short-iso"])
|
||||||
|
except (OSError, subprocess.SubprocessError) as Error:
|
||||||
|
return [f"Couldn't read the journal: {Error}"]
|
||||||
|
return Result.stdout.splitlines() or [Result.stderr.strip() or "(empty)"]
|
||||||
|
|
||||||
|
|
||||||
|
def LogFiles(LogDir: Path) -> list[Path]:
|
||||||
|
"""Newest first."""
|
||||||
|
if not LogDir.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted((Item for Item in LogDir.iterdir() if Item.is_file()), key=lambda Item: Item.stat().st_mtime, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def ReadTail(FilePath: Path, Lines: int) -> list[str]:
|
||||||
|
try:
|
||||||
|
return FilePath.read_text(encoding="utf-8", errors="replace").splitlines()[-Lines:]
|
||||||
|
except OSError as Error:
|
||||||
|
return [f"Couldn't read {FilePath}: {Error}"]
|
||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
"""SaraSortd's service: status, start/stop/restart, start on login, its
|
||||||
|
version, and its logs (its own log files and the systemd journal)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from Core import ChipButton, ChipButtonStyle, DataHome, Heading, Note, Palette
|
||||||
|
from Service import Control, Journal, LogFiles, ReadTail, Status
|
||||||
|
from Widgets import App
|
||||||
|
|
||||||
|
ReceiptPath = DataHome / "SaraSuite" / "Receipts" / "SaraSortd.toml"
|
||||||
|
|
||||||
|
|
||||||
|
def LineColor(Line: str) -> str:
|
||||||
|
Lower = Line.lower()
|
||||||
|
if "error" in Lower or "traceback" in Lower or "failed" in Lower:
|
||||||
|
return Palette.Red
|
||||||
|
if "sorted" in Lower:
|
||||||
|
return Palette.Green
|
||||||
|
if "started" in Lower or "stopped" in Lower:
|
||||||
|
return Palette.Mauve
|
||||||
|
if "checking" in Lower:
|
||||||
|
return Palette.Overlay0
|
||||||
|
return Palette.Text
|
||||||
|
|
||||||
|
|
||||||
|
class ServicePage(ft.Column):
|
||||||
|
def __init__(self, TheApp: App):
|
||||||
|
super().__init__(expand=True, spacing=0)
|
||||||
|
self.App = TheApp
|
||||||
|
self.Polling = False
|
||||||
|
self.Source = "Files" # or "Journal"
|
||||||
|
|
||||||
|
self.Dot = ft.Container(width=14, height=14, border_radius=7, bgcolor=Palette.Overlay0)
|
||||||
|
self.StateText = ft.Text("", size=16)
|
||||||
|
self.Details = ft.Column(spacing=2)
|
||||||
|
self.VersionInfo = ft.Column(spacing=2)
|
||||||
|
self.Autostart = ft.Switch(label="Start SaraSortd on login", on_change=self._ToggleAutostart)
|
||||||
|
self.StartButton = ChipButton("Start", lambda e: self._Do("start"), ft.Icons.PLAY_ARROW)
|
||||||
|
self.StopButton = ChipButton("Stop", lambda e: self._Do("stop"), ft.Icons.STOP, Accent=Palette.Red)
|
||||||
|
self.RestartButton = ChipButton("Restart", lambda e: self._Do("restart"), ft.Icons.RESTART_ALT)
|
||||||
|
|
||||||
|
self.FilesButton = ChipButton("Log files", lambda e: self._SetSource("Files"))
|
||||||
|
self.JournalButton = ChipButton("Journal", lambda e: self._SetSource("Journal"))
|
||||||
|
self.FilePicker = ft.Dropdown(label="Log file", dense=True, width=260, on_select=lambda e: self._LoadLog())
|
||||||
|
self.HideChecking = ft.Checkbox(label="Hide \"Checking\" lines", value=True, on_change=lambda e: self._LoadLog())
|
||||||
|
self.Follow = ft.Checkbox(label="Follow", value=True)
|
||||||
|
self.LogView = ft.ListView(expand=True, spacing=0, padding=8, auto_scroll=True)
|
||||||
|
|
||||||
|
StatusCard = ft.Container(
|
||||||
|
ft.Column([
|
||||||
|
ft.Row([self.Dot, self.StateText], spacing=10),
|
||||||
|
self.Details,
|
||||||
|
ft.Row([self.StartButton, self.StopButton, self.RestartButton], wrap=True),
|
||||||
|
self.Autostart,
|
||||||
|
], spacing=10),
|
||||||
|
padding=14, bgcolor=Palette.Mantle, border=ft.Border.all(1, Palette.Surface1), border_radius=6, expand=True,
|
||||||
|
)
|
||||||
|
VersionCard = ft.Container(
|
||||||
|
ft.Column([ft.Text("Version", color=Palette.Mauve), self.VersionInfo], spacing=8),
|
||||||
|
padding=14, bgcolor=Palette.Mantle, border=ft.Border.all(1, Palette.Surface1), border_radius=6, expand=True,
|
||||||
|
)
|
||||||
|
self.controls = [
|
||||||
|
ft.Container(
|
||||||
|
ft.Row([Heading("Service"), ft.Container(expand=True),
|
||||||
|
ChipButton("Reload", lambda e: self.Refresh(), ft.Icons.REFRESH)]),
|
||||||
|
padding=ft.Padding.only(left=16, right=16, top=12, bottom=8),
|
||||||
|
),
|
||||||
|
ft.Container(ft.Row([StatusCard, VersionCard], vertical_alignment=ft.CrossAxisAlignment.START),
|
||||||
|
padding=ft.Padding.symmetric(horizontal=16)),
|
||||||
|
ft.Container(
|
||||||
|
ft.Row([self.FilesButton, self.JournalButton, self.FilePicker, ft.Container(width=8),
|
||||||
|
self.HideChecking, self.Follow],
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER, scroll=ft.ScrollMode.AUTO),
|
||||||
|
padding=ft.Padding.only(left=16, right=16, top=14, bottom=6),
|
||||||
|
),
|
||||||
|
ft.Container(self.LogView, expand=True, bgcolor=Palette.Crust, margin=ft.Margin.only(left=16, right=16, bottom=16),
|
||||||
|
border=ft.Border.all(1, Palette.Surface1), border_radius=6),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Status
|
||||||
|
|
||||||
|
def Refresh(self) -> None:
|
||||||
|
self._RefreshStatus()
|
||||||
|
self._RefreshVersion()
|
||||||
|
self._RefreshLogFiles()
|
||||||
|
self._LoadLog()
|
||||||
|
self._StyleSourceButtons()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _RefreshStatus(self) -> None:
|
||||||
|
Current = Status(self.App.Config.ServiceName)
|
||||||
|
if not Current.Found:
|
||||||
|
Color, Text = Palette.Overlay0, f"{self.App.Config.ServiceName} isn't installed"
|
||||||
|
elif Current.Running:
|
||||||
|
Color, Text = Palette.Green, f"Running ({Current.Sub})"
|
||||||
|
elif Current.Active == "failed":
|
||||||
|
Color, Text = Palette.Red, "Failed"
|
||||||
|
else:
|
||||||
|
Color, Text = Palette.Yellow, f"Stopped ({Current.Active})"
|
||||||
|
self.Dot.bgcolor = Color
|
||||||
|
self.StateText.value = Text
|
||||||
|
self.StateText.color = Color
|
||||||
|
self.Details.controls = [
|
||||||
|
Note(f"Unit: {self.App.Config.ServiceName}"),
|
||||||
|
Note(f"Since: {Current.Since or '-'}"),
|
||||||
|
Note(f"PID: {Current.Pid or '-'}"),
|
||||||
|
Note(f"Unit file: {Current.Enabled}"),
|
||||||
|
]
|
||||||
|
self.StartButton.disabled = Current.Running or not Current.Found
|
||||||
|
self.StopButton.disabled = not Current.Running
|
||||||
|
self.RestartButton.disabled = not Current.Found
|
||||||
|
self.Autostart.value = Current.Enabled == "enabled"
|
||||||
|
self.Autostart.disabled = not Current.Found
|
||||||
|
|
||||||
|
def _RefreshVersion(self) -> None:
|
||||||
|
TheSetup, Error = self.App.LoadSetup()
|
||||||
|
Lines = []
|
||||||
|
Daemon = TheSetup.DaemonVersion() if TheSetup else None
|
||||||
|
Lines.append(ft.Text(f"SaraSortd {Daemon or '?'}", size=16))
|
||||||
|
Lines.append(Note(str(self.App.Config.SortdScriptPath)))
|
||||||
|
if TheSetup:
|
||||||
|
ConfVersion = str(TheSetup.Data.get("Version", "?"))
|
||||||
|
Mismatch = Daemon and ConfVersion.split(".")[0] != Daemon.split(".")[0]
|
||||||
|
Lines.append(ft.Text(f"GlobalConf.toml {ConfVersion}" + (" (major version differs!)" if Mismatch else ""),
|
||||||
|
color=Palette.Red if Mismatch else Palette.Text, size=13))
|
||||||
|
else:
|
||||||
|
Lines.append(ft.Text(Error, color=Palette.Red, size=12))
|
||||||
|
try:
|
||||||
|
Receipt = tomlkit.parse(ReceiptPath.read_text()).unwrap()
|
||||||
|
Lines.append(Note(f"Installed by SaraSuite: commit {str(Receipt.get('Commit', '?'))[:10]}"))
|
||||||
|
Lines.append(Note(f"Installed {Receipt.get('InstalledAt', '?')}, updated {Receipt.get('UpdatedAt', '?')}"))
|
||||||
|
except (OSError, tomlkit.exceptions.TOMLKitError):
|
||||||
|
Lines.append(Note("Not installed through SaraSuite (no receipt)."))
|
||||||
|
self.VersionInfo.controls = Lines
|
||||||
|
|
||||||
|
def _Do(self, Action: str) -> None:
|
||||||
|
Error = Control(self.App.Config.ServiceName, Action)
|
||||||
|
if Error:
|
||||||
|
self.App.Notify(Error, "error")
|
||||||
|
else:
|
||||||
|
self.App.Notify(f"SaraSortd: {Action} done.", "success")
|
||||||
|
self.Refresh()
|
||||||
|
|
||||||
|
def _ToggleAutostart(self, e) -> None:
|
||||||
|
Error = Control(self.App.Config.ServiceName, "enable" if self.Autostart.value else "disable")
|
||||||
|
if Error:
|
||||||
|
self.App.Notify(Error, "error")
|
||||||
|
self._RefreshStatus()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
|
||||||
|
def _StyleSourceButtons(self) -> None:
|
||||||
|
self.FilesButton.style = ChipButtonStyle(Palette.Mauve if self.Source == "Files" else Palette.Surface1)
|
||||||
|
self.JournalButton.style = ChipButtonStyle(Palette.Mauve if self.Source == "Journal" else Palette.Surface1)
|
||||||
|
self.FilePicker.visible = self.Source == "Files"
|
||||||
|
|
||||||
|
def _SetSource(self, Source: str) -> None:
|
||||||
|
self.Source = Source
|
||||||
|
self._StyleSourceButtons()
|
||||||
|
self._LoadLog()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _LogDir(self) -> Path | None:
|
||||||
|
TheSetup, _ = self.App.LoadSetup()
|
||||||
|
return TheSetup.SpecialDir("LogDir") if TheSetup else None
|
||||||
|
|
||||||
|
def _RefreshLogFiles(self) -> None:
|
||||||
|
LogDir = self._LogDir()
|
||||||
|
Files = LogFiles(LogDir) if LogDir else []
|
||||||
|
Current = self.FilePicker.value
|
||||||
|
self.FilePicker.options = [ft.DropdownOption(key=str(FilePath), text=FilePath.name) for FilePath in Files]
|
||||||
|
Keys = [str(FilePath) for FilePath in Files]
|
||||||
|
if Current not in Keys:
|
||||||
|
self.FilePicker.value = Keys[0] if Keys else None
|
||||||
|
|
||||||
|
def _LoadLog(self) -> None:
|
||||||
|
Limit = self.App.Config.LogLines
|
||||||
|
if self.Source == "Journal":
|
||||||
|
Lines = Journal(self.App.Config.ServiceName, Limit)
|
||||||
|
elif self.FilePicker.value:
|
||||||
|
Lines = ReadTail(Path(self.FilePicker.value), Limit * 4)
|
||||||
|
else:
|
||||||
|
Lines = ["No log files yet (is Logging = 1 in GlobalConf.toml?)."]
|
||||||
|
if self.HideChecking.value:
|
||||||
|
Lines = [Line for Line in Lines if "Checking" not in Line]
|
||||||
|
Lines = Lines[-Limit:]
|
||||||
|
self.LogView.auto_scroll = bool(self.Follow.value)
|
||||||
|
self.LogView.controls = [
|
||||||
|
ft.Text(Line, size=12, color=LineColor(Line), selectable=True) for Line in Lines
|
||||||
|
]
|
||||||
|
if self.page:
|
||||||
|
self.LogView.update()
|
||||||
|
|
||||||
|
# Polling while the page is shown
|
||||||
|
|
||||||
|
def StartPolling(self) -> None:
|
||||||
|
if self.Polling:
|
||||||
|
return
|
||||||
|
self.Polling = True
|
||||||
|
self.App.Page.run_task(self._Poll)
|
||||||
|
|
||||||
|
def StopPolling(self) -> None:
|
||||||
|
self.Polling = False
|
||||||
|
|
||||||
|
async def _Poll(self) -> None:
|
||||||
|
while self.Polling:
|
||||||
|
await asyncio.sleep(max(1, int(self.App.Config.RefreshSeconds)))
|
||||||
|
if not self.Polling:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
self._RefreshStatus()
|
||||||
|
if self.Follow.value:
|
||||||
|
self._RefreshLogFiles()
|
||||||
|
self._LoadLog()
|
||||||
|
self.update()
|
||||||
|
except Exception: # the page may be gone mid-update
|
||||||
|
pass
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
"""SaraSort's own settings (not SaraSortd's): the shared SaraSuite appearance,
|
||||||
|
SaraSort's own, where SaraSortd lives, and exporting/importing settings in
|
||||||
|
the same file format SaraFem uses ([SaraSuite] + one section per program)."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from Core import (
|
||||||
|
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButton, Heading, LoadSharedSettings, Note,
|
||||||
|
Palette, SharedSettings, ExportSettings, ImportSettings,
|
||||||
|
)
|
||||||
|
from Widgets import App
|
||||||
|
|
||||||
|
CategoryListWidth = 170
|
||||||
|
RestartNote = "Theme, font and title bar changes apply the next time SaraSort starts."
|
||||||
|
Pages = ["Flow", "Service", "Configs", "Templates", "Tools", "Settings"]
|
||||||
|
|
||||||
|
|
||||||
|
def InstalledFonts() -> list[str]:
|
||||||
|
try:
|
||||||
|
Result = subprocess.run(["fc-list", ":", "family"], capture_output=True, text=True, timeout=5, env=ChildEnvironment())
|
||||||
|
Families = {Line.split(",")[0].strip() for Line in Result.stdout.splitlines() if Line.strip()}
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
Families = set()
|
||||||
|
Families.discard(BundledFontFamily)
|
||||||
|
return [BundledFontFamily] + sorted(Families, key=str.lower)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsPage(ft.Column):
|
||||||
|
def __init__(self, TheApp: App, OnSaved: Callable[[], None]):
|
||||||
|
super().__init__(expand=True, spacing=0)
|
||||||
|
self.App = TheApp
|
||||||
|
self.OnSaved = OnSaved
|
||||||
|
self.Selected = "Shared"
|
||||||
|
self.Detail = ft.Container(expand=True, padding=ft.Padding.only(left=16, right=16, top=8))
|
||||||
|
self.CategoryList = ft.Column(spacing=0)
|
||||||
|
self.Fonts: list[str] = []
|
||||||
|
self.controls = [
|
||||||
|
ft.Container(
|
||||||
|
ft.Row([
|
||||||
|
Heading("Settings"), ft.Container(expand=True),
|
||||||
|
ChipButton("Import", self._Import, ft.Icons.FILE_DOWNLOAD_OUTLINED),
|
||||||
|
ChipButton("Export", self._Export, ft.Icons.FILE_UPLOAD_OUTLINED),
|
||||||
|
ChipButton("Revert", lambda e: self.Refresh(), ft.Icons.UNDO),
|
||||||
|
ChipButton("Save", lambda e: self._Save(), ft.Icons.SAVE_OUTLINED),
|
||||||
|
]),
|
||||||
|
padding=ft.Padding.only(left=16, right=16, top=12, bottom=8),
|
||||||
|
),
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
ft.Row([
|
||||||
|
ft.Container(self.CategoryList, width=CategoryListWidth, bgcolor=Palette.Mantle, padding=4),
|
||||||
|
self.Detail,
|
||||||
|
], expand=True, vertical_alignment=ft.CrossAxisAlignment.STRETCH),
|
||||||
|
ft.Container(Note(RestartNote), padding=ft.Padding.only(left=16, bottom=10, top=6)),
|
||||||
|
]
|
||||||
|
|
||||||
|
def Refresh(self) -> None:
|
||||||
|
if not self.Fonts:
|
||||||
|
self.Fonts = InstalledFonts()
|
||||||
|
Config = self.App.Config
|
||||||
|
Themes = AvailableThemes()
|
||||||
|
|
||||||
|
def ThemePicker(Value: str) -> ft.Dropdown:
|
||||||
|
Names = Themes if Value in Themes else [Value] + Themes
|
||||||
|
return ft.Dropdown(value=Value, label="Theme", options=[ft.DropdownOption(key=Name, text=Name) for Name in Names])
|
||||||
|
|
||||||
|
def FontPicker(Value: str) -> ft.Dropdown:
|
||||||
|
Names = self.Fonts if Value in self.Fonts else [Value] + self.Fonts
|
||||||
|
return ft.Dropdown(value=Value, label="Font", editable=True, enable_filter=True, menu_height=320,
|
||||||
|
options=[ft.DropdownOption(key=Name, text=Name) for Name in Names])
|
||||||
|
|
||||||
|
Shared = LoadSharedSettings(Config)
|
||||||
|
self.UseShared = ft.Switch(label="SaraSort uses these shared settings", value=Config.UseSharedSettings,
|
||||||
|
on_change=lambda e: self._DimOwn())
|
||||||
|
self.SharedTheme = ThemePicker(Shared.Theme)
|
||||||
|
self.SharedFont = FontPicker(Shared.FontFamily)
|
||||||
|
self.SharedTitleBar = ft.Switch(label="Hide window title bar", value=Shared.HideWindowTitleBar)
|
||||||
|
|
||||||
|
self.OwnTheme = ThemePicker(Config.Theme)
|
||||||
|
self.OwnFont = FontPicker(Config.FontFamily)
|
||||||
|
self.OwnTitleBar = ft.Switch(label="Hide window title bar", value=Config.HideWindowTitleBar)
|
||||||
|
self.OwnAppearance = ft.Column([
|
||||||
|
Note("SaraSort's own appearance, used when shared settings are off"),
|
||||||
|
self.OwnTheme, self.OwnFont, self.OwnTitleBar,
|
||||||
|
], spacing=12)
|
||||||
|
|
||||||
|
self.ConfigDir = ft.TextField(label="SaraSortd config folder (GlobalConf.toml and the templates)", value=Config.SortdConfigDir)
|
||||||
|
self.AppDir = ft.TextField(label="SaraSortd program folder (SaraSortd.py)", value=Config.SortdAppDir)
|
||||||
|
self.Unit = ft.TextField(label="systemd user unit", value=Config.ServiceName)
|
||||||
|
self.AutoRestart = ft.Switch(label="Restart SaraSortd after changes to GlobalConf.toml", value=Config.AutoRestart)
|
||||||
|
self.Refresh_ = ft.TextField(label="Service page refresh (seconds)", value=str(Config.RefreshSeconds), width=260,
|
||||||
|
keyboard_type=ft.KeyboardType.NUMBER)
|
||||||
|
self.LogLines = ft.TextField(label="Log lines to show", value=str(Config.LogLines), width=260,
|
||||||
|
keyboard_type=ft.KeyboardType.NUMBER)
|
||||||
|
self.LabelLength = ft.TextField(label="Arrow label length before shortening", value=str(Config.LabelLength), width=260,
|
||||||
|
keyboard_type=ft.KeyboardType.NUMBER)
|
||||||
|
self.StartPage = ft.Dropdown(label="Start page", value=Config.StartPage, width=260,
|
||||||
|
options=[ft.DropdownOption(key=Name, text=Name) for Name in Pages[:-1]])
|
||||||
|
|
||||||
|
self.Categories = {
|
||||||
|
"Shared": ft.Column([
|
||||||
|
Note("Shared by every SaraSuite program (~/.config/SaraSuite/Config.toml)."),
|
||||||
|
self.UseShared,
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
self.SharedTheme, self.SharedFont, self.SharedTitleBar,
|
||||||
|
], spacing=12, scroll=ft.ScrollMode.AUTO),
|
||||||
|
"SaraSort": ft.Column([
|
||||||
|
self.StartPage, self.LabelLength,
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
self.OwnAppearance,
|
||||||
|
], spacing=12, scroll=ft.ScrollMode.AUTO),
|
||||||
|
"SaraSortd": ft.Column([
|
||||||
|
Note("Defaults are where SaraSuite installs SaraSortd."),
|
||||||
|
self.ConfigDir, self.AppDir, self.Unit, self.AutoRestart, self.Refresh_, self.LogLines,
|
||||||
|
], spacing=12, scroll=ft.ScrollMode.AUTO),
|
||||||
|
}
|
||||||
|
self._DimOwn(Update=False)
|
||||||
|
self._Select(self.Selected, Update=False)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _DimOwn(self, Update: bool = True) -> None:
|
||||||
|
self.OwnAppearance.disabled = bool(self.UseShared.value)
|
||||||
|
self.OwnAppearance.opacity = 0.5 if self.UseShared.value else 1
|
||||||
|
if Update:
|
||||||
|
self.OwnAppearance.update()
|
||||||
|
|
||||||
|
def _Select(self, Name: str, Update: bool = True) -> None:
|
||||||
|
self.Selected = Name
|
||||||
|
self.CategoryList.controls = [
|
||||||
|
ft.ListTile(title=Category, selected=Category == Name, selected_tile_color=Palette.Surface1,
|
||||||
|
selected_color=Palette.Mauve, on_click=lambda e, Category=Category: self._Select(Category))
|
||||||
|
for Category in self.Categories
|
||||||
|
]
|
||||||
|
self.Detail.content = self.Categories[Name]
|
||||||
|
if Update:
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _Int(Field: ft.TextField, Fallback: int, Minimum: int) -> int:
|
||||||
|
try:
|
||||||
|
return max(Minimum, int(Field.value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Fallback
|
||||||
|
|
||||||
|
def _Save(self) -> None:
|
||||||
|
Config: AppConfig = self.App.Config
|
||||||
|
Config.UseSharedSettings = bool(self.UseShared.value)
|
||||||
|
Config.Theme = self.OwnTheme.value or Config.Theme
|
||||||
|
Config.FontFamily = self.OwnFont.value or Config.FontFamily
|
||||||
|
Config.HideWindowTitleBar = bool(self.OwnTitleBar.value)
|
||||||
|
Config.SortdConfigDir = (self.ConfigDir.value or "").strip() or Config.SortdConfigDir
|
||||||
|
Config.SortdAppDir = (self.AppDir.value or "").strip() or Config.SortdAppDir
|
||||||
|
Config.ServiceName = (self.Unit.value or "").strip() or Config.ServiceName
|
||||||
|
Config.AutoRestart = bool(self.AutoRestart.value)
|
||||||
|
Config.RefreshSeconds = self._Int(self.Refresh_, Config.RefreshSeconds, 1)
|
||||||
|
Config.LogLines = self._Int(self.LogLines, Config.LogLines, 10)
|
||||||
|
Config.LabelLength = self._Int(self.LabelLength, Config.LabelLength, 6)
|
||||||
|
Config.StartPage = self.StartPage.value or Config.StartPage
|
||||||
|
Config.Save()
|
||||||
|
Shared = LoadSharedSettings(Config)
|
||||||
|
SharedSettings(
|
||||||
|
Theme=self.SharedTheme.value or Shared.Theme,
|
||||||
|
FontFamily=self.SharedFont.value or Shared.FontFamily,
|
||||||
|
HideWindowTitleBar=bool(self.SharedTitleBar.value),
|
||||||
|
).Save()
|
||||||
|
self.App.Notify("Settings saved.", "success")
|
||||||
|
self.OnSaved()
|
||||||
|
|
||||||
|
async def _Export(self, e) -> None:
|
||||||
|
Chosen = await self.App.Picker.save_file(dialog_title="Export settings", file_name="SaraSortSettings.toml",
|
||||||
|
allowed_extensions=["toml"])
|
||||||
|
if not Chosen:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ExportSettings(self.App.Config, Path(Chosen))
|
||||||
|
except OSError as Error:
|
||||||
|
self.App.Notify(f"Couldn't export settings: {Error}", "error")
|
||||||
|
return
|
||||||
|
self.App.Notify("Settings exported.", "success")
|
||||||
|
|
||||||
|
async def _Import(self, e) -> None:
|
||||||
|
Files = await self.App.Picker.pick_files(dialog_title="Import settings", allowed_extensions=["toml"])
|
||||||
|
if not Files:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ImportSettings(self.App.Config, Path(Files[0].path))
|
||||||
|
except (OSError, tomlkit.exceptions.TOMLKitError) as Error:
|
||||||
|
self.App.Notify(f"Couldn't import settings: {Error}", "error")
|
||||||
|
return
|
||||||
|
self.Refresh()
|
||||||
|
self.App.Notify("Settings imported. Theme, font and title bar apply on the next start.", "success")
|
||||||
|
self.OnSaved()
|
||||||
@@ -0,0 +1,673 @@
|
|||||||
|
"""SaraSortd's configs as SaraSort sees them: which directories are inputs and
|
||||||
|
outputs, which file patterns go from where to where, and the edits the
|
||||||
|
flowchart and templates make.
|
||||||
|
|
||||||
|
SaraSort never sorts anything itself. To show what SaraSortd will really do,
|
||||||
|
Parse, GetConf and Simulate follow SaraSortd.py's own Parse, GetConf and
|
||||||
|
DecideNewPath, quirks included (e.g. Parse also swaps variable characters
|
||||||
|
inside paths). All writes go through tomlkit, so comments and layout in the
|
||||||
|
files survive; SaraSortd itself rewrites directory configs with `toml`, which
|
||||||
|
drops comments, but GlobalConf.toml and the templates keep theirs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import fnmatch
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import tomlkit
|
||||||
|
from tomlkit.items import AoT
|
||||||
|
|
||||||
|
from Core import AppConfig
|
||||||
|
|
||||||
|
TemplateNames = {"In": "InputDirConf.toml", "Out": "OutputDirConf.toml"}
|
||||||
|
NameKeys = {"In": "InDirConfName", "Out": "OutDirConfName"}
|
||||||
|
# 0/1 switches in SaraSortd's configs, shown as switches in the editor.
|
||||||
|
SwitchKeys = {"SafeMode", "SilentMode", "DotFiles", "Logging", "OverwriteDirConf", "DeleteOrg", "SortDirs", "CaseSensitive", "Overwrite"}
|
||||||
|
|
||||||
|
|
||||||
|
class SortdError(Exception):
|
||||||
|
"""GlobalConf.toml is missing or unreadable."""
|
||||||
|
|
||||||
|
|
||||||
|
def ReadToml(FilePath: Path) -> tomlkit.TOMLDocument:
|
||||||
|
return tomlkit.parse(FilePath.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def WriteToml(FilePath: Path, Document: tomlkit.TOMLDocument) -> None:
|
||||||
|
FilePath.write_text(tomlkit.dumps(Document), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def GetConf(Parameter: str, Data: dict) -> Any:
|
||||||
|
"""SaraSortd's GetConf: a top-level key, or one a level down inside a
|
||||||
|
table or array of tables."""
|
||||||
|
if Parameter in Data:
|
||||||
|
return Data[Parameter]
|
||||||
|
for Value in Data.values():
|
||||||
|
if isinstance(Value, dict):
|
||||||
|
if Parameter in Value:
|
||||||
|
return Value[Parameter]
|
||||||
|
elif isinstance(Value, list):
|
||||||
|
for Item in Value:
|
||||||
|
if isinstance(Item, dict) and Parameter in Item:
|
||||||
|
return Item[Parameter]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def MajorVersion(Version: str) -> str:
|
||||||
|
return str(Version).split(".")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def VersionAtLeast(Version: str, Wanted: tuple[int, int]) -> bool:
|
||||||
|
try:
|
||||||
|
Parts = tuple(int(Part) for Part in str(Version).split(".")[:2])
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return Parts >= Wanted
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileRule:
|
||||||
|
"""One [[Files]] entry of an output directory that applies to an arrow."""
|
||||||
|
|
||||||
|
Index: int # position in the output's Files array
|
||||||
|
Pattern: str
|
||||||
|
NewFileName: str
|
||||||
|
OwnInputs: bool # True: the entry has its own ValidInputDirs (7.1)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Edge:
|
||||||
|
Input: str
|
||||||
|
Output: str
|
||||||
|
Rules: list[FileRule] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def Patterns(self) -> list[str]:
|
||||||
|
return [Rule.Pattern for Rule in self.Rules]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DirNode:
|
||||||
|
Path: str # resolved, as SaraSortd sees it
|
||||||
|
IsInput: bool = False
|
||||||
|
IsOutput: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def Kind(self) -> str:
|
||||||
|
if self.IsInput and self.IsOutput:
|
||||||
|
return "Both"
|
||||||
|
return "Input" if self.IsInput else "Output"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SimulationResult:
|
||||||
|
Steps: list[str]
|
||||||
|
Destination: Optional[str] # full new path, or None
|
||||||
|
Outcome: str # "Sorted", "Failed", "Skipped"
|
||||||
|
|
||||||
|
|
||||||
|
class Setup:
|
||||||
|
"""GlobalConf.toml plus everything reachable from it. Cheap to reload:
|
||||||
|
pages just make a new one after each change."""
|
||||||
|
|
||||||
|
def __init__(self, Config: AppConfig):
|
||||||
|
self.Config = Config
|
||||||
|
self.ConfDir = Path(Config.SortdConfigDir).expanduser()
|
||||||
|
self.GlobalPath = Config.GlobalConfPath
|
||||||
|
if not self.GlobalPath.exists():
|
||||||
|
raise SortdError(f"No GlobalConf.toml at {self.GlobalPath}")
|
||||||
|
try:
|
||||||
|
self.Global = ReadToml(self.GlobalPath)
|
||||||
|
except tomlkit.exceptions.TOMLKitError as Error:
|
||||||
|
raise SortdError(f"GlobalConf.toml can't be read: {Error}") from Error
|
||||||
|
self.Data = self.Global.unwrap()
|
||||||
|
self.Vars = self.Data.get("Variables", {})
|
||||||
|
self.Dirs = self.Data.get("DirectoryPaths", {})
|
||||||
|
self.Names = self.Data.get("Names", {})
|
||||||
|
|
||||||
|
# Parsing, as SaraSortd.py does it
|
||||||
|
|
||||||
|
def Parse(
|
||||||
|
self, String: str, ConfData: Optional[dict] = None, NextNum=None, NextChar=None, Parent=None,
|
||||||
|
OrgFile: Optional[str] = None, VarCall=None, Now: Optional[datetime.datetime] = None,
|
||||||
|
) -> str:
|
||||||
|
"""SaraSortd's Parse. ConfData is the file %Name% lookups read from
|
||||||
|
(GlobalConf unless given, like SaraSortd's Path argument)."""
|
||||||
|
if not isinstance(String, str):
|
||||||
|
return String
|
||||||
|
ConfData = self.Data if ConfData is None else ConfData
|
||||||
|
Now = Now or datetime.datetime.now()
|
||||||
|
OrgFileName, OrgFileType = os.path.splitext(OrgFile) if OrgFile is not None else (None, None)
|
||||||
|
V = self.Vars
|
||||||
|
VarValues = {
|
||||||
|
V.get("NextNum"): str(NextNum),
|
||||||
|
V.get("NextChar"): str(NextChar),
|
||||||
|
V.get("Parent"): str(Parent),
|
||||||
|
V.get("OrgFileName"): str(OrgFileName),
|
||||||
|
V.get("OrgFileType"): str(OrgFileType),
|
||||||
|
V.get("Year"): str(Now.year),
|
||||||
|
V.get("Month"): str(Now.month).rjust(2, "0"),
|
||||||
|
V.get("Day"): str(Now.day).rjust(2, "0"),
|
||||||
|
V.get("Hour"): str(Now.hour).rjust(2, "0"),
|
||||||
|
V.get("Minute"): str(Now.minute).rjust(2, "0"),
|
||||||
|
V.get("Second"): str(Now.second).rjust(2, "0"),
|
||||||
|
V.get("VarCall"): str(VarCall),
|
||||||
|
}
|
||||||
|
for Key, Value in VarValues.items():
|
||||||
|
if Key and Key in String:
|
||||||
|
String = String.replace(Key, Value)
|
||||||
|
|
||||||
|
Call = V.get("VarCall", "%%")
|
||||||
|
if len(Call) < 2:
|
||||||
|
return String
|
||||||
|
|
||||||
|
def Replacer(Match: re.Match) -> str:
|
||||||
|
Content = Match.group(1)
|
||||||
|
if not Content:
|
||||||
|
return str(VarCall) if VarCall is not None else ""
|
||||||
|
Found = GetConf(Content, ConfData)
|
||||||
|
return str(Found) if Found is not None else Match.group(0)
|
||||||
|
|
||||||
|
return re.sub(f"{re.escape(Call[0])}(.*?){re.escape(Call[1])}", Replacer, String)
|
||||||
|
|
||||||
|
def ResolveDir(self, Raw: str) -> str:
|
||||||
|
return self.Parse(Raw)
|
||||||
|
|
||||||
|
def DirList(self, Key: str) -> list[str]:
|
||||||
|
Value = self.Dirs.get(Key, [])
|
||||||
|
return [str(Item) for Item in (Value if isinstance(Value, list) else [Value])]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def Inputs(self) -> list[str]:
|
||||||
|
return [self.ResolveDir(Raw) for Raw in self.DirList("InputDir")]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def Outputs(self) -> list[str]:
|
||||||
|
return [self.ResolveDir(Raw) for Raw in self.DirList("OutputDir")]
|
||||||
|
|
||||||
|
def SpecialDir(self, Key: str) -> Optional[Path]:
|
||||||
|
Raw = self.Dirs.get(Key)
|
||||||
|
return Path(self.ResolveDir(str(Raw))) if Raw else None
|
||||||
|
|
||||||
|
def RootDir(self) -> Optional[str]:
|
||||||
|
Root = self.Dirs.get("RootDir")
|
||||||
|
return None if not Root or Root == "Unset" else str(Root)
|
||||||
|
|
||||||
|
def Shorthand(self, PathText: str) -> str:
|
||||||
|
"""How a path is written into GlobalConf: %RootDir%/... when under RootDir."""
|
||||||
|
Root = self.RootDir()
|
||||||
|
Call = self.Vars.get("VarCall", "%%")
|
||||||
|
if Root and len(Call) >= 2:
|
||||||
|
Root = Root.rstrip("/")
|
||||||
|
if PathText == Root or PathText.startswith(Root + "/"):
|
||||||
|
return f"{Call[0]}RootDir{Call[1]}{PathText[len(Root):]}"
|
||||||
|
return PathText
|
||||||
|
|
||||||
|
def ConfFile(self, DirPath: str, Kind: str) -> Path:
|
||||||
|
"""A directory's own config, e.g. ~/Sorting/Output/.OutputConfig.toml."""
|
||||||
|
Name = self.Parse(str(self.Names.get(NameKeys[Kind], ".@Config")), Parent=os.path.basename(DirPath))
|
||||||
|
return Path(DirPath) / f"{Name}.toml"
|
||||||
|
|
||||||
|
def SharedConfName(self) -> bool:
|
||||||
|
"""Input and output configs use the same file name (both default to .@Config)."""
|
||||||
|
return self.Names.get("InDirConfName") == self.Names.get("OutDirConfName")
|
||||||
|
|
||||||
|
def TemplatePath(self, Kind: str) -> Path:
|
||||||
|
return self.ConfDir / TemplateNames[Kind]
|
||||||
|
|
||||||
|
def LoadDirConf(self, DirPath: str, Kind: str, UseTemplate: bool = True) -> Optional[dict]:
|
||||||
|
"""A directory's config. When it has none yet, the template it will
|
||||||
|
get from SaraSortd on its next start (unless UseTemplate is off)."""
|
||||||
|
ConfPath = self.ConfFile(DirPath, Kind)
|
||||||
|
try:
|
||||||
|
return ReadToml(ConfPath).unwrap()
|
||||||
|
except (OSError, tomlkit.exceptions.TOMLKitError):
|
||||||
|
pass
|
||||||
|
if not UseTemplate or ConfPath.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return self._NewDirConf(DirPath, Kind).unwrap()
|
||||||
|
except (OSError, tomlkit.exceptions.TOMLKitError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def DaemonVersion(self) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
Match = re.search(r'# Version = "([^"]+)"', self.Config.SortdScriptPath.read_text())
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return Match.group(1) if Match else None
|
||||||
|
|
||||||
|
# The graph
|
||||||
|
|
||||||
|
def Nodes(self) -> dict[str, DirNode]:
|
||||||
|
Nodes: dict[str, DirNode] = {}
|
||||||
|
for DirPath in self.Inputs:
|
||||||
|
Nodes.setdefault(DirPath, DirNode(DirPath)).IsInput = True
|
||||||
|
for DirPath in self.Outputs:
|
||||||
|
Nodes.setdefault(DirPath, DirNode(DirPath)).IsOutput = True
|
||||||
|
return Nodes
|
||||||
|
|
||||||
|
def _DirAccepts(self, OutConf: dict, OutputPath: str, InputPath: str) -> bool:
|
||||||
|
"""The output directory's own ValidInputDirs, as DecideNewPath checks it."""
|
||||||
|
Parent = GetConf("ParentDir", OutConf)
|
||||||
|
for Valid in GetConf("ValidInputDirs", OutConf) or []:
|
||||||
|
if fnmatch.fnmatchcase(InputPath, self.Parse(str(Valid), Parent=Parent)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _EntryAccepts(self, Entry: dict, OutputPath: str, InputPath: str) -> bool:
|
||||||
|
"""SaraSortd 7.1's FileInputValid."""
|
||||||
|
Own = Entry.get("ValidInputDirs")
|
||||||
|
if Own is None:
|
||||||
|
return True
|
||||||
|
Parent = os.path.basename(OutputPath)
|
||||||
|
return any(fnmatch.fnmatchcase(InputPath, self.Parse(str(Valid), Parent=Parent)) for Valid in Own)
|
||||||
|
|
||||||
|
def Edges(self) -> list[Edge]:
|
||||||
|
Edges = []
|
||||||
|
for OutputPath in dict.fromkeys(self.Outputs):
|
||||||
|
OutConf = self.LoadDirConf(OutputPath, "Out")
|
||||||
|
if OutConf is None:
|
||||||
|
continue
|
||||||
|
Entries = OutConf.get("Files", [])
|
||||||
|
for InputPath in dict.fromkeys(self.Inputs):
|
||||||
|
if not self._DirAccepts(OutConf, OutputPath, InputPath):
|
||||||
|
continue
|
||||||
|
Found = Edge(InputPath, OutputPath)
|
||||||
|
for Index, Entry in enumerate(Entries):
|
||||||
|
if "Pattern" not in Entry or not self._EntryAccepts(Entry, OutputPath, InputPath):
|
||||||
|
continue
|
||||||
|
Found.Rules.append(FileRule(
|
||||||
|
Index, str(Entry["Pattern"]), str(Entry.get("NewFileName", "")), "ValidInputDirs" in Entry,
|
||||||
|
))
|
||||||
|
if Found.Rules:
|
||||||
|
Edges.append(Found)
|
||||||
|
return Edges
|
||||||
|
|
||||||
|
# Edits
|
||||||
|
|
||||||
|
def _SaveGlobal(self) -> None:
|
||||||
|
WriteToml(self.GlobalPath, self.Global)
|
||||||
|
self.Data = self.Global.unwrap()
|
||||||
|
self.Dirs = self.Data.get("DirectoryPaths", {})
|
||||||
|
|
||||||
|
def _AddToList(self, Key: str, PathText: str) -> bool:
|
||||||
|
"""Adds a directory to InputDir/OutputDir. True if GlobalConf changed."""
|
||||||
|
if PathText in (self.Inputs if Key == "InputDir" else self.Outputs):
|
||||||
|
return False
|
||||||
|
Section = self.Global.setdefault("DirectoryPaths", tomlkit.table())
|
||||||
|
Current = Section.get(Key)
|
||||||
|
if Current is None:
|
||||||
|
Section[Key] = [self.Shorthand(PathText)]
|
||||||
|
elif isinstance(Current, list):
|
||||||
|
Current.append(self.Shorthand(PathText))
|
||||||
|
else: # a single string; SaraSortd expects a list
|
||||||
|
Section[Key] = [str(Current), self.Shorthand(PathText)]
|
||||||
|
self._SaveGlobal()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def AddDirectory(self, PathText: str, Keys: list[str]) -> bool:
|
||||||
|
"""Adds a directory as an input and/or output ("InputDir"/"OutputDir")
|
||||||
|
and writes its config from the template. True if GlobalConf changed."""
|
||||||
|
Changed = False
|
||||||
|
for Key in Keys:
|
||||||
|
Changed = self._AddToList(Key, PathText) or Changed
|
||||||
|
for Key in Keys:
|
||||||
|
Kind = "In" if Key == "InputDir" else "Out"
|
||||||
|
Document, IsNew = self.EnsureDirConf(PathText, Kind)
|
||||||
|
WriteToml(self.ConfFile(PathText, Kind), Document)
|
||||||
|
return Changed
|
||||||
|
|
||||||
|
def RemoveDir(self, PathText: str) -> bool:
|
||||||
|
"""Takes a directory out of InputDir and OutputDir. Leaves the
|
||||||
|
directory and its config on disk. True if GlobalConf changed."""
|
||||||
|
Changed = False
|
||||||
|
Section = self.Global.get("DirectoryPaths", {})
|
||||||
|
for Key in ("InputDir", "OutputDir"):
|
||||||
|
Current = Section.get(Key)
|
||||||
|
if not isinstance(Current, list):
|
||||||
|
continue
|
||||||
|
for Index in reversed(range(len(Current))):
|
||||||
|
if self.ResolveDir(str(Current[Index])) == PathText:
|
||||||
|
del Current[Index]
|
||||||
|
Changed = True
|
||||||
|
if Changed:
|
||||||
|
self._SaveGlobal()
|
||||||
|
return Changed
|
||||||
|
|
||||||
|
def _NewDirConf(self, DirPath: str, Kind: str) -> tomlkit.TOMLDocument:
|
||||||
|
"""What SaraSortd's Dir() would write: the template, with ParentDir and
|
||||||
|
Title set."""
|
||||||
|
Name = os.path.basename(DirPath)
|
||||||
|
Document = ReadToml(self.TemplatePath(Kind))
|
||||||
|
Document["ParentDir"] = Name
|
||||||
|
Document["Title"] = f"{Name} Config"
|
||||||
|
return Document
|
||||||
|
|
||||||
|
def EnsureDirConf(self, DirPath: str, Kind: str) -> tuple[tomlkit.TOMLDocument, bool]:
|
||||||
|
"""The directory's config, made from the template when missing.
|
||||||
|
Returns (document, IsNew). The directory itself is made too."""
|
||||||
|
Path(DirPath).mkdir(parents=True, exist_ok=True)
|
||||||
|
ConfPath = self.ConfFile(DirPath, Kind)
|
||||||
|
if ConfPath.exists():
|
||||||
|
Document = ReadToml(ConfPath)
|
||||||
|
IsNew = False
|
||||||
|
else:
|
||||||
|
Document = self._NewDirConf(DirPath, Kind)
|
||||||
|
IsNew = True
|
||||||
|
if self.SharedConfName() and DirPath in self.Inputs and DirPath in self.Outputs:
|
||||||
|
# One file for both roles: make sure it has the keys both read.
|
||||||
|
Other = "In" if Kind == "Out" else "Out"
|
||||||
|
OtherTemplate = ReadToml(self.TemplatePath(Other))
|
||||||
|
for Key, Value in OtherTemplate.items():
|
||||||
|
if Key != "Files" and Key not in Document:
|
||||||
|
Document[Key] = Value
|
||||||
|
# Its [[Files]] are read as output rules too, which need more keys.
|
||||||
|
Defaults = self.TemplateFileDefaults()
|
||||||
|
for Entry in Document.get("Files", []):
|
||||||
|
for Key, Value in Defaults.items():
|
||||||
|
if Key not in Entry:
|
||||||
|
Entry[Key] = Value
|
||||||
|
return Document, IsNew
|
||||||
|
|
||||||
|
def TemplateFileDefaults(self) -> dict:
|
||||||
|
"""The first [[Files]] entry of OutputDirConf.toml: the defaults for
|
||||||
|
every new entry (NewFileName, NextNum, ...)."""
|
||||||
|
try:
|
||||||
|
Entries = ReadToml(self.TemplatePath("Out")).unwrap().get("Files", [])
|
||||||
|
except (OSError, tomlkit.exceptions.TOMLKitError):
|
||||||
|
Entries = []
|
||||||
|
Defaults = dict(Entries[0]) if Entries else {}
|
||||||
|
Defaults.setdefault("NewFileName", "~^")
|
||||||
|
Defaults.setdefault("NextNum", "1")
|
||||||
|
Defaults.setdefault("NextChar", "A")
|
||||||
|
Defaults.setdefault("CaseSensitive", 0)
|
||||||
|
Defaults.setdefault("Overwrite", 1)
|
||||||
|
Defaults.pop("ValidInputDirs", None)
|
||||||
|
return Defaults
|
||||||
|
|
||||||
|
def Connect(
|
||||||
|
self, InputPath: str, OutputPath: str, Patterns: list[str],
|
||||||
|
EntryOverrides: Optional[dict] = None, OutputSettings: Optional[dict] = None,
|
||||||
|
InputSettings: Optional[dict] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Makes InputPath send Patterns to OutputPath. Each pattern becomes a
|
||||||
|
[[Files]] entry in the output's config (defaults from OutputDirConf.toml)
|
||||||
|
with its own ValidInputDirs, so other inputs aren't affected. Returns
|
||||||
|
True if GlobalConf.toml changed (SaraSortd needs a restart)."""
|
||||||
|
Patterns = [Pattern.strip() for Pattern in Patterns if Pattern.strip()]
|
||||||
|
Changed = self._AddToList("InputDir", InputPath)
|
||||||
|
Changed = self._AddToList("OutputDir", OutputPath) or Changed
|
||||||
|
|
||||||
|
# The output side.
|
||||||
|
OutDoc, IsNew = self.EnsureDirConf(OutputPath, "Out")
|
||||||
|
if IsNew and "Files" in OutDoc:
|
||||||
|
# The template's catch-all entry would take every file from every
|
||||||
|
# input; the new entries replace it.
|
||||||
|
del OutDoc["Files"]
|
||||||
|
for Key, Value in (OutputSettings or {}).items():
|
||||||
|
OutDoc[Key] = Value
|
||||||
|
OutData = OutDoc.unwrap()
|
||||||
|
if not self._DirAccepts(OutData, OutputPath, InputPath):
|
||||||
|
Valid = OutDoc.get("ValidInputDirs")
|
||||||
|
if isinstance(Valid, list):
|
||||||
|
Valid.append(self.Shorthand(InputPath))
|
||||||
|
else:
|
||||||
|
OutDoc["ValidInputDirs"] = [self.Shorthand(InputPath)]
|
||||||
|
if "Files" not in OutDoc:
|
||||||
|
OutDoc["Files"] = AoT([])
|
||||||
|
Files = OutDoc["Files"]
|
||||||
|
Defaults = self.TemplateFileDefaults()
|
||||||
|
for Pattern in Patterns:
|
||||||
|
Existing = next((Entry for Entry in Files if Entry.get("Pattern") == Pattern), None)
|
||||||
|
if Existing is not None:
|
||||||
|
Own = Existing.get("ValidInputDirs")
|
||||||
|
if Own is not None and not self._EntryAccepts(Existing.unwrap(), OutputPath, InputPath):
|
||||||
|
Own.append(self.Shorthand(InputPath))
|
||||||
|
for Key, Value in (EntryOverrides or {}).items():
|
||||||
|
Existing[Key] = Value
|
||||||
|
continue
|
||||||
|
Entry = tomlkit.table()
|
||||||
|
Entry["Pattern"] = Pattern
|
||||||
|
for Key, Value in Defaults.items():
|
||||||
|
if Key != "Pattern":
|
||||||
|
Entry[Key] = Value
|
||||||
|
for Key, Value in (EntryOverrides or {}).items():
|
||||||
|
Entry[Key] = Value
|
||||||
|
Entry["ValidInputDirs"] = [self.Shorthand(InputPath)]
|
||||||
|
Files.append(Entry)
|
||||||
|
WriteToml(self.ConfFile(OutputPath, "Out"), OutDoc)
|
||||||
|
|
||||||
|
# The input side: it has to let these files through at all.
|
||||||
|
InDoc, IsNew = self.EnsureDirConf(InputPath, "In")
|
||||||
|
for Key, Value in (InputSettings or {}).items():
|
||||||
|
InDoc[Key] = Value
|
||||||
|
if "Files" not in InDoc:
|
||||||
|
InDoc["Files"] = AoT([])
|
||||||
|
InPatterns = [str(Entry.get("Pattern")) for Entry in InDoc["Files"]]
|
||||||
|
# When the input is also an output and both use one config file, its
|
||||||
|
# [[Files]] are output rules too: an input-only entry (no NewFileName)
|
||||||
|
# would crash SaraSortd, so leave them alone.
|
||||||
|
SharedFile = self.SharedConfName() and InputPath in self.Outputs
|
||||||
|
if "*" not in InPatterns and not SharedFile:
|
||||||
|
for Pattern in Patterns:
|
||||||
|
if Pattern not in InPatterns:
|
||||||
|
Entry = tomlkit.table()
|
||||||
|
Entry["Pattern"] = Pattern
|
||||||
|
Entry["CaseSensitive"] = 0
|
||||||
|
InDoc["Files"].append(Entry)
|
||||||
|
WriteToml(self.ConfFile(InputPath, "In"), InDoc)
|
||||||
|
return Changed
|
||||||
|
|
||||||
|
def ConnectedInputs(self, OutputPath: str) -> list[str]:
|
||||||
|
OutConf = self.LoadDirConf(OutputPath, "Out") or {}
|
||||||
|
return [Input for Input in dict.fromkeys(self.Inputs) if self._DirAccepts(OutConf, OutputPath, Input)]
|
||||||
|
|
||||||
|
def RemoveRule(self, InputPath: str, OutputPath: str, Index: int) -> Optional[str]:
|
||||||
|
"""Stops the [[Files]] entry at Index taking files from InputPath, and
|
||||||
|
removes the entry once no input is left. Returns a message when that
|
||||||
|
can't be done exactly (a wildcard ValidInputDirs entry)."""
|
||||||
|
ConfPath = self.ConfFile(OutputPath, "Out")
|
||||||
|
Document = ReadToml(ConfPath)
|
||||||
|
Files = Document.get("Files")
|
||||||
|
if Files is None or Index >= len(Files):
|
||||||
|
return "That rule no longer exists."
|
||||||
|
Entry = Files[Index]
|
||||||
|
Own = Entry.get("ValidInputDirs")
|
||||||
|
Parent = os.path.basename(OutputPath)
|
||||||
|
if Own is None:
|
||||||
|
# It took files from every input: list the others explicitly.
|
||||||
|
Others = [Other for Other in self.ConnectedInputs(OutputPath) if Other != InputPath]
|
||||||
|
Entry["ValidInputDirs"] = [self.Shorthand(Other) for Other in Others]
|
||||||
|
Own = Entry["ValidInputDirs"]
|
||||||
|
else:
|
||||||
|
for Position in reversed(range(len(Own))):
|
||||||
|
if self.Parse(str(Own[Position]), Parent=Parent) == InputPath:
|
||||||
|
del Own[Position]
|
||||||
|
Message = None
|
||||||
|
if self._EntryAccepts(Entry.unwrap(), OutputPath, InputPath):
|
||||||
|
Message = f"{Entry.get('Pattern')} still matches through a wildcard in its ValidInputDirs; edit it in Configs."
|
||||||
|
if len(Own) == 0:
|
||||||
|
del Files[Index]
|
||||||
|
WriteToml(ConfPath, Document)
|
||||||
|
return Message
|
||||||
|
|
||||||
|
def Disconnect(self, InputPath: str, OutputPath: str) -> list[str]:
|
||||||
|
Messages = []
|
||||||
|
for FoundEdge in self.Edges():
|
||||||
|
if FoundEdge.Input == InputPath and FoundEdge.Output == OutputPath:
|
||||||
|
for Rule in sorted(FoundEdge.Rules, key=lambda Rule: Rule.Index, reverse=True):
|
||||||
|
Message = self.RemoveRule(InputPath, OutputPath, Rule.Index)
|
||||||
|
if Message:
|
||||||
|
Messages.append(Message)
|
||||||
|
return Messages
|
||||||
|
|
||||||
|
# What would happen to a file
|
||||||
|
|
||||||
|
def Simulate(self, InputPath: str, FileName: str) -> SimulationResult:
|
||||||
|
"""Follows Main() and DecideNewPath() for one file, without touching
|
||||||
|
anything (NextNum/NextChar aren't incremented)."""
|
||||||
|
Steps: list[str] = []
|
||||||
|
if FileName.startswith(".") and self.Data.get("DotFiles", 0) == 0:
|
||||||
|
return SimulationResult(["Dot files are skipped (DotFiles = 0)."], None, "Skipped")
|
||||||
|
InConf = self.LoadDirConf(InputPath, "In")
|
||||||
|
if InConf is None:
|
||||||
|
return SimulationResult(["The input's config can't be read."], None, "Skipped")
|
||||||
|
Parent = os.path.basename(InputPath)
|
||||||
|
Matched = False
|
||||||
|
for Entry in InConf.get("Files", []):
|
||||||
|
Pattern = self.Parse(str(Entry.get("Pattern", "")), Parent=Parent)
|
||||||
|
if Entry.get("CaseSensitive", 0) == 1:
|
||||||
|
Matched = fnmatch.fnmatchcase(FileName, Pattern)
|
||||||
|
else:
|
||||||
|
Matched = fnmatch.fnmatchcase(FileName.lower(), Pattern.lower())
|
||||||
|
if Matched:
|
||||||
|
Steps.append(f"The input lets it through ({Entry.get('Pattern')}).")
|
||||||
|
break
|
||||||
|
if not Matched:
|
||||||
|
Steps.append("None of the input's own patterns match, so it stays where it is.")
|
||||||
|
return SimulationResult(Steps, None, "Skipped")
|
||||||
|
|
||||||
|
for OutputPath in self.Outputs:
|
||||||
|
OutConf = self.LoadDirConf(OutputPath, "Out")
|
||||||
|
if OutConf is None:
|
||||||
|
Steps.append(f"{OutputPath}: its config can't be read, skipped.")
|
||||||
|
continue
|
||||||
|
if not self._DirAccepts(OutConf, OutputPath, InputPath):
|
||||||
|
Steps.append(f"{OutputPath}: its ValidInputDirs don't include this input.")
|
||||||
|
continue
|
||||||
|
Limit = OutConf.get("FileLimit", 0) or 0
|
||||||
|
if Limit > 0 and os.path.isdir(OutputPath):
|
||||||
|
Count = sum(
|
||||||
|
1 for Name in os.listdir(OutputPath)
|
||||||
|
if not Name.startswith(".") and os.path.isfile(os.path.join(OutputPath, Name))
|
||||||
|
)
|
||||||
|
if Count >= Limit:
|
||||||
|
Steps.append(f"{OutputPath}: full ({Count}/{Limit} files).")
|
||||||
|
continue
|
||||||
|
for Entry in OutConf.get("Files", []):
|
||||||
|
if not self._EntryAccepts(Entry, OutputPath, InputPath):
|
||||||
|
continue
|
||||||
|
Pattern = str(Entry.get("Pattern", ""))
|
||||||
|
if Entry.get("CaseSensitive", 0) == 1:
|
||||||
|
Match = fnmatch.fnmatchcase(FileName, Pattern)
|
||||||
|
else:
|
||||||
|
Match = fnmatch.fnmatch(FileName.lower(), Pattern.lower())
|
||||||
|
if not Match:
|
||||||
|
continue
|
||||||
|
NewName = self.PreviewName(str(Entry.get("NewFileName", "~^")), FileName, OutputPath, Entry, OutConf)
|
||||||
|
if Entry.get("Overwrite", 1) == 0 and os.path.exists(os.path.join(OutputPath, NewName)):
|
||||||
|
Steps.append(f"{OutputPath}: {Pattern} matches, but {NewName} exists and Overwrite = 0.")
|
||||||
|
continue
|
||||||
|
Action = "moved" if OutConf.get("DeleteOrg", 1) == 1 else "copied"
|
||||||
|
Steps.append(f"{OutputPath}: {Pattern} matches, so it's {Action} and named {NewName}.")
|
||||||
|
return SimulationResult(Steps, f"{OutputPath}/{NewName}", "Sorted")
|
||||||
|
Steps.append(f"{OutputPath}: no pattern matches.")
|
||||||
|
Failed = self.SpecialDir("FailedDir")
|
||||||
|
Steps.append(f"No output takes it, so it goes to {Failed}.")
|
||||||
|
return SimulationResult(Steps, f"{Failed}/{FileName}" if Failed else None, "Failed")
|
||||||
|
|
||||||
|
def PreviewName(
|
||||||
|
self, NewFileName: str, FileName: str, OutputPath: str, Entry: Optional[dict] = None,
|
||||||
|
OutConf: Optional[dict] = None,
|
||||||
|
) -> str:
|
||||||
|
Entry = Entry or {}
|
||||||
|
NextChar = str(Entry.get("NextChar", "A"))
|
||||||
|
# Init() pads NextChar to the longest run of the NextChar variable.
|
||||||
|
Character = self.Vars.get("NextChar", "&")
|
||||||
|
Runs = re.findall(f"{re.escape(Character)}+", NewFileName) if Character else []
|
||||||
|
Width = max((len(Run) for Run in Runs), default=0)
|
||||||
|
if Width:
|
||||||
|
NextChar = NextChar.upper().rjust(Width, "A")
|
||||||
|
return self.Parse(
|
||||||
|
NewFileName, ConfData=OutConf if OutConf is not None else {}, OrgFile=FileName,
|
||||||
|
NextNum=Entry.get("NextNum", "1"), NextChar=NextChar, Parent=os.path.basename(OutputPath),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Problems worth showing
|
||||||
|
|
||||||
|
def Warnings(self) -> list[tuple[str, str]]:
|
||||||
|
"""(Level, Text) pairs; Level is "Error", "Warning" or "Info"."""
|
||||||
|
Found: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def Walk(Data: Any, Where: str) -> None:
|
||||||
|
if isinstance(Data, dict):
|
||||||
|
for Key, Value in Data.items():
|
||||||
|
Walk(Value, f"{Where}{Key}" if not Where else f"{Where}.{Key}")
|
||||||
|
elif Data is None or Data == "Unset":
|
||||||
|
Found.append(("Error", f"GlobalConf.toml: {Where} is Unset. With SafeMode = 1 SaraSortd won't start."))
|
||||||
|
|
||||||
|
Walk(self.Data, "")
|
||||||
|
|
||||||
|
Daemon = self.DaemonVersion()
|
||||||
|
ConfVersion = str(self.Data.get("Version", "?"))
|
||||||
|
if Daemon is None:
|
||||||
|
Found.append(("Warning", f"Can't find SaraSortd.py at {self.Config.SortdScriptPath} (see Settings)."))
|
||||||
|
elif MajorVersion(Daemon) != MajorVersion(ConfVersion):
|
||||||
|
Found.append(("Error", f"GlobalConf.toml is version {ConfVersion}, but SaraSortd is {Daemon}: the first number differs, so the config won't work."))
|
||||||
|
|
||||||
|
Call = self.Vars.get("VarCall", "%%")
|
||||||
|
if len(Call) >= 2:
|
||||||
|
Leftover = re.compile(f"{re.escape(Call[0])}[^{re.escape(Call[1])}]+{re.escape(Call[1])}")
|
||||||
|
for Key in ("InputDir", "OutputDir"):
|
||||||
|
for Raw in self.DirList(Key):
|
||||||
|
if Leftover.search(self.ResolveDir(Raw)):
|
||||||
|
Found.append(("Error", f"{Raw} doesn't resolve (SaraSortd only replaces one level of variables)."))
|
||||||
|
|
||||||
|
Inputs = list(dict.fromkeys(self.Inputs))
|
||||||
|
Outputs = list(dict.fromkeys(self.Outputs))
|
||||||
|
Edges = self.Edges()
|
||||||
|
UsesOwnInputs = False
|
||||||
|
for DirPath in dict.fromkeys(Inputs + Outputs):
|
||||||
|
if not os.path.isdir(DirPath):
|
||||||
|
Found.append(("Info", f"{DirPath} doesn't exist yet; SaraSortd makes it when it starts."))
|
||||||
|
for Kind in (["In"] if DirPath in Inputs else []) + (["Out"] if DirPath in Outputs else []):
|
||||||
|
if os.path.isdir(DirPath) and not self.ConfFile(DirPath, Kind).exists():
|
||||||
|
Found.append(("Info", f"{DirPath} has no {'input' if Kind == 'In' else 'output'} config yet; SaraSortd copies the template when it starts."))
|
||||||
|
|
||||||
|
for InputPath in Inputs:
|
||||||
|
if not any(FoundEdge.Input == InputPath for FoundEdge in Edges):
|
||||||
|
Found.append(("Warning", f"{InputPath} has no outputs: every file it lets through goes to FailedDir."))
|
||||||
|
for OutputPath in Outputs:
|
||||||
|
OutConf = self.LoadDirConf(OutputPath, "Out")
|
||||||
|
if OutConf is None:
|
||||||
|
continue
|
||||||
|
if not any(FoundEdge.Output == OutputPath for FoundEdge in Edges):
|
||||||
|
Found.append(("Warning", f"{OutputPath} can't receive anything: no input matches its ValidInputDirs and patterns."))
|
||||||
|
if "*" in [str(Valid) for Valid in OutConf.get("ValidInputDirs", [])] and any(
|
||||||
|
"ValidInputDirs" not in Entry for Entry in OutConf.get("Files", [])
|
||||||
|
):
|
||||||
|
Found.append(("Info", f"{OutputPath} takes files from every input (ValidInputDirs = [\"*\"])."))
|
||||||
|
Entries = OutConf.get("Files", [])
|
||||||
|
for Position, Entry in enumerate(Entries):
|
||||||
|
UsesOwnInputs = UsesOwnInputs or "ValidInputDirs" in Entry
|
||||||
|
for Missing in ("Pattern", "NewFileName", "NextNum", "NextChar", "CaseSensitive", "Overwrite"):
|
||||||
|
if Missing not in Entry:
|
||||||
|
Found.append(("Error", f"{OutputPath}: [[Files]] #{Position + 1} has no {Missing}; SaraSortd will crash on it."))
|
||||||
|
if Entry.get("Pattern") == "*" and "ValidInputDirs" not in Entry and Position < len(Entries) - 1:
|
||||||
|
Found.append(("Warning", f"{OutputPath}: [[Files]] #{Position + 1} is a catch-all \"*\", so the entries after it never get a file."))
|
||||||
|
for InputPath in Inputs:
|
||||||
|
# SaraSortd tries outputs in OutputDir order and stops at the first match.
|
||||||
|
CatchAll = None
|
||||||
|
for FoundEdge in sorted(
|
||||||
|
(FoundEdge for FoundEdge in Edges if FoundEdge.Input == InputPath),
|
||||||
|
key=lambda FoundEdge: Outputs.index(FoundEdge.Output),
|
||||||
|
):
|
||||||
|
if CatchAll:
|
||||||
|
Found.append(("Warning", f"{CatchAll} takes every file from {InputPath} first, so {FoundEdge.Output} never gets any ({', '.join(FoundEdge.Patterns)})."))
|
||||||
|
elif "*" in FoundEdge.Patterns:
|
||||||
|
CatchAll = FoundEdge.Output
|
||||||
|
if UsesOwnInputs and Daemon and not VersionAtLeast(Daemon, (7, 1)):
|
||||||
|
Found.append(("Error", f"Some [[Files]] entries have their own ValidInputDirs, which SaraSortd {Daemon} ignores. Update SaraSortd to 7.1."))
|
||||||
|
|
||||||
|
for Raw in self.DirList("InputDir") + self.DirList("OutputDir"):
|
||||||
|
Odd = [Char for Char in (self.Vars.get(Key) for Key in ("NextNum", "NextChar", "Parent", "OrgFileName", "OrgFileType")) if Char and Char in Raw]
|
||||||
|
if Odd:
|
||||||
|
Found.append(("Warning", f"{Raw} contains {', '.join(Odd)}, which SaraSortd swaps for a variable."))
|
||||||
|
|
||||||
|
Order = {"Error": 0, "Warning": 1, "Info": 2}
|
||||||
|
return sorted(Found, key=lambda Item: Order[Item[0]])
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
"""Ready-made connections ("Pictures sorting" and so on): a list of patterns
|
||||||
|
plus a few settings, installed between an input and an output you pick.
|
||||||
|
|
||||||
|
Built-ins live here. Your own are TOML files in
|
||||||
|
~/.config/SaraSuite/SaraSort/Templates/, same keys as SortTemplate:
|
||||||
|
|
||||||
|
Name = "3D prints"
|
||||||
|
Description = "Models and slicer files."
|
||||||
|
Icon = "VIEW_IN_AR"
|
||||||
|
Patterns = ["*.stl", "*.3mf"]
|
||||||
|
NewFileName = "~^" # optional; OutputDirConf.toml's default otherwise
|
||||||
|
SuggestedOutput = "~/Prints" # optional
|
||||||
|
[OutputSettings] # optional, top-level keys of the output's config
|
||||||
|
DeleteOrg = 1
|
||||||
|
[InputSettings] # optional, top-level keys of the input's config
|
||||||
|
SortDirs = 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from Core import UserTemplatesDir
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SortTemplate:
|
||||||
|
Name: str
|
||||||
|
Description: str = ""
|
||||||
|
Icon: str = "FOLDER_COPY_OUTLINED"
|
||||||
|
Patterns: list[str] = field(default_factory=list)
|
||||||
|
NewFileName: str = "" # empty: OutputDirConf.toml's default
|
||||||
|
SuggestedOutput: str = ""
|
||||||
|
OutputSettings: dict = field(default_factory=dict)
|
||||||
|
InputSettings: dict = field(default_factory=dict)
|
||||||
|
FilePath: Optional[Path] = None # set for your own templates
|
||||||
|
|
||||||
|
@property
|
||||||
|
def BuiltIn(self) -> bool:
|
||||||
|
return self.FilePath is None
|
||||||
|
|
||||||
|
|
||||||
|
BuiltInTemplates = [
|
||||||
|
SortTemplate(
|
||||||
|
"Pictures", "Photos, screenshots and other images.", "IMAGE_OUTLINED",
|
||||||
|
["*.jp*g", "*.png", "*.gif", "*.webp", "*.bmp", "*.svg", "*.heic", "*.tif*", "*.avif"],
|
||||||
|
SuggestedOutput="~/Pictures",
|
||||||
|
),
|
||||||
|
SortTemplate(
|
||||||
|
"Videos", "Video files.", "MOVIE_OUTLINED",
|
||||||
|
["*.mp4", "*.mkv", "*.webm", "*.mov", "*.avi", "*.m4v"],
|
||||||
|
SuggestedOutput="~/Videos",
|
||||||
|
),
|
||||||
|
SortTemplate(
|
||||||
|
"Music", "Audio files.", "MUSIC_NOTE_OUTLINED",
|
||||||
|
["*.mp3", "*.flac", "*.ogg", "*.opus", "*.wav", "*.m4a", "*.aac"],
|
||||||
|
SuggestedOutput="~/Music",
|
||||||
|
),
|
||||||
|
SortTemplate(
|
||||||
|
"Documents", "PDFs, office files and text.", "DESCRIPTION_OUTLINED",
|
||||||
|
["*.pdf", "*.doc*", "*.odt", "*.rtf", "*.txt", "*.md", "*.xls*", "*.ods", "*.csv", "*.ppt*", "*.odp", "*.epub"],
|
||||||
|
SuggestedOutput="~/Documents",
|
||||||
|
),
|
||||||
|
SortTemplate(
|
||||||
|
"Archives (unpacked)", "Archives, unpacked in the output and the archive removed.", "FOLDER_ZIP_OUTLINED",
|
||||||
|
["*.zip", "*.tar", "*.tar.gz", "*.tgz", "*.tar.xz", "*.tar.bz2"],
|
||||||
|
OutputSettings={"Unzip": ["zip", "tar", "tar.gz", "tgz", "tar.xz", "tar.bz2"]},
|
||||||
|
),
|
||||||
|
SortTemplate(
|
||||||
|
"Git repositories", "Folders (e.g. cloned repos) are zipped by the input, moved and unzipped in the output.",
|
||||||
|
"ACCOUNT_TREE_OUTLINED", ["*.zip"],
|
||||||
|
SuggestedOutput="~/Applications",
|
||||||
|
OutputSettings={"Unzip": ["zip"]},
|
||||||
|
InputSettings={"SortDirs": 1},
|
||||||
|
),
|
||||||
|
SortTemplate(
|
||||||
|
"Logs", "Log files, renamed with today's date in front.", "RECEIPT_LONG_OUTLINED",
|
||||||
|
["*.log", "*.log.*"], NewFileName="[y].[m].[d]_~^",
|
||||||
|
),
|
||||||
|
SortTemplate(
|
||||||
|
"3D printing", "Models, OpenSCAD files and slicer output.", "VIEW_IN_AR_OUTLINED",
|
||||||
|
["*.stl", "*.3mf", "*.scad", "*.step", "*.stp", "*.obj", "*.gcode", "*.bgcode"],
|
||||||
|
),
|
||||||
|
SortTemplate(
|
||||||
|
"Installers", "AppImages, packages and Flatpak refs.", "INSTALL_DESKTOP_OUTLINED",
|
||||||
|
["*.AppImage", "*.rpm", "*.deb", "*.flatpak", "*.flatpakref"],
|
||||||
|
),
|
||||||
|
SortTemplate(
|
||||||
|
"Backup copy", "Copies everything instead of moving it (DeleteOrg = 0).", "CONTENT_COPY_OUTLINED",
|
||||||
|
["*"], OutputSettings={"DeleteOrg": 0},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def UserTemplates() -> list[SortTemplate]:
|
||||||
|
if not UserTemplatesDir.is_dir():
|
||||||
|
return []
|
||||||
|
Found = []
|
||||||
|
Known = set(SortTemplate.__dataclass_fields__) - {"FilePath"}
|
||||||
|
for FilePath in sorted(UserTemplatesDir.glob("*.toml")):
|
||||||
|
try:
|
||||||
|
Data = tomlkit.parse(FilePath.read_text()).unwrap()
|
||||||
|
except (OSError, tomlkit.exceptions.TOMLKitError):
|
||||||
|
continue
|
||||||
|
Values = {Key: Value for Key, Value in Data.items() if Key in Known}
|
||||||
|
Values.setdefault("Name", FilePath.stem)
|
||||||
|
Found.append(SortTemplate(**Values, FilePath=FilePath))
|
||||||
|
return Found
|
||||||
|
|
||||||
|
|
||||||
|
def AllTemplates() -> list[SortTemplate]:
|
||||||
|
return BuiltInTemplates + UserTemplates()
|
||||||
|
|
||||||
|
|
||||||
|
def SaveUserTemplate(Template: SortTemplate) -> Path:
|
||||||
|
UserTemplatesDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
FileName = re.sub(r"[^A-Za-z0-9]+", "", Template.Name.title()) or "Template"
|
||||||
|
FilePath = UserTemplatesDir / f"{FileName}.toml"
|
||||||
|
Data = {Key: Value for Key, Value in asdict(Template).items() if Key != "FilePath" and Value not in ("", {}, [])}
|
||||||
|
Data["Patterns"] = Template.Patterns
|
||||||
|
FilePath.write_text(tomlkit.dumps(Data))
|
||||||
|
return FilePath
|
||||||
|
|
||||||
|
|
||||||
|
def DeleteUserTemplate(Template: SortTemplate) -> None:
|
||||||
|
if Template.FilePath:
|
||||||
|
Template.FilePath.unlink(missing_ok=True)
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Ready-made connections to install between an input and an output you pick,
|
||||||
|
plus your own templates (saved from an arrow, or made here)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from Core import ChipButton, DisplayPath, Heading, Note, Palette
|
||||||
|
from Templates import AllTemplates, DeleteUserTemplate, SaveUserTemplate, SortTemplate
|
||||||
|
from Widgets import App, DirField, ExpandPath, NameField
|
||||||
|
|
||||||
|
CardWidth = 300
|
||||||
|
|
||||||
|
|
||||||
|
class TemplatesPage(ft.Column):
|
||||||
|
def __init__(self, TheApp: App):
|
||||||
|
super().__init__(expand=True, spacing=0)
|
||||||
|
self.App = TheApp
|
||||||
|
self.Grid = ft.Row(wrap=True, spacing=12, run_spacing=12, vertical_alignment=ft.CrossAxisAlignment.START)
|
||||||
|
self.controls = [
|
||||||
|
ft.Container(
|
||||||
|
ft.Row([
|
||||||
|
Heading("Templates"), ft.Container(expand=True),
|
||||||
|
ChipButton("New template", lambda e: self._EditDialog(None), ft.Icons.ADD),
|
||||||
|
]),
|
||||||
|
padding=ft.Padding.only(left=16, right=16, top=12, bottom=4),
|
||||||
|
),
|
||||||
|
ft.Container(Note("Pick one, choose where files come from and where they go. "
|
||||||
|
"Your own templates live in ~/.config/SaraSuite/SaraSort/Templates/."),
|
||||||
|
padding=ft.Padding.only(left=16, bottom=8)),
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
ft.Container(ft.Column([self.Grid], scroll=ft.ScrollMode.AUTO, expand=True), padding=16, expand=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
def Refresh(self) -> None:
|
||||||
|
self.Grid.controls = [self._Card(Template) for Template in AllTemplates()]
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _Card(self, Template: SortTemplate) -> ft.Container:
|
||||||
|
Icon = getattr(ft.Icons, Template.Icon, ft.Icons.FOLDER_COPY_OUTLINED)
|
||||||
|
Patterns = ", ".join(Template.Patterns)
|
||||||
|
Extras = []
|
||||||
|
if Template.OutputSettings.get("Unzip"):
|
||||||
|
Extras.append("unpacks archives")
|
||||||
|
if Template.OutputSettings.get("DeleteOrg") == 0:
|
||||||
|
Extras.append("copies")
|
||||||
|
if Template.InputSettings.get("SortDirs"):
|
||||||
|
Extras.append("sorts folders")
|
||||||
|
if Template.NewFileName:
|
||||||
|
Extras.append(f"renames to {Template.NewFileName}")
|
||||||
|
Buttons = [ChipButton("Install", lambda e: self._InstallDialog(Template), ft.Icons.DOWNLOAD_OUTLINED)]
|
||||||
|
if not Template.BuiltIn:
|
||||||
|
Buttons += [
|
||||||
|
ft.IconButton(ft.Icons.EDIT_OUTLINED, tooltip="Edit", on_click=lambda e: self._EditDialog(Template)),
|
||||||
|
ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_color=Palette.Red, tooltip="Delete template",
|
||||||
|
on_click=lambda e: self.App.Confirm("Delete template", f"Delete \"{Template.Name}\"?",
|
||||||
|
lambda: (DeleteUserTemplate(Template), self.Refresh()), "Delete")),
|
||||||
|
]
|
||||||
|
return ft.Container(
|
||||||
|
ft.Column([
|
||||||
|
ft.Row([ft.Icon(Icon, color=Palette.Mauve), ft.Text(Template.Name, size=16, expand=True),
|
||||||
|
Note("built-in" if Template.BuiltIn else "yours", 11)]),
|
||||||
|
ft.Text(Template.Description or " ", size=12),
|
||||||
|
ft.Text(Patterns, size=12, color=Palette.Mauve, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS, tooltip=Patterns),
|
||||||
|
Note(", ".join(Extras), 11) if Extras else ft.Container(height=0),
|
||||||
|
ft.Row(Buttons),
|
||||||
|
], spacing=8),
|
||||||
|
width=CardWidth, padding=14, bgcolor=Palette.Mantle, border=ft.Border.all(1, Palette.Surface1), border_radius=6,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _InstallDialog(self, Template: SortTemplate) -> None:
|
||||||
|
TheSetup, Error = self.App.LoadSetup()
|
||||||
|
if TheSetup is None:
|
||||||
|
self.App.Notify(Error, "error")
|
||||||
|
return
|
||||||
|
Root = TheSetup.RootDir() or os.path.expanduser("~")
|
||||||
|
DefaultInput = next(iter(TheSetup.Inputs), f"{Root}/Input")
|
||||||
|
DefaultOutput = ExpandPath(Template.SuggestedOutput) if Template.SuggestedOutput else f"{Root}/{Template.Name.split(' ')[0]}"
|
||||||
|
InputRow, InputField = DirField(self.App, "Input (files come from)", DefaultInput)
|
||||||
|
OutputRow, OutputField = DirField(self.App, "Output (files go to)", DefaultOutput)
|
||||||
|
Inputs = list(dict.fromkeys(TheSetup.Inputs))
|
||||||
|
Quick = ft.Row([
|
||||||
|
ft.Container(ft.Text(DisplayPath(PathText), size=11, color=Palette.Red), padding=ft.Padding.symmetric(horizontal=6, vertical=2),
|
||||||
|
border=ft.Border.all(1, Palette.Surface1), border_radius=4,
|
||||||
|
on_click=lambda e, PathText=PathText: (setattr(InputField, "value", PathText), InputField.update()))
|
||||||
|
for PathText in Inputs
|
||||||
|
], wrap=True, spacing=4)
|
||||||
|
PatternField = ft.TextField(label="Patterns", value=", ".join(Template.Patterns), dense=True, multiline=True, max_lines=3)
|
||||||
|
Defaults = TheSetup.TemplateFileDefaults()
|
||||||
|
Name = NameField(TheSetup, Template.NewFileName or str(Defaults.get("NewFileName", "~^")), DefaultOutput, Defaults)
|
||||||
|
Move = ft.Switch(label="Move files (off: copy them)", value=Template.OutputSettings.get("DeleteOrg", 1) == 1)
|
||||||
|
|
||||||
|
def Install(e) -> None:
|
||||||
|
InputPath, OutputPath = ExpandPath(InputField.value or ""), ExpandPath(OutputField.value or "")
|
||||||
|
Patterns = [Part.strip() for Part in (PatternField.value or "").split(",") if Part.strip()]
|
||||||
|
if not InputPath or not OutputPath or not Patterns:
|
||||||
|
self.App.Notify("Pick an input, an output and at least one pattern.", "warning")
|
||||||
|
return
|
||||||
|
if InputPath == OutputPath:
|
||||||
|
self.App.Notify("Input and output are the same directory.", "warning")
|
||||||
|
return
|
||||||
|
self.App.Close()
|
||||||
|
OutputSettings = dict(Template.OutputSettings)
|
||||||
|
OutputSettings["DeleteOrg"] = 1 if Move.value else 0
|
||||||
|
Overrides = {}
|
||||||
|
if Name.value and Name.value != Defaults.get("NewFileName"):
|
||||||
|
Overrides["NewFileName"] = Name.value
|
||||||
|
try:
|
||||||
|
Changed = TheSetup.Connect(InputPath, OutputPath, Patterns, Overrides, OutputSettings, dict(Template.InputSettings))
|
||||||
|
except Exception as Error:
|
||||||
|
self.App.Notify(f"Couldn't install: {Error}", "error")
|
||||||
|
return
|
||||||
|
if Changed:
|
||||||
|
self.App.AfterGlobalChange()
|
||||||
|
else:
|
||||||
|
self.App.Notify(f"Installed {Template.Name}.", "success")
|
||||||
|
self.App.Navigate("Flow")
|
||||||
|
|
||||||
|
Notes = []
|
||||||
|
if Template.InputSettings.get("SortDirs"):
|
||||||
|
Notes.append(Note("Turns on SortDirs for the input: folders dropped there are zipped and sorted too."))
|
||||||
|
if Template.OutputSettings.get("Unzip"):
|
||||||
|
Notes.append(Note(f"The output unpacks {', '.join(Template.OutputSettings['Unzip'])} files and removes the archive."))
|
||||||
|
self.App.ShowDialog(f"Install: {Template.Name}", ft.Column([
|
||||||
|
InputRow, Quick, OutputRow, PatternField, Name, Move, *Notes,
|
||||||
|
], tight=True, spacing=10, scroll=ft.ScrollMode.AUTO), [
|
||||||
|
ChipButton("Cancel", lambda e: self.App.Close()), ChipButton("Install", Install),
|
||||||
|
], Width=640)
|
||||||
|
|
||||||
|
def _EditDialog(self, Template: SortTemplate | None) -> None:
|
||||||
|
TheSetup, _ = self.App.LoadSetup()
|
||||||
|
NameInput = ft.TextField(label="Name", value=Template.Name if Template else "", dense=True, autofocus=True)
|
||||||
|
Description = ft.TextField(label="Description", value=Template.Description if Template else "", dense=True)
|
||||||
|
PatternField = ft.TextField(label="Patterns, separated by commas", value=", ".join(Template.Patterns) if Template else "",
|
||||||
|
dense=True, multiline=True, max_lines=3)
|
||||||
|
Suggested = ft.TextField(label="Suggested output (optional)", value=Template.SuggestedOutput if Template else "", dense=True)
|
||||||
|
NewName = NameField(TheSetup, Template.NewFileName if Template else "", "/Output", Label="NewFileName (empty: the default)")
|
||||||
|
Move = ft.Switch(label="Move files (off: copy them)", value=(Template.OutputSettings.get("DeleteOrg", 1) if Template else 1) == 1)
|
||||||
|
Unzip = ft.TextField(label="Unzip these types (optional, commas)", dense=True,
|
||||||
|
value=", ".join(Template.OutputSettings.get("Unzip", [])) if Template else "")
|
||||||
|
SortDirs = ft.Switch(label="Input also sorts folders (SortDirs)", value=bool(Template and Template.InputSettings.get("SortDirs")))
|
||||||
|
|
||||||
|
def Save(e) -> None:
|
||||||
|
if not (NameInput.value or "").strip():
|
||||||
|
return
|
||||||
|
self.App.Close()
|
||||||
|
OutputSettings = dict(Template.OutputSettings) if Template else {}
|
||||||
|
OutputSettings["DeleteOrg"] = 1 if Move.value else 0
|
||||||
|
UnzipTypes = [Part.strip() for Part in (Unzip.value or "").split(",") if Part.strip()]
|
||||||
|
if UnzipTypes:
|
||||||
|
OutputSettings["Unzip"] = UnzipTypes
|
||||||
|
else:
|
||||||
|
OutputSettings.pop("Unzip", None)
|
||||||
|
New = SortTemplate(
|
||||||
|
NameInput.value.strip(), Description.value or "", Template.Icon if Template else "BOOKMARK_OUTLINED",
|
||||||
|
[Part.strip() for Part in (PatternField.value or "").split(",") if Part.strip()],
|
||||||
|
NewName.value, Suggested.value or "", OutputSettings, {"SortDirs": 1} if SortDirs.value else {},
|
||||||
|
)
|
||||||
|
if Template and Template.FilePath:
|
||||||
|
DeleteUserTemplate(Template)
|
||||||
|
SaveUserTemplate(New)
|
||||||
|
self.Refresh()
|
||||||
|
|
||||||
|
self.App.ShowDialog("Edit template" if Template else "New template", ft.Column([
|
||||||
|
NameInput, Description, PatternField, Suggested, NewName, Move, Unzip, SortDirs,
|
||||||
|
], tight=True, spacing=10, scroll=ft.ScrollMode.AUTO), [
|
||||||
|
ChipButton("Cancel", lambda e: self.App.Close()), ChipButton("Save", Save),
|
||||||
|
], Width=600)
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
"""Tools: warnings about the current setup, a filename tester that shows where
|
||||||
|
a file would go, and the files that failed to sort (with a retry)."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from Core import ChipButton, ChipButtonStyle, DisplayPath, Heading, Note, Palette
|
||||||
|
from Sortd import Setup
|
||||||
|
from Widgets import App, EmptyState
|
||||||
|
|
||||||
|
LevelColors = {"Error": Palette.Red, "Warning": Palette.Yellow, "Info": Palette.Blue}
|
||||||
|
LevelIcons = {"Error": ft.Icons.ERROR_OUTLINE, "Warning": ft.Icons.WARNING_AMBER, "Info": ft.Icons.INFO_OUTLINE}
|
||||||
|
|
||||||
|
|
||||||
|
def HumanSize(Size: int) -> str:
|
||||||
|
for Unit in ("B", "KB", "MB", "GB"):
|
||||||
|
if Size < 1024 or Unit == "GB":
|
||||||
|
return f"{Size:.0f} {Unit}" if Unit == "B" else f"{Size:.1f} {Unit}"
|
||||||
|
Size /= 1024
|
||||||
|
return str(Size)
|
||||||
|
|
||||||
|
|
||||||
|
def FreeName(Folder: Path, Name: str) -> Path:
|
||||||
|
"""Folder/Name, or Folder/"Name (2)" etc. if that's taken."""
|
||||||
|
Target = Folder / Name
|
||||||
|
Stem, Suffix = os.path.splitext(Name)
|
||||||
|
Count = 2
|
||||||
|
while Target.exists():
|
||||||
|
Target = Folder / f"{Stem} ({Count}){Suffix}"
|
||||||
|
Count += 1
|
||||||
|
return Target
|
||||||
|
|
||||||
|
|
||||||
|
class ToolsPage(ft.Column):
|
||||||
|
def __init__(self, TheApp: App):
|
||||||
|
super().__init__(expand=True, spacing=0)
|
||||||
|
self.App = TheApp
|
||||||
|
self.Section = "Warnings"
|
||||||
|
self.TheSetup: Setup | None = None
|
||||||
|
self.Buttons = {Name: ChipButton(Name, lambda e, Name=Name: self._Show(Name))
|
||||||
|
for Name in ("Warnings", "Tester", "Failed files")}
|
||||||
|
self.Body = ft.Container(expand=True, padding=16)
|
||||||
|
|
||||||
|
# Tester
|
||||||
|
self.TestInput = ft.Dropdown(label="Input directory", dense=True, width=360)
|
||||||
|
self.TestName = ft.TextField(label="File name", value="Holiday photo.png", dense=True, expand=True,
|
||||||
|
on_submit=lambda e: self._RunTest())
|
||||||
|
self.TestResult = ft.Column(spacing=6)
|
||||||
|
|
||||||
|
# Failed files
|
||||||
|
self.RetryInput = ft.Dropdown(label="Retry into", dense=True, width=360)
|
||||||
|
self.FailedList = ft.ListView(spacing=2, expand=True)
|
||||||
|
|
||||||
|
self.controls = [
|
||||||
|
ft.Container(
|
||||||
|
ft.Row([Heading("Tools"), ft.Container(width=12), *self.Buttons.values(), ft.Container(expand=True),
|
||||||
|
ChipButton("Reload", lambda e: self.Refresh(), ft.Icons.REFRESH)]),
|
||||||
|
padding=ft.Padding.only(left=16, right=16, top=12, bottom=8),
|
||||||
|
),
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
self.Body,
|
||||||
|
]
|
||||||
|
|
||||||
|
def Refresh(self) -> None:
|
||||||
|
self.TheSetup, Error = self.App.LoadSetup()
|
||||||
|
if self.TheSetup is None:
|
||||||
|
self.Body.content = EmptyState("Can't read SaraSortd's config.", Error)
|
||||||
|
self.update()
|
||||||
|
return
|
||||||
|
Inputs = list(dict.fromkeys(self.TheSetup.Inputs))
|
||||||
|
for Picker in (self.TestInput, self.RetryInput):
|
||||||
|
Current = Picker.value
|
||||||
|
Picker.options = [ft.DropdownOption(key=PathText, text=DisplayPath(PathText)) for PathText in Inputs]
|
||||||
|
Picker.value = Current if Current in Inputs else (Inputs[0] if Inputs else None)
|
||||||
|
self._Show(self.Section)
|
||||||
|
|
||||||
|
def _Show(self, Name: str) -> None:
|
||||||
|
self.Section = Name
|
||||||
|
for ButtonName, Button in self.Buttons.items():
|
||||||
|
Button.style = ChipButtonStyle(Palette.Mauve if ButtonName == Name else Palette.Surface1)
|
||||||
|
if self.TheSetup is not None:
|
||||||
|
self.Body.content = {"Warnings": self._Warnings, "Tester": self._Tester, "Failed files": self._Failed}[Name]()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# Warnings
|
||||||
|
|
||||||
|
def _Warnings(self) -> ft.Control:
|
||||||
|
Found = self.TheSetup.Warnings()
|
||||||
|
if not Found:
|
||||||
|
return EmptyState("Nothing to warn about.", "SaraSortd's setup looks fine.")
|
||||||
|
Counts = {Level: sum(1 for Item in Found if Item[0] == Level) for Level in LevelColors}
|
||||||
|
return ft.Column([
|
||||||
|
Note(f"{Counts['Error']} errors, {Counts['Warning']} warnings, {Counts['Info']} notes"),
|
||||||
|
ft.ListView([
|
||||||
|
ft.Container(
|
||||||
|
ft.Row([
|
||||||
|
ft.Icon(LevelIcons[Level], color=LevelColors[Level], size=18),
|
||||||
|
ft.Text(Text, size=13, selectable=True, expand=True),
|
||||||
|
], vertical_alignment=ft.CrossAxisAlignment.START),
|
||||||
|
padding=10, bgcolor=Palette.Mantle, border=ft.Border.only(left=ft.BorderSide(3, LevelColors[Level])),
|
||||||
|
border_radius=4,
|
||||||
|
)
|
||||||
|
for Level, Text in Found
|
||||||
|
], spacing=6, expand=True),
|
||||||
|
], expand=True)
|
||||||
|
|
||||||
|
# Tester
|
||||||
|
|
||||||
|
def _Tester(self) -> ft.Control:
|
||||||
|
return ft.Column([
|
||||||
|
Note("Where would a file go? Follows SaraSortd's own rules without moving anything."),
|
||||||
|
ft.Row([self.TestInput, self.TestName, ChipButton("Test", lambda e: self._RunTest(), ft.Icons.PLAY_ARROW)]),
|
||||||
|
ft.Divider(height=1, color=Palette.Surface1),
|
||||||
|
ft.Column([self.TestResult], scroll=ft.ScrollMode.AUTO, expand=True),
|
||||||
|
], expand=True, spacing=10)
|
||||||
|
|
||||||
|
def _RunTest(self) -> None:
|
||||||
|
if not self.TestInput.value or not (self.TestName.value or "").strip():
|
||||||
|
return
|
||||||
|
Result = self.TheSetup.Simulate(self.TestInput.value, self.TestName.value.strip())
|
||||||
|
Color = {"Sorted": Palette.Green, "Failed": Palette.Red, "Skipped": Palette.Yellow}[Result.Outcome]
|
||||||
|
self.TestResult.controls = [
|
||||||
|
ft.Text({"Sorted": "Sorted to", "Failed": "Fails, goes to", "Skipped": "Not sorted"}[Result.Outcome], color=Color, size=16),
|
||||||
|
ft.Text(DisplayPath(Result.Destination) if Result.Destination else "-", selectable=True, size=15),
|
||||||
|
ft.Container(height=6),
|
||||||
|
*[ft.Text(f"{Index + 1}. {Step}", size=12, color=Palette.Overlay0, selectable=True) for Index, Step in enumerate(Result.Steps)],
|
||||||
|
]
|
||||||
|
self.TestResult.update()
|
||||||
|
|
||||||
|
# Failed files
|
||||||
|
|
||||||
|
def _FailedDir(self) -> Path | None:
|
||||||
|
return self.TheSetup.SpecialDir("FailedDir")
|
||||||
|
|
||||||
|
def _Failed(self) -> ft.Control:
|
||||||
|
FailedDir = self._FailedDir()
|
||||||
|
Files = sorted((Item for Item in FailedDir.iterdir() if not Item.name.startswith(".")),
|
||||||
|
key=lambda Item: Item.stat().st_mtime, reverse=True) if FailedDir and FailedDir.is_dir() else []
|
||||||
|
Rows = []
|
||||||
|
for Item in Files:
|
||||||
|
Stat = Item.stat()
|
||||||
|
Rows.append(ft.Container(ft.Row([
|
||||||
|
ft.Icon(ft.Icons.FOLDER_OUTLINED if Item.is_dir() else ft.Icons.INSERT_DRIVE_FILE_OUTLINED, size=18, color=Palette.Overlay0),
|
||||||
|
ft.Text(Item.name, size=13, expand=True, selectable=True, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS),
|
||||||
|
Note(HumanSize(Stat.st_size) if Item.is_file() else "folder", 11),
|
||||||
|
Note(datetime.datetime.fromtimestamp(Stat.st_mtime).strftime("%Y-%m-%d %H:%M"), 11),
|
||||||
|
ft.IconButton(ft.Icons.TROUBLESHOOT, tooltip="Test where it would go now",
|
||||||
|
on_click=lambda e, Name=Item.name: self._TestFailed(Name)),
|
||||||
|
ft.IconButton(ft.Icons.REPLAY, tooltip="Move back into the input to retry",
|
||||||
|
on_click=lambda e, Item=Item: self._Retry([Item])),
|
||||||
|
]), padding=ft.Padding.symmetric(horizontal=8), bgcolor=Palette.Mantle, border_radius=4))
|
||||||
|
self.FailedList.controls = Rows or [Note("No failed files.")]
|
||||||
|
return ft.Column([
|
||||||
|
ft.Row([
|
||||||
|
ft.Text(DisplayPath(str(FailedDir)) if FailedDir else "No FailedDir set", color=Palette.Mauve, expand=True),
|
||||||
|
self.RetryInput,
|
||||||
|
ChipButton("Retry all", lambda e: self._Retry(Files), ft.Icons.REPLAY, Disabled=not Files),
|
||||||
|
ChipButton("Open folder", lambda e: self.App.OpenFolder(str(FailedDir)), ft.Icons.FOLDER_OPEN_OUTLINED,
|
||||||
|
Disabled=not (FailedDir and FailedDir.is_dir())),
|
||||||
|
]),
|
||||||
|
Note("Retrying moves files back into the input; if no output takes them, SaraSortd puts them back here. "
|
||||||
|
"Fix the arrows first (the test button shows what would happen)."),
|
||||||
|
self.FailedList,
|
||||||
|
], expand=True, spacing=10)
|
||||||
|
|
||||||
|
def _TestFailed(self, Name: str) -> None:
|
||||||
|
self.TestName.value = Name
|
||||||
|
if self.RetryInput.value:
|
||||||
|
self.TestInput.value = self.RetryInput.value
|
||||||
|
self._Show("Tester")
|
||||||
|
self._RunTest()
|
||||||
|
|
||||||
|
def _Retry(self, Items: list[Path]) -> None:
|
||||||
|
Target = self.RetryInput.value
|
||||||
|
if not Target:
|
||||||
|
self.App.Notify("Pick an input to retry into.", "warning")
|
||||||
|
return
|
||||||
|
Moved = 0
|
||||||
|
for Item in Items:
|
||||||
|
try:
|
||||||
|
shutil.move(str(Item), str(FreeName(Path(Target), Item.name)))
|
||||||
|
Moved += 1
|
||||||
|
except OSError as Error:
|
||||||
|
self.App.Notify(f"Couldn't move {Item.name}: {Error}", "error")
|
||||||
|
if Moved:
|
||||||
|
self.App.Notify(f"Moved {Moved} file{'s' if Moved != 1 else ''} back into {DisplayPath(Target)}.", "success")
|
||||||
|
self._Show("Failed files")
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
"""What every page shares: the App context (config, notifications, reloading
|
||||||
|
SaraSortd's setup, restarting it after changes) and small reusable controls."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from Core import AppConfig, ChildEnvironment, ChipButton, Note, Palette
|
||||||
|
from Service import RestartIfRunning
|
||||||
|
from Sortd import Setup, SortdError
|
||||||
|
|
||||||
|
|
||||||
|
class App:
|
||||||
|
def __init__(self, Page: ft.Page, Config: AppConfig):
|
||||||
|
self.Page = Page
|
||||||
|
self.Config = Config
|
||||||
|
self.Picker = ft.FilePicker() # registers itself with the page
|
||||||
|
self.Navigate: Callable[..., None] = lambda Name, **Arguments: None # set by Main
|
||||||
|
|
||||||
|
def Notify(self, Text: str, Kind: str = "info") -> None:
|
||||||
|
Color = {"success": Palette.Green, "error": Palette.Red, "warning": Palette.Yellow}.get(Kind, Palette.Text)
|
||||||
|
self.Page.show_dialog(ft.SnackBar(
|
||||||
|
ft.Text(Text, color=Color), bgcolor=Palette.Surface0, duration=ft.Duration(seconds=4),
|
||||||
|
))
|
||||||
|
|
||||||
|
def LoadSetup(self) -> tuple[Optional[Setup], str]:
|
||||||
|
try:
|
||||||
|
return Setup(self.Config), ""
|
||||||
|
except SortdError as Error:
|
||||||
|
return None, str(Error)
|
||||||
|
|
||||||
|
def AfterGlobalChange(self) -> None:
|
||||||
|
"""GlobalConf.toml changed; SaraSortd only reads it when it starts."""
|
||||||
|
if not self.Config.AutoRestart:
|
||||||
|
self.Notify("Saved. Restart SaraSortd (Service page) for it to take effect.", "warning")
|
||||||
|
return
|
||||||
|
Error = RestartIfRunning(self.Config.ServiceName)
|
||||||
|
if Error:
|
||||||
|
self.Notify(f"Saved, but restarting SaraSortd failed: {Error}", "error")
|
||||||
|
else:
|
||||||
|
self.Notify("Saved; SaraSortd restarted.", "success")
|
||||||
|
|
||||||
|
def ShowDialog(self, Title: str, Content: ft.Control, Actions: list[ft.Control], Width: int = 560) -> ft.AlertDialog:
|
||||||
|
Dialog = ft.AlertDialog(
|
||||||
|
title=Title,
|
||||||
|
content=ft.Container(Content, width=Width),
|
||||||
|
actions=Actions,
|
||||||
|
)
|
||||||
|
self.Page.show_dialog(Dialog)
|
||||||
|
return Dialog
|
||||||
|
|
||||||
|
def Close(self) -> None:
|
||||||
|
self.Page.pop_dialog()
|
||||||
|
|
||||||
|
def Confirm(self, Title: str, Text: str, OnYes: Callable[[], None], YesText: str = "Remove") -> None:
|
||||||
|
def Yes(e) -> None:
|
||||||
|
self.Close()
|
||||||
|
OnYes()
|
||||||
|
|
||||||
|
self.ShowDialog(Title, ft.Text(Text), [
|
||||||
|
ChipButton("Cancel", lambda e: self.Close()),
|
||||||
|
ChipButton(YesText, Yes, Accent=Palette.Red),
|
||||||
|
], Width=440)
|
||||||
|
|
||||||
|
def OpenFolder(self, PathText: str) -> None:
|
||||||
|
try:
|
||||||
|
subprocess.Popen(["xdg-open", PathText], env=ChildEnvironment(), start_new_session=True)
|
||||||
|
except OSError as Error:
|
||||||
|
self.Notify(f"Couldn't open {PathText}: {Error}", "error")
|
||||||
|
|
||||||
|
|
||||||
|
def DirField(TheApp: App, Label: str, Value: str = "", Expand: bool = True) -> tuple[ft.Row, ft.TextField]:
|
||||||
|
"""A path field with a Browse button. Returns (row, field)."""
|
||||||
|
Field = ft.TextField(label=Label, value=Value, expand=Expand, dense=True)
|
||||||
|
|
||||||
|
async def Browse(e) -> None:
|
||||||
|
Chosen = await TheApp.Picker.get_directory_path(dialog_title=Label, initial_directory=Field.value or None)
|
||||||
|
if Chosen:
|
||||||
|
Field.value = Chosen
|
||||||
|
Field.update()
|
||||||
|
|
||||||
|
return ft.Row([Field, ft.IconButton(ft.Icons.FOLDER_OPEN_OUTLINED, tooltip="Browse", on_click=Browse)]), Field
|
||||||
|
|
||||||
|
|
||||||
|
def ExpandPath(Text: str) -> str:
|
||||||
|
return os.path.abspath(os.path.expanduser(Text.strip())).rstrip("/") if Text.strip() else ""
|
||||||
|
|
||||||
|
|
||||||
|
class NameField(ft.Column):
|
||||||
|
"""A NewFileName field with buttons that insert SaraSortd's variables and
|
||||||
|
a live example of the resulting name."""
|
||||||
|
|
||||||
|
def __init__(self, TheSetup: Optional[Setup], Value: str, OutputPath: str = "", Entry: Optional[dict] = None,
|
||||||
|
Label: str = "NewFileName", OnChange: Optional[Callable[[str], None]] = None):
|
||||||
|
super().__init__(spacing=4)
|
||||||
|
self.TheSetup = TheSetup
|
||||||
|
self.OutputPath = OutputPath or "/Output"
|
||||||
|
self.Entry = Entry or {}
|
||||||
|
self.OnChangeCallback = OnChange
|
||||||
|
self.ExampleFile = ft.TextField(value="Holiday photo.png", label="Example file", dense=True, width=180,
|
||||||
|
on_change=lambda e: self._Refresh())
|
||||||
|
self.Field = ft.TextField(label=Label, value=Value, dense=True, expand=True, on_change=lambda e: self._Changed())
|
||||||
|
self.Preview = ft.Text("", color=Palette.Green, size=12, selectable=True)
|
||||||
|
Chips = []
|
||||||
|
if TheSetup:
|
||||||
|
V = TheSetup.Vars
|
||||||
|
for Key, Tip in [
|
||||||
|
("OrgFileName", "original name"), ("OrgFileType", "original extension"), ("NextNum", "next number"),
|
||||||
|
("NextChar", "next letter"), ("Parent", "folder name"), ("Year", "year"), ("Month", "month"),
|
||||||
|
("Day", "day"), ("Hour", "hour"), ("Minute", "minute"), ("Second", "second"),
|
||||||
|
]:
|
||||||
|
if V.get(Key):
|
||||||
|
Chips.append(ft.Container(
|
||||||
|
ft.Text(f"{V[Key]}", size=12, color=Palette.Mauve),
|
||||||
|
tooltip=f"{Key}: {Tip}", padding=ft.Padding.symmetric(horizontal=6, vertical=2),
|
||||||
|
border=ft.Border.all(1, Palette.Surface1), border_radius=4,
|
||||||
|
on_click=lambda e, Text=V[Key]: self._Insert(Text),
|
||||||
|
))
|
||||||
|
self.controls = [
|
||||||
|
ft.Row([self.Field, self.ExampleFile]),
|
||||||
|
ft.Row(Chips, wrap=True, spacing=4, run_spacing=4),
|
||||||
|
self.Preview,
|
||||||
|
]
|
||||||
|
self._Refresh(Update=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self) -> str:
|
||||||
|
return self.Field.value or ""
|
||||||
|
|
||||||
|
def _Insert(self, Text: str) -> None:
|
||||||
|
self.Field.value = (self.Field.value or "") + Text
|
||||||
|
self.Field.update()
|
||||||
|
self._Changed()
|
||||||
|
|
||||||
|
def _Changed(self) -> None:
|
||||||
|
self._Refresh()
|
||||||
|
if self.OnChangeCallback:
|
||||||
|
self.OnChangeCallback(self.value)
|
||||||
|
|
||||||
|
def _Refresh(self, Update: bool = True) -> None:
|
||||||
|
if self.TheSetup:
|
||||||
|
Example = self.ExampleFile.value or "file.txt"
|
||||||
|
try:
|
||||||
|
Name = self.TheSetup.PreviewName(self.value or "~^", Example, self.OutputPath, self.Entry)
|
||||||
|
self.Preview.value = f"{Example} -> {Name}"
|
||||||
|
except Exception as Error: # a half-typed pattern shouldn't break the dialog
|
||||||
|
self.Preview.value = f"(can't preview: {Error})"
|
||||||
|
if Update:
|
||||||
|
self.Preview.update()
|
||||||
|
|
||||||
|
|
||||||
|
def KindColor(Kind: str) -> str:
|
||||||
|
return {"Input": Palette.Red, "Output": Palette.Blue, "Both": Palette.Mauve}[Kind]
|
||||||
|
|
||||||
|
|
||||||
|
def EmptyState(Text: str, Detail: str = "") -> ft.Control:
|
||||||
|
return ft.Container(
|
||||||
|
ft.Column([ft.Text(Text, color=Palette.Overlay0, size=16), Note(Detail)], horizontal_alignment=ft.CrossAxisAlignment.CENTER),
|
||||||
|
alignment=ft.Alignment.CENTER, expand=True,
|
||||||
|
)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
@@ -0,0 +1,3 @@
|
|||||||
|
flet>=1.0,<2.0
|
||||||
|
flet-desktop>=1.0,<2.0 # the desktop client; otherwise Flet fetches it on first launch and quits once
|
||||||
|
tomlkit
|
||||||
Reference in new issue
Block a user