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 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-07 22:44:39 +02:00
1 parent e90f59bc16
commit 60da6dd9ab
4 files changed
+337 -8

No files matched your search

+7 -1
View File
@@ -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 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 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. button, like SaraSuite's.
After a change to `GlobalConf.toml` (new inputs or outputs) SaraSort restarts SaraSortd, since SaraSortd 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/Config.toml` | SaraSort's own settings |
| `~/.config/SaraSuite/SaraSort/Layout.toml` | where flowchart boxes were dragged to | | `~/.config/SaraSuite/SaraSort/Layout.toml` | where flowchart boxes were dragged to |
| `~/.config/SaraSuite/SaraSort/Templates/` | your own templates | | `~/.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) | | `~/.config/SaraSuite/SaraSortd/` | SaraSortd's configs, as SaraSuite installs them (changeable in Settings) |
## Running and building ## 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 | | `Service.py` | systemctl and journalctl |
| `Templates.py` | built-in and your own templates | | `Templates.py` | built-in and your own templates |
| `Settings.py` | the Settings dialog | | `Settings.py` | the Settings dialog |
| `SortdTransfer.py` | exporting/importing all of SaraSortd's configs |
| `Widgets.py` | the shared App context and small controls | | `Widgets.py` | the shared App context and small controls |
| `*Page.py` | one per page | | `*Page.py` | one per page |
+108 -1
View File
@@ -14,7 +14,9 @@ from Core import (
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, Home, AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, Home,
ImportSettings, LoadSharedSettings, Palette, SharedSettings, 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 DialogWidth = 700
DialogHeight = 520 DialogHeight = 520
@@ -108,6 +110,32 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None:
RefreshField = _NumberField("Service page refresh (seconds)", Config.RefreshSeconds) RefreshField = _NumberField("Service page refresh (seconds)", Config.RefreshSeconds)
LogLinesField = _NumberField("Log lines to show", Config.LogLines) 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: def OnExport(e) -> None:
PathText = (SettingsFileField.value or "").strip() PathText = (SettingsFileField.value or "").strip()
if not PathText: if not PathText:
@@ -177,6 +205,15 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None:
ft.Divider(height=1, color=Palette.Surface1), ft.Divider(height=1, color=Palette.Surface1),
RefreshField, RefreshField,
LogLinesField, 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, spacing=12,
scroll=ft.ScrollMode.AUTO, 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), 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,
),
],
))
+11 -6
View File
@@ -114,16 +114,21 @@ class Setup:
"""GlobalConf.toml plus everything reachable from it. Cheap to reload: """GlobalConf.toml plus everything reachable from it. Cheap to reload:
pages just make a new one after each change.""" 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.Config = Config
self.ConfDir = Path(Config.SortdConfigDir).expanduser() self.ConfDir = Path(Config.SortdConfigDir).expanduser()
self.GlobalPath = Config.GlobalConfPath 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}") raise SortdError(f"No GlobalConf.toml at {self.GlobalPath}")
try: else:
self.Global = ReadToml(self.GlobalPath) try:
except tomlkit.exceptions.TOMLKitError as Error: self.Global = ReadToml(self.GlobalPath)
raise SortdError(f"GlobalConf.toml can't be read: {Error}") from Error except tomlkit.exceptions.TOMLKitError as Error:
raise SortdError(f"GlobalConf.toml can't be read: {Error}") from Error
self.Data = self.Global.unwrap() self.Data = self.Global.unwrap()
self.Vars = self.Data.get("Variables", {}) self.Vars = self.Data.get("Variables", {})
self.Dirs = self.Data.get("DirectoryPaths", {}) self.Dirs = self.Data.get("DirectoryPaths", {})
+211
View File
@@ -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)