Follow SaraSortd 7.3: catch-alls as fallback, path parsing, nested variables

The tester tries specific patterns before catch-alls, directory paths only get
%Name% lookups and date/time variables, and nested %Name%s resolve. Warnings
for what 7.3 fixed are gone; a SaraSortd older than 7.3 gets a warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-08 18:18:02 +02:00
1 parent 28252e3101
commit d0e5eb2086
2 files changed
+87 -60

No files matched your search

+82 -55
View File
@@ -59,6 +59,11 @@ def GetConf(Parameter: str, Data: dict) -> Any:
return None
def IsCatchAll(Pattern: str) -> bool:
"""SaraSortd 7.3's IsCatchAll: a pattern of only "*" takes any file, so it's a fallback."""
return Pattern != "" and set(Pattern) == {"*"}
def MajorVersion(Version: str) -> str:
return str(Version).split(".")[0]
@@ -177,12 +182,44 @@ class Setup:
if not Content:
return str(VarCall) if VarCall is not None else ""
Found = GetConf(Content, ConfData)
return str(Found) if Found is not None else Match.group(0)
return self.ResolveLookups(str(Found), ConfData) if Found is not None else Match.group(0)
return re.sub(f"{re.escape(Call[0])}(.*?){re.escape(Call[1])}", Replacer, String)
def ResolveLookups(self, String: str, ConfData: Optional[dict] = None, Depth: int = 0) -> str:
"""SaraSortd 7.3's ResolveLookups: %Name% lookups, also inside the values
they bring in (ImageDir = "%RootDir%/Images" used as "%ImageDir%").
Unknown names are left as they are."""
ConfData = self.Data if ConfData is None else ConfData
Call = self.Vars.get("VarCall", "%%")
if len(Call) < 2:
return String
def Replacer(Match: re.Match) -> str:
Found = GetConf(Match.group(1), ConfData) if Match.group(1) else None
if Found is None or Depth >= 10:
return Match.group(0)
return self.ResolveLookups(str(Found), ConfData, Depth + 1)
return re.sub(f"{re.escape(Call[0])}(.*?){re.escape(Call[1])}", Replacer, String)
def ParsePath(self, String: str) -> str:
"""SaraSortd 7.3's ParsePath: directory paths only get %Name% lookups and
the date and time variables, so "~/Stuff&Things" stays as it is; a
leading "~" is the home directory."""
Now = datetime.datetime.now()
V = self.Vars
for Key, Value in {
V.get("Year"): str(Now.year), V.get("Month"): str(Now.month).rjust(2, "0"),
V.get("Day"): str(Now.day).rjust(2, "0"), V.get("Hour"): str(Now.hour).rjust(2, "0"),
V.get("Minute"): str(Now.minute).rjust(2, "0"), V.get("Second"): str(Now.second).rjust(2, "0"),
}.items():
if Key and Key in String:
String = String.replace(Key, Value)
return os.path.expanduser(self.ResolveLookups(String))
def ResolveDir(self, Raw: str) -> str:
return self.Parse(Raw)
return self.ParsePath(Raw)
def DirList(self, Key: str) -> list[str]:
Value = self.Dirs.get(Key, [])
@@ -216,14 +253,14 @@ class Setup:
def ConfFolder(self, PathText: str) -> Path:
"""SaraSortd's ConfFolder: relative paths count from GlobalConf.toml's folder."""
Parsed = self.Parse(str(PathText))
Parsed = self.ParsePath(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("/")
Root = self.ParsePath(str(self.Dirs.get("RootDir", ""))).rstrip("/")
DirPath = DirPath.rstrip("/")
if Root and DirPath.startswith(Root + "/"):
Relative = DirPath[len(Root) + 1:]
@@ -235,7 +272,7 @@ class Setup:
"""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 self.ParsePath(str(Key)).rstrip("/") == DirPath.rstrip("/"):
if isinstance(Value, dict):
Value = Value.get(Kind)
if Value:
@@ -343,9 +380,8 @@ class Setup:
def _DirAccepts(self, OutConf: dict, OutputPath: str, InputPath: str) -> bool:
"""The output directory's own ValidInputDirs, as DecideNewPath checks it."""
Parent = GetConf("ParentDir", OutConf)
for Valid in GetConf("ValidInputDirs", OutConf) or []:
if fnmatch.fnmatchcase(InputPath, self.Parse(str(Valid), Parent=Parent)):
if fnmatch.fnmatchcase(InputPath, self.ParsePath(str(Valid))):
return True
return False
@@ -354,8 +390,7 @@ class Setup:
Own = Entry.get("ValidInputDirs")
if Own is None:
return True
Parent = os.path.basename(OutputPath)
return any(fnmatch.fnmatchcase(InputPath, self.Parse(str(Valid), Parent=Parent)) for Valid in Own)
return any(fnmatch.fnmatchcase(InputPath, self.ParsePath(str(Valid))) for Valid in Own)
def Edges(self) -> list[Edge]:
Edges = []
@@ -580,7 +615,6 @@ class Setup:
return "That rule no longer exists."
Entry = Files[Index]
Own = Entry.get("ValidInputDirs")
Parent = os.path.basename(OutputPath)
if Own is None:
# It took files from every input: list the others explicitly.
Others = [Other for Other in self.ConnectedInputs(OutputPath) if Other != InputPath]
@@ -588,7 +622,7 @@ class Setup:
Own = Entry["ValidInputDirs"]
else:
for Position in reversed(range(len(Own))):
if self.Parse(str(Own[Position]), Parent=Parent) == InputPath:
if self.ParsePath(str(Own[Position])) == InputPath:
del Own[Position]
Message = None
if self._EntryAccepts(Entry.unwrap(), OutputPath, InputPath):
@@ -636,8 +670,7 @@ class Setup:
if Own is None:
Remaining = [self.Shorthand(Other) for Other in Others]
else:
Parent = os.path.basename(OutputPath)
Remaining = [Valid for Valid in Own if self.Parse(str(Valid), Parent=Parent) != InputPath]
Remaining = [Valid for Valid in Own if self.ParsePath(str(Valid)) != InputPath]
Test = dict(Entry.unwrap(), ValidInputDirs=Remaining)
if self._EntryAccepts(Test, OutputPath, InputPath):
Remaining = None # a wildcard still matches this input: can't split it off
@@ -675,20 +708,18 @@ class Setup:
Changed = False
DirValid = Document.get("ValidInputDirs")
if isinstance(DirValid, list):
Parent = Document.get("ParentDir")
for Position in reversed(range(len(DirValid))):
if self.Parse(str(DirValid[Position]), Parent=Parent) == PathText:
if self.ParsePath(str(DirValid[Position])) == PathText:
del DirValid[Position]
Changed = True
Files = Document.get("Files")
Parent = os.path.basename(OutputPath)
for Index in reversed(range(len(Files) if Files is not None else 0)):
Own = Files[Index].get("ValidInputDirs")
if not isinstance(Own, list):
continue
Before = len(Own)
for Position in reversed(range(Before)):
if self.Parse(str(Own[Position]), Parent=Parent) == PathText:
if self.ParsePath(str(Own[Position])) == PathText:
del Own[Position]
if len(Own) != Before:
Changed = True
@@ -729,6 +760,7 @@ class Setup:
Steps.append("None of the input's own patterns match, so it stays where it is.")
return SimulationResult(Steps, None, "Skipped")
Candidates = []
for OutputPath in self.Outputs:
OutConf = self.LoadDirConf(OutputPath, "Out")
if OutConf is None:
@@ -746,24 +778,30 @@ class Setup:
if Count >= Limit:
Steps.append(f"{OutputPath}: full ({Count}/{Limit} files).")
continue
for Entry in OutConf.get("Files", []):
if not self._EntryAccepts(Entry, OutputPath, InputPath):
continue
Pattern = str(Entry.get("Pattern", ""))
if Entry.get("CaseSensitive", 0) == 1:
Match = fnmatch.fnmatchcase(FileName, Pattern)
else:
Match = fnmatch.fnmatch(FileName.lower(), Pattern.lower())
if not Match:
continue
NewName = self.PreviewName(str(Entry.get("NewFileName", "~^")), FileName, OutputPath, Entry, OutConf)
if Entry.get("Overwrite", 1) == 0 and os.path.exists(os.path.join(OutputPath, NewName)):
Steps.append(f"{OutputPath}: {Pattern} matches, but {NewName} exists and Overwrite = 0.")
continue
Action = "moved" if OutConf.get("DeleteOrg", 1) == 1 else "copied"
Steps.append(f"{OutputPath}: {Pattern} matches, so it's {Action} and named {NewName}.")
return SimulationResult(Steps, f"{OutputPath}/{NewName}", "Sorted")
Steps.append(f"{OutputPath}: no pattern matches.")
Candidates.append((OutputPath, OutConf))
# SaraSortd 7.3: specific patterns first, catch-alls ("*") only as a fallback.
for CatchAll in (False, True):
if CatchAll:
Steps.append("No specific pattern takes it; trying catch-alls (\"*\").")
for OutputPath, OutConf in Candidates:
for Entry in OutConf.get("Files", []):
Pattern = str(Entry.get("Pattern", ""))
if IsCatchAll(Pattern) != CatchAll or not self._EntryAccepts(Entry, OutputPath, InputPath):
continue
if Entry.get("CaseSensitive", 0) == 1:
Match = fnmatch.fnmatchcase(FileName, Pattern)
else:
Match = fnmatch.fnmatch(FileName.lower(), Pattern.lower())
if not Match:
continue
NewName = self.PreviewName(str(Entry.get("NewFileName", "~^")), FileName, OutputPath, Entry, OutConf)
if Entry.get("Overwrite", 1) == 0 and os.path.exists(os.path.join(OutputPath, NewName)):
Steps.append(f"{OutputPath}: {Pattern} matches, but {NewName} exists and Overwrite = 0.")
continue
Action = "moved" if OutConf.get("DeleteOrg", 1) == 1 else "copied"
Steps.append(f"{OutputPath}: {Pattern} matches, so it's {Action} and named {NewName}.")
return SimulationResult(Steps, f"{OutputPath}/{NewName}", "Sorted")
Failed = self.SpecialDir("FailedDir")
Steps.append(f"No output takes it, so it goes to {Failed}.")
return SimulationResult(Steps, f"{Failed}/{FileName}" if Failed else None, "Failed")
@@ -813,7 +851,7 @@ class Setup:
for Key in ("InputDir", "OutputDir"):
for Raw in self.DirList(Key):
if Leftover.search(self.ResolveDir(Raw)):
Found.append(("Error", f"{Raw} doesn't resolve (SaraSortd only replaces one level of variables)."))
Found.append(("Error", f"{Raw} uses a %Name% that GlobalConf.toml doesn't define."))
Inputs = list(dict.fromkeys(self.Inputs))
Outputs = list(dict.fromkeys(self.Outputs))
@@ -844,20 +882,14 @@ class Setup:
UsesOwnInputs = UsesOwnInputs or "ValidInputDirs" in Entry
for Missing in ("Pattern", "NewFileName", "NextNum", "NextChar", "CaseSensitive", "Overwrite"):
if Missing not in Entry:
Found.append(("Error", f"{OutputPath}: [[Files]] #{Position + 1} has no {Missing}; SaraSortd will crash on it."))
if Entry.get("Pattern") == "*" and "ValidInputDirs" not in Entry and Position < len(Entries) - 1:
Found.append(("Warning", f"{OutputPath}: [[Files]] #{Position + 1} is a catch-all \"*\", so the entries after it never get a file."))
for InputPath in Inputs:
# SaraSortd tries outputs in OutputDir order and stops at the first match.
CatchAll = None
for FoundEdge in sorted(
(FoundEdge for FoundEdge in Edges if FoundEdge.Input == InputPath),
key=lambda FoundEdge: Outputs.index(FoundEdge.Output),
):
if CatchAll:
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
if Missing != "Pattern" and Daemon and VersionAtLeast(Daemon, (7, 3)):
Found.append(("Info", f"{OutputPath}: [[Files]] #{Position + 1} has no {Missing}; SaraSortd fills it in from the template on its next start."))
else:
Found.append(("Error", f"{OutputPath}: [[Files]] #{Position + 1} has no {Missing}; SaraSortd will crash on it."))
if Daemon and not VersionAtLeast(Daemon, (7, 3)):
Found.append(("Warning", f"SaraSort shows how SaraSortd 7.3 sorts, and SaraSortd {Daemon} differs: a catch-all (\"*\") "
"output listed first takes every file, nested %Name%s don't resolve, and ! & @ ~ ^ in "
"paths get swapped. Update SaraSortd to 7.3."))
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]), "
@@ -870,10 +902,5 @@ class Setup:
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."))
for Raw in self.DirList("InputDir") + self.DirList("OutputDir"):
Odd = [Char for Char in (self.Vars.get(Key) for Key in ("NextNum", "NextChar", "Parent", "OrgFileName", "OrgFileType")) if Char and Char in Raw]
if Odd:
Found.append(("Warning", f"{Raw} contains {', '.join(Odd)}, which SaraSortd swaps for a variable."))
Order = {"Error": 0, "Warning": 1, "Info": 2}
return sorted(Found, key=lambda Item: Order[Item[0]])
+5 -5
View File
@@ -151,12 +151,12 @@ def Backup(Config: AppConfig) -> Optional[Path]:
return BackupPath
def _RemapList(TheSetup: Setup, Values, Remap: dict[str, str], Parent: Optional[str] = None) -> None:
def _RemapList(TheSetup: Setup, Values, Remap: dict[str, str]) -> 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)
Resolved = TheSetup.ParsePath(str(Values[Index]))
if Resolved in Remap:
Values[Index] = TheSetup.Shorthand(Remap[Resolved])
@@ -175,7 +175,7 @@ def ApplyBundle(Config: AppConfig, TheBundle: Bundle, Remap: dict[str, str]) ->
# 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("/")
Resolved = TheSetup.ParsePath(str(Key)).rstrip("/")
if Resolved in Remap:
DirConfs[TheSetup.Shorthand(Remap[Resolved])] = DirConfs[Key]
del DirConfs[Key]
@@ -191,9 +191,9 @@ def ApplyBundle(Config: AppConfig, TheBundle: Bundle, Remap: dict[str, str]) ->
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)
_RemapList(TheSetup, Document.get("ValidInputDirs"), Remap)
for FileEntry in Document.get("Files", []):
_RemapList(TheSetup, FileEntry.get("ValidInputDirs"), Remap, Parent)
_RemapList(TheSetup, FileEntry.get("ValidInputDirs"), Remap)
NewName = os.path.basename(Target)
if NewName != Parent:
if "ParentDir" in Document: