Directory configs outside their directories (SaraSortd 7.2)

SaraSort finds configs and history files where SaraSortd 7.2 keeps them
(DirConfDir, [DirConfs]), and Settings > SaraSortd moves the existing ones
between "inside each directory" and one folder. Imports keep [DirConfs]
entries with moved directories, and Warnings flag a config folder inside an
input or a SaraSortd older than 7.2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-08 18:09:49 +02:00
1 parent edc2832a0f
commit 28252e3101
5 files changed
+177 -20

No files matched your search

+102 -12
View File
@@ -14,6 +14,7 @@ import datetime
import fnmatch
import os
import re
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
@@ -38,6 +39,7 @@ def ReadToml(FilePath: Path) -> tomlkit.TOMLDocument:
def WriteToml(FilePath: Path, Document: tomlkit.TOMLDocument) -> None:
FilePath.parent.mkdir(parents=True, exist_ok=True) # configs can live outside their directory
FilePath.write_text(tomlkit.dumps(Document), encoding="utf-8")
@@ -212,14 +214,97 @@ class Setup:
return f"{Call[0]}RootDir{Call[1]}{PathText[len(Root):]}"
return PathText
def ConfFolder(self, PathText: str) -> Path:
"""SaraSortd's ConfFolder: relative paths count from GlobalConf.toml's folder."""
Parsed = self.Parse(str(PathText))
if os.path.isabs(Parsed):
return Path(os.path.normpath(Parsed))
return Path(os.path.normpath(self.GlobalPath.parent / Parsed))
def FlatName(self, DirPath: str) -> str:
"""SaraSortd's FlatName: "%RootDir%/Pictures/Cats" -> "Pictures_Cats"."""
Root = self.Parse(str(self.Dirs.get("RootDir", ""))).rstrip("/")
DirPath = DirPath.rstrip("/")
if Root and DirPath.startswith(Root + "/"):
Relative = DirPath[len(Root) + 1:]
else:
Relative = DirPath.strip("/") or os.path.basename(DirPath)
return Relative.replace("/", "_")
def CentralConf(self, DirPath: str, Kind: str) -> Optional[Path]:
"""SaraSortd 7.2: a config named in [DirConfs], or one in DirConfDir.
None when the config lives inside the directory."""
for Key, Value in (self.Data.get("DirConfs") or {}).items():
if self.Parse(str(Key)).rstrip("/") == DirPath.rstrip("/"):
if isinstance(Value, dict):
Value = Value.get(Kind)
if Value:
return self.ConfFolder(str(Value))
Folder = self.Dirs.get("DirConfDir")
if Folder:
Name = self.Parse(str(self.Names.get(NameKeys[Kind], ".@Config")), Parent=self.FlatName(DirPath)).lstrip(".")
return self.ConfFolder(str(Folder)) / f"{Name}.toml"
return None
def ConfFile(self, DirPath: str, Kind: str) -> Path:
"""A directory's own config, e.g. ~/Sorting/Output/.OutputConfig.toml."""
"""A directory's own config: inside it (e.g. ~/Sorting/Output/.OutputConfig.toml),
or wherever DirConfDir / [DirConfs] put it."""
Central = self.CentralConf(DirPath, Kind)
if Central:
return Central
Name = self.Parse(str(self.Names.get(NameKeys[Kind], ".@Config")), Parent=os.path.basename(DirPath))
return Path(DirPath) / f"{Name}.toml"
def SharedConfName(self) -> bool:
"""Input and output configs use the same file name (both default to .@Config)."""
return self.Names.get("InDirConfName") == self.Names.get("OutDirConfName")
def HistoryFile(self, DirPath: str) -> Optional[Path]:
"""SaraSortd's HistoryPath: next to a config kept outside the directory, or inside it."""
HistoryName = self.Names.get("FileHistoryName")
if not HistoryName:
return None
Central = self.CentralConf(DirPath, "In") or self.CentralConf(DirPath, "Out")
if Central:
return Central.parent / f"{self.Parse(str(HistoryName), Parent=self.FlatName(DirPath)).lstrip('.')}.toml"
return Path(DirPath) / f"{self.Parse(str(HistoryName), Parent=os.path.basename(DirPath))}.toml"
def SharesConf(self, DirPath: str) -> bool:
"""Its input and output config are one file (the default .@Config names do that)."""
return self.ConfFile(DirPath, "In") == self.ConfFile(DirPath, "Out")
def CentralFolder(self) -> Optional[Path]:
Folder = self.Dirs.get("DirConfDir")
return self.ConfFolder(str(Folder)) if Folder else None
def MoveDirConfs(self, NewFolder: str) -> int:
"""Sets DirConfDir ("" = inside each directory) and moves every existing
directory config and history file to match, so no rules are lost.
Directories named in [DirConfs] stay where they are. Returns how many
files moved. Raises SortdError (before moving anything) when a file is
already where one would go."""
NewDoc = tomlkit.parse(tomlkit.dumps(self.Global))
NewDoc.setdefault("DirectoryPaths", tomlkit.table())["DirConfDir"] = NewFolder
After = Setup(self.Config, NewDoc)
Folder = After.CentralFolder()
for InputPath in self.Inputs:
if Folder and (Folder == Path(InputPath) or str(Folder).startswith(InputPath.rstrip("/") + "/")):
raise SortdError(f"{Folder} is inside the input {InputPath}; SaraSortd would sort its own configs away.")
Plan: dict[Path, Path] = {}
for DirPath in dict.fromkeys(self.Inputs + self.Outputs):
Kinds = (["In"] if DirPath in self.Inputs else []) + (["Out"] if DirPath in self.Outputs else [])
Pairs = [(self.ConfFile(DirPath, Kind), After.ConfFile(DirPath, Kind)) for Kind in Kinds]
Pairs.append((self.HistoryFile(DirPath), After.HistoryFile(DirPath)))
for Old, New in Pairs:
if Old and New and Old != New and Old.exists():
Plan[Old] = New
Taken = [New for New in Plan.values() if New.exists()]
if Taken:
raise SortdError(f"Not moved: {', '.join(str(New) for New in Taken)} already exist(s).")
for Old, New in Plan.items():
New.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(Old), str(New))
WriteToml(self.GlobalPath, NewDoc)
self.Global = NewDoc
self.Data = NewDoc.unwrap()
self.Dirs = self.Data.get("DirectoryPaths", {})
return len(Plan)
def TemplatePath(self, Kind: str) -> Path:
return self.ConfDir / TemplateNames[Kind]
@@ -364,7 +449,7 @@ class Setup:
else:
Document = self._NewDirConf(DirPath, Kind)
IsNew = True
if self.SharedConfName() and DirPath in self.Inputs and DirPath in self.Outputs:
if DirPath in self.Inputs and DirPath in self.Outputs and self.SharesConf(DirPath):
# One file for both roles: make sure it has the keys both read.
Other = "In" if Kind == "Out" else "Out"
OtherTemplate = ReadToml(self.TemplatePath(Other))
@@ -469,7 +554,7 @@ class Setup:
# When the input is also an output and both use one config file, its
# [[Files]] are output rules too: an input-only entry (no NewFileName)
# would crash SaraSortd, so leave them alone.
SharedFile = self.SharedConfName() and InputPath in self.Outputs
SharedFile = InputPath in self.Outputs and self.SharesConf(InputPath)
if "*" not in InPatterns and not SharedFile:
for Pattern in Patterns:
if Pattern not in InPatterns:
@@ -613,12 +698,8 @@ class Setup:
WriteToml(ConfPath, Document)
GlobalChanged = self.RemoveDir(PathText)
Parent = os.path.basename(PathText)
Leftovers = {self.ConfFile(PathText, "In"), self.ConfFile(PathText, "Out")}
HistoryName = self.Names.get("FileHistoryName")
if HistoryName:
Leftovers.add(Path(PathText) / f"{self.Parse(str(HistoryName), Parent=Parent)}.toml")
for FilePath in Leftovers:
Leftovers = {self.ConfFile(PathText, "In"), self.ConfFile(PathText, "Out"), self.HistoryFile(PathText)}
for FilePath in filter(None, Leftovers):
FilePath.unlink(missing_ok=True)
return GlobalChanged
@@ -777,6 +858,15 @@ class Setup:
Found.append(("Warning", f"{CatchAll} takes every file from {InputPath} first, so {FoundEdge.Output} never gets any ({', '.join(FoundEdge.Patterns)})."))
elif "*" in FoundEdge.Patterns:
CatchAll = FoundEdge.Output
Central = self.CentralFolder()
if (Central or self.Data.get("DirConfs")) and Daemon and not VersionAtLeast(Daemon, (7, 2)):
Found.append(("Error", f"Directory configs are kept outside their directories (DirConfDir / [DirConfs]), "
f"which SaraSortd {Daemon} doesn't know about. Update SaraSortd to 7.2."))
if Central:
for InputPath in Inputs:
if Central == Path(InputPath) or str(Central).startswith(InputPath.rstrip("/") + "/"):
Found.append(("Error", f"DirConfDir ({Central}) is inside the input {InputPath}, so SaraSortd "
"would sort its own configs away."))
if UsesOwnInputs and Daemon and not VersionAtLeast(Daemon, (7, 1)):
Found.append(("Error", f"Some [[Files]] entries have their own ValidInputDirs, which SaraSortd {Daemon} ignores. Update SaraSortd to 7.1."))