mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-14 10:48:11 +02:00
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:
1 parent
e90f59bc16
commit
60da6dd9ab
4 files changed
+337
-8
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user