commit f7ed23edf996a35b06bbe906a6bb5d948d92ecae Author: SRHTNIN Date: Wed Oct 7 22:23:36 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b48d2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +build/ +.flet/ +dist/ +*.AppImage diff --git a/ConfigPage.py b/ConfigPage.py new file mode 100644 index 0000000..4c13ed9 --- /dev/null +++ b/ConfigPage.py @@ -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) diff --git a/Core.py b/Core.py new file mode 100644 index 0000000..0c71f2b --- /dev/null +++ b/Core.py @@ -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/.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 diff --git a/FlowPage.py b/FlowPage.py new file mode 100644 index 0000000..416ed17 --- /dev/null +++ b/FlowPage.py @@ -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) diff --git a/Main.py b/Main.py new file mode 100644 index 0000000..75aa261 --- /dev/null +++ b/Main.py @@ -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)) diff --git a/Packaging/BuildAppImage.sh b/Packaging/BuildAppImage.sh new file mode 100755 index 0000000..09ada55 --- /dev/null +++ b/Packaging/BuildAppImage.sh @@ -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" diff --git a/Packaging/Launch.sh b/Packaging/Launch.sh new file mode 100755 index 0000000..514065b --- /dev/null +++ b/Packaging/Launch.sh @@ -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 "$@" diff --git a/Packaging/SaraSuite/Manifest.toml b/Packaging/SaraSuite/Manifest.toml new file mode 100644 index 0000000..b02323d --- /dev/null +++ b/Packaging/SaraSuite/Manifest.toml @@ -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" diff --git a/Packaging/SaraSuite/Templates/Launch.sh b/Packaging/SaraSuite/Templates/Launch.sh new file mode 100755 index 0000000..514065b --- /dev/null +++ b/Packaging/SaraSuite/Templates/Launch.sh @@ -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 "$@" diff --git a/Packaging/SaraSuite/Templates/SaraSort.desktop b/Packaging/SaraSuite/Templates/SaraSort.desktop new file mode 100644 index 0000000..079c9a7 --- /dev/null +++ b/Packaging/SaraSuite/Templates/SaraSort.desktop @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ffaadb --- /dev/null +++ b/README.md @@ -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 | diff --git a/Service.py b/Service.py new file mode 100644 index 0000000..16bfcae --- /dev/null +++ b/Service.py @@ -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}"] diff --git a/ServicePage.py b/ServicePage.py new file mode 100644 index 0000000..56019cc --- /dev/null +++ b/ServicePage.py @@ -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 diff --git a/SettingsPage.py b/SettingsPage.py new file mode 100644 index 0000000..58c337a --- /dev/null +++ b/SettingsPage.py @@ -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() diff --git a/Sortd.py b/Sortd.py new file mode 100644 index 0000000..f2a4ff5 --- /dev/null +++ b/Sortd.py @@ -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]]) diff --git a/Templates.py b/Templates.py new file mode 100644 index 0000000..acc9a74 --- /dev/null +++ b/Templates.py @@ -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) diff --git a/TemplatesPage.py b/TemplatesPage.py new file mode 100644 index 0000000..75a0533 --- /dev/null +++ b/TemplatesPage.py @@ -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) diff --git a/ToolsPage.py b/ToolsPage.py new file mode 100644 index 0000000..1e6663c --- /dev/null +++ b/ToolsPage.py @@ -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") diff --git a/Widgets.py b/Widgets.py new file mode 100644 index 0000000..1312205 --- /dev/null +++ b/Widgets.py @@ -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, + ) diff --git a/assets/Icon.png b/assets/Icon.png new file mode 100644 index 0000000..4c8446c Binary files /dev/null and b/assets/Icon.png differ diff --git a/assets/fonts/Fixedsys_Excelsior_Regular.ttf b/assets/fonts/Fixedsys_Excelsior_Regular.ttf new file mode 100644 index 0000000..a3dbddd Binary files /dev/null and b/assets/fonts/Fixedsys_Excelsior_Regular.ttf differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f282f7b --- /dev/null +++ b/requirements.txt @@ -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