"""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) # A moved directory keeps its own config file from [DirConfs] (SaraSortd 7.2). DirConfs = GlobalDoc.get("DirConfs") for Key in list(DirConfs.keys()) if DirConfs is not None else []: Resolved = TheSetup.Parse(str(Key)).rstrip("/") if Resolved in Remap: DirConfs[TheSetup.Shorthand(Remap[Resolved])] = DirConfs[Key] del DirConfs[Key] 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)