From d0e5eb2086901d74a90ac79c7cdb5e8fc1d5659f Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Thu, 8 Oct 2026 18:18:02 +0200 Subject: [PATCH] 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 --- Sortd.py | 137 ++++++++++++++++++++++++++++------------------- SortdTransfer.py | 10 ++-- 2 files changed, 87 insertions(+), 60 deletions(-) diff --git a/Sortd.py b/Sortd.py index a48f8ed..05e786f 100644 --- a/Sortd.py +++ b/Sortd.py @@ -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]]) diff --git a/SortdTransfer.py b/SortdTransfer.py index 8700106..e92be48 100644 --- a/SortdTransfer.py +++ b/SortdTransfer.py @@ -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: