mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-13 18:28:18 +02:00
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:
1 parent
28252e3101
commit
d0e5eb2086
2 files changed
+87
-60
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user