From 60da6dd9abdb670abfa849de5def7eb1785097dc Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Wed, 7 Oct 2026 22:44:39 +0200 Subject: [PATCH] Export and import all of SaraSortd's configs Settings > SaraSortd can export GlobalConf.toml, both templates and every directory's config as one file, and import it again. An import backs up the current configs first; directories that don't exist can be given a new path (InputDir/OutputDir, ValidInputDirs and the config's own name follow) or made. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- Settings.py | 109 +++++++++++++++++++++++- Sortd.py | 17 ++-- SortdTransfer.py | 211 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 337 insertions(+), 8 deletions(-) create mode 100644 SortdTransfer.py diff --git a/README.md b/README.md index 610fb8e..eb17c63 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,11 @@ Needs SaraSortd **7.1** or newer for arrows with their own patterns (per-entry ` The gear button opens **Settings**, laid out like SaraFem's and SaraSuite's: the shared SaraSuite appearance and export/import (same file format as SaraFem's, `[SaraSuite]` plus `[SaraSort]`), SaraSort's -own settings, and where SaraSortd lives. Run as an AppImage, the header also has an "Add to app menu" +own settings, and where SaraSortd lives. The SaraSortd tab also exports and imports all of SaraSortd's +configs as one file: GlobalConf.toml, both templates and every directory's own config, comments included. +Importing backs the current configs up to `~/.config/SaraSuite/SaraSort/Backups/` first. If directories it +uses don't exist, you can cancel, give any of them a new path (the configs and arrows follow), or have them +made. Run as an AppImage, the header also has an "Add to app menu" button, like SaraSuite's. After a change to `GlobalConf.toml` (new inputs or outputs) SaraSort restarts SaraSortd, since SaraSortd @@ -44,6 +48,7 @@ only reads that file when it starts. That can be turned off in Settings. | `~/.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/SaraSort/Backups/` | SaraSortd's configs from before each import | | `~/.config/SaraSuite/SaraSortd/` | SaraSortd's configs, as SaraSuite installs them (changeable in Settings) | ## Running and building @@ -64,5 +69,6 @@ only reads that file when it starts. That can be turned off in Settings. | `Service.py` | systemctl and journalctl | | `Templates.py` | built-in and your own templates | | `Settings.py` | the Settings dialog | +| `SortdTransfer.py` | exporting/importing all of SaraSortd's configs | | `Widgets.py` | the shared App context and small controls | | `*Page.py` | one per page | diff --git a/Settings.py b/Settings.py index f6b0bf1..1c2dfbc 100644 --- a/Settings.py +++ b/Settings.py @@ -14,7 +14,9 @@ from Core import ( AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, Home, ImportSettings, LoadSharedSettings, Palette, SharedSettings, ) -from Widgets import App +from Sortd import MajorVersion, Setup, SortdError +from SortdTransfer import ApplyBundle, Bundle, BundleDirectories, ExportSortd, MissingDirectories, ReadBundleSafely +from Widgets import App, DirField, ExpandPath DialogWidth = 700 DialogHeight = 520 @@ -108,6 +110,32 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None: RefreshField = _NumberField("Service page refresh (seconds)", Config.RefreshSeconds) LogLinesField = _NumberField("Log lines to show", Config.LogLines) + SortdFileField = ft.TextField(value=str(Home / "SaraSortd-Configs.toml"), label="SaraSortd export file path") + + def OnSortdExport(e) -> None: + PathText = (SortdFileField.value or "").strip() + if not PathText: + return + try: + Count = ExportSortd(Config, Path(PathText).expanduser()) + except (OSError, SortdError) as Error: + TheApp.Notify(f"Couldn't export SaraSortd's configs: {Error}", "error") + return + TheApp.Notify(f"Exported {Count} SaraSortd configs.", "success") + + def OnSortdImport(e) -> None: + PathText = (SortdFileField.value or "").strip() + if not PathText: + return + TheBundle, Error = ReadBundleSafely(Path(PathText).expanduser()) + if TheBundle is None: + TheApp.Notify(Error, "error") + return + # Same as importing SaraSort's own settings: the Settings dialog + # closes, so a stale Save can't undo what the import wrote. + Page.pop_dialog() + ShowSortdImportDialog(TheApp, TheBundle, OnSaved) + def OnExport(e) -> None: PathText = (SettingsFileField.value or "").strip() if not PathText: @@ -177,6 +205,15 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None: ft.Divider(height=1, color=Palette.Surface1), RefreshField, LogLinesField, + ft.Divider(height=1, color=Palette.Surface1), + _Note("Export/import all of SaraSortd's configs as one file: GlobalConf.toml, both templates " + "and every input/output directory's own config. Importing replaces the current ones " + "(they're backed up first)."), + SortdFileField, + ft.Row([ + ft.OutlinedButton(content="Export", style=ChipButtonStyle(), on_click=OnSortdExport), + ft.OutlinedButton(content="Import", style=ChipButtonStyle(), on_click=OnSortdImport), + ]), ], spacing=12, scroll=ft.ScrollMode.AUTO, @@ -251,3 +288,73 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None: ft.OutlinedButton(content="Save", style=ChipButtonStyle(), on_click=Save), ], )) + + +def ShowSortdImportDialog(TheApp: App, TheBundle: Bundle, OnDone: Callable[[], None]) -> None: + """Confirms a SaraSortd import. Directories it uses that don't exist are + listed with a path field each: keep the path to have it made, or change it + to use another directory instead.""" + Page = TheApp.Page + Config = TheApp.Config + try: + Missing = MissingDirectories(Config, TheBundle) + Total = len(BundleDirectories(Config, TheBundle)) + except Exception as Error: # e.g. a GlobalConf in the export without the keys SaraSortd needs + TheApp.Notify(f"Can't use that export: {Error}", "error") + return + + Fields: dict[str, ft.TextField] = {} + Rows: list[ft.Control] = [] + for DirPath in Missing: + Row, Field = DirField(TheApp, f"{Path(DirPath).name or DirPath} (doesn't exist)", DirPath) + Fields[DirPath] = Field + Rows.append(Row) + + Info = [ + _Note(f"From an export of SaraSortd {TheBundle.SortdVersion or '?'}" + + (f", made {TheBundle.Exported.replace('T', ' ')}" if TheBundle.Exported else "") + "."), + ft.Text(f"This replaces SaraSortd's configs in {Config.SortdConfigDir} and in {Total} " + f"director{'y' if Total == 1 else 'ies'}. The current ones are backed up to " + "~/.config/SaraSuite/SaraSort/Backups/ first.", size=13), + ] + try: + Daemon = Setup(Config).DaemonVersion() + except SortdError: + Daemon = None + if Daemon and TheBundle.SortdVersion and MajorVersion(Daemon) != MajorVersion(TheBundle.SortdVersion): + Info.append(ft.Text(f"These configs are from SaraSortd {TheBundle.SortdVersion}, but {Daemon} is installed: " + "the first number differs, so they may not work.", color=Palette.Red, size=13)) + if Missing: + Info += [ + ft.Divider(height=1, color=Palette.Surface1), + ft.Row([ft.Icon(ft.Icons.WARNING_AMBER, color=Palette.Yellow), + ft.Text(f"{len(Missing)} director{'y' if len(Missing) == 1 else 'ies'} " + f"{'doesn' if len(Missing) == 1 else 'don'}'t exist:", color=Palette.Yellow)]), + _Note("Keep a path to have it made, or change it to use another directory instead " + "(the configs and arrows follow it)."), + # Only the list scrolls, so the dialog stays as small as its content. + ft.Column(Rows, spacing=8, scroll=ft.ScrollMode.AUTO, height=min(300, 58 * len(Rows))), + ] + + def Continue(e) -> None: + Remap = {Old: ExpandPath(Field.value or "") or Old for Old, Field in Fields.items()} + Page.pop_dialog() + try: + ApplyBundle(Config, TheBundle, Remap) + except (OSError, SortdError) as Error: + TheApp.Notify(f"Import failed: {Error}", "error") + return + OnDone() + TheApp.AfterGlobalChange() # its toast says whether SaraSortd restarted + + Page.show_dialog(ft.AlertDialog( + title="Import SaraSortd configs", + content=ft.Column(Info, width=620, tight=True, spacing=10), + actions=[ + ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: Page.pop_dialog()), + ft.OutlinedButton( + content="Make directories and import" if Missing else "Import", + style=ChipButtonStyle(Palette.Yellow if Missing else ""), on_click=Continue, + ), + ], + )) diff --git a/Sortd.py b/Sortd.py index f2a4ff5..e655d09 100644 --- a/Sortd.py +++ b/Sortd.py @@ -114,16 +114,21 @@ 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): + def __init__(self, Config: AppConfig, Document: Optional[tomlkit.TOMLDocument] = None): + """Document: a GlobalConf.toml that isn't on disk yet (an import being + checked); otherwise it's read from SortdConfigDir.""" self.Config = Config self.ConfDir = Path(Config.SortdConfigDir).expanduser() self.GlobalPath = Config.GlobalConfPath - if not self.GlobalPath.exists(): + if Document is not None: + self.Global = Document + elif 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 + else: + 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", {}) diff --git a/SortdTransfer.py b/SortdTransfer.py new file mode 100644 index 0000000..2d3884e --- /dev/null +++ b/SortdTransfer.py @@ -0,0 +1,211 @@ +"""Exporting and importing all of SaraSortd's configs as one file: +GlobalConf.toml, the two directory templates, and every input/output +directory's own config. Each is stored as its full text, so comments travel. + + Format = "SaraSortdExport" + FormatVersion = 1 + SaraSortdVersion = "7.1" + Exported = "2026-10-07T22:40:00" + + [[Configs]] + Role = "Global" # Global, InputTemplate, OutputTemplate, Directory + Content = '''...''' + + [[Configs]] + Role = "Directory" + Directory = "/home/sarah/Sorting/Pics" # resolved, as SaraSortd sees it + Kinds = ["In", "Out"] # which roles read this file + Content = '''...''' + +On import, directories can be moved somewhere else: GlobalConf's InputDir and +OutputDir, every ValidInputDirs entry and the directory configs' own +ParentDir/Title follow the new path. +""" + +import datetime +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +import tomlkit + +from Core import AppConfig, ConfigDir +from Sortd import Setup, SortdError, TemplateNames, WriteToml + +FormatName = "SaraSortdExport" +FormatVersion = 1 +BackupsDir = ConfigDir / "Backups" +Roles = {"InputTemplate": "In", "OutputTemplate": "Out"} + + +@dataclass +class DirectoryConf: + Directory: str + Kinds: list[str] + Content: str + + +@dataclass +class Bundle: + GlobalContent: str + Templates: dict[str, str] = field(default_factory=dict) # "In"/"Out" -> content + Directories: list[DirectoryConf] = field(default_factory=list) + SortdVersion: str = "" + Exported: str = "" + + +def ExportSortd(Config: AppConfig, DestPath: Path) -> int: + """Writes the bundle; returns how many config files went in.""" + TheSetup = Setup(Config) + Configs = tomlkit.aot() + + def Add(Role: str, Content: str, **Extra) -> None: + Table = tomlkit.table() + Table["Role"] = Role + for Key, Value in Extra.items(): + Table[Key] = Value + Table["Content"] = tomlkit.string(Content, multiline=True, literal="'''" not in Content) + Configs.append(Table) + + Add("Global", TheSetup.GlobalPath.read_text(encoding="utf-8")) + for Kind, Role in (("In", "InputTemplate"), ("Out", "OutputTemplate")): + TemplatePath = TheSetup.TemplatePath(Kind) + if TemplatePath.exists(): + Add(Role, TemplatePath.read_text(encoding="utf-8")) + + # One entry per file: a directory that is both input and output may use one file for both. + Files: dict[Path, tuple[str, list[str]]] = {} + for Kind, Paths in (("In", TheSetup.Inputs), ("Out", TheSetup.Outputs)): + for DirPath in dict.fromkeys(Paths): + ConfPath = TheSetup.ConfFile(DirPath, Kind) + if ConfPath.exists(): + Files.setdefault(ConfPath, (DirPath, []))[1].append(Kind) + for ConfPath, (DirPath, Kinds) in Files.items(): + Add("Directory", ConfPath.read_text(encoding="utf-8"), Directory=DirPath, Kinds=Kinds) + + Document = tomlkit.document() + Document["Format"] = FormatName + Document["FormatVersion"] = FormatVersion + Document["SaraSortdVersion"] = TheSetup.DaemonVersion() or str(TheSetup.Data.get("Version", "")) + Document["Exported"] = datetime.datetime.now().isoformat(timespec="seconds") + Document["Configs"] = Configs + DestPath.parent.mkdir(parents=True, exist_ok=True) + DestPath.write_text(tomlkit.dumps(Document), encoding="utf-8") + return len(Configs) + + +def ReadBundle(SourcePath: Path) -> Bundle: + """Raises SortdError when it isn't a usable SaraSortd export.""" + try: + Data = tomlkit.parse(SourcePath.read_text(encoding="utf-8")).unwrap() + except (OSError, tomlkit.exceptions.TOMLKitError) as Error: + raise SortdError(f"Can't read {SourcePath}: {Error}") from Error + if Data.get("Format") != FormatName: + raise SortdError(f"{SourcePath.name} isn't a SaraSortd export (no Format = \"{FormatName}\").") + if int(Data.get("FormatVersion", 0)) > FormatVersion: + raise SortdError("That export was made by a newer SaraSort.") + Result = Bundle("", SortdVersion=str(Data.get("SaraSortdVersion", "")), Exported=str(Data.get("Exported", ""))) + for Entry in Data.get("Configs", []): + Role, Content = Entry.get("Role"), str(Entry.get("Content", "")) + try: + tomlkit.parse(Content) + except tomlkit.exceptions.TOMLKitError as Error: + raise SortdError(f"A {Role} config in the export isn't valid TOML: {Error}") from Error + if Role == "Global": + Result.GlobalContent = Content + elif Role in Roles: + Result.Templates[Roles[Role]] = Content + elif Role == "Directory" and Entry.get("Directory"): + Result.Directories.append(DirectoryConf(str(Entry["Directory"]), list(Entry.get("Kinds", [])), Content)) + if not Result.GlobalContent: + raise SortdError("The export has no GlobalConf.toml.") + return Result + + +def BundleSetup(Config: AppConfig, TheBundle: Bundle) -> Setup: + """The imported GlobalConf, not written anywhere yet.""" + return Setup(Config, tomlkit.parse(TheBundle.GlobalContent)) + + +def BundleDirectories(Config: AppConfig, TheBundle: Bundle) -> list[str]: + """Every input/output directory the import uses, in order.""" + TheSetup = BundleSetup(Config, TheBundle) + Paths = TheSetup.Inputs + TheSetup.Outputs + [Entry.Directory for Entry in TheBundle.Directories] + return list(dict.fromkeys(Path for Path in Paths if Path)) + + +def MissingDirectories(Config: AppConfig, TheBundle: Bundle) -> list[str]: + return [DirPath for DirPath in BundleDirectories(Config, TheBundle) if not os.path.isdir(DirPath)] + + +def Backup(Config: AppConfig) -> Optional[Path]: + """Exports the current configs before an import replaces them.""" + try: + Setup(Config) + except SortdError: + return None # nothing there to lose + Stamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + BackupPath = BackupsDir / f"SaraSortd-{Stamp}.toml" + ExportSortd(Config, BackupPath) + return BackupPath + + +def _RemapList(TheSetup: Setup, Values, Remap: dict[str, str], Parent: Optional[str] = None) -> None: + """Points entries that resolve to a moved directory at its new path.""" + if not isinstance(Values, list): + return + for Index in range(len(Values)): + Resolved = TheSetup.Parse(str(Values[Index]), Parent=Parent) + if Resolved in Remap: + Values[Index] = TheSetup.Shorthand(Remap[Resolved]) + + +def ApplyBundle(Config: AppConfig, TheBundle: Bundle, Remap: dict[str, str]) -> Optional[Path]: + """Writes the import. Remap moves directories: {old path: new path}. + Missing directories are made. Returns the backup's path (if any).""" + Remap = {Old: New for Old, New in Remap.items() if New and New != Old} + BackupPath = Backup(Config) + + GlobalDoc = tomlkit.parse(TheBundle.GlobalContent) + TheSetup = Setup(Config, GlobalDoc) + Paths = GlobalDoc.get("DirectoryPaths", {}) + for Key in ("InputDir", "OutputDir"): + _RemapList(TheSetup, Paths.get(Key), Remap) + + ConfDir = Path(Config.SortdConfigDir).expanduser() + ConfDir.mkdir(parents=True, exist_ok=True) + WriteToml(Config.GlobalConfPath, GlobalDoc) + for Kind, Content in TheBundle.Templates.items(): + (ConfDir / TemplateNames[Kind]).write_text(Content, encoding="utf-8") + + TheSetup = Setup(Config) # the written GlobalConf, with the new paths + for Entry in TheBundle.Directories: + Target = Remap.get(Entry.Directory, Entry.Directory) + Document = tomlkit.parse(Entry.Content) + Parent = os.path.basename(Entry.Directory) + _RemapList(TheSetup, Document.get("ValidInputDirs"), Remap, Parent) + for FileEntry in Document.get("Files", []): + _RemapList(TheSetup, FileEntry.get("ValidInputDirs"), Remap, Parent) + NewName = os.path.basename(Target) + if NewName != Parent: + if "ParentDir" in Document: + Document["ParentDir"] = NewName + if "Title" in Document: + Document["Title"] = f"{NewName} Config" + Path(Target).mkdir(parents=True, exist_ok=True) + for Kind in dict.fromkeys(Entry.Kinds or ["Out"]): + WriteToml(TheSetup.ConfFile(Target, Kind), Document) + + # Directories without a config in the export still need to exist. + for DirPath in TheSetup.Inputs + TheSetup.Outputs: + Path(DirPath).mkdir(parents=True, exist_ok=True) + return BackupPath + + +def ReadBundleSafely(SourcePath: Path) -> tuple[Optional[Bundle], str]: + try: + return ReadBundle(SourcePath), "" + except SortdError as Error: + return None, str(Error) +