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