Files
SaraSort/SortdTransfer.py
T
sarahandClaude Opus 5.5 60da6dd9ab 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]>
2026-10-07 22:44:39 +02:00

212 lines
8.5 KiB
Python

"""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)