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:
sarahandClaude Opus 5.5 committed 2026-10-07 22:23:36 +02:00
commit f7ed23edf9
22 files changed
+3399

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
build/
.flet/
dist/
*.AppImage
+438
View File
@@ -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)
+307
View File
@@ -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
View File
@@ -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)
+111
View File
@@ -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))
+38
View File
@@ -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"
+6
View File
@@ -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 "$@"
+27
View File
@@ -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"
+6
View File
@@ -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
+64
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+673
View File
@@ -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
View File
@@ -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)
+168
View File
@@ -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
View File
@@ -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
View File
@@ -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,
)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
+3
View File
@@ -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