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

+12 -7
View File
@@ -308,12 +308,17 @@ class ConfigPage(ft.Row):
self._OpenCurrent()
self.update()
def _KindOf(self, ConfPath: Path) -> str:
def _EntryOf(self, ConfPath: Path) -> tuple[str, str]:
"""(Kind, directory) of a config in the list. The directory is what
the list shows, since a config can live outside its directory."""
for _, Items in self._Entries():
for _, ItemPath, Kind in Items:
for Label, ItemPath, Kind in Items:
if ItemPath == ConfPath:
return Kind
return "Global"
return Kind, Label
return "Global", ""
def _KindOf(self, ConfPath: Path) -> str:
return self._EntryOf(ConfPath)[0]
def _OpenCurrent(self) -> None:
ConfPath = self.Current
@@ -329,12 +334,12 @@ class ConfigPage(ft.Row):
self.Mode = "Raw"
self.App.Notify(f"{ConfPath.name} isn't valid TOML ({Error}); showing it raw.", "warning")
Kind = self._KindOf(ConfPath)
Kind, DirPath = self._EntryOf(ConfPath)
TheSetup = self.TheSetup
OutputStyle = Kind in ("Out", "OutTemplate") or (
Kind == "In" and TheSetup.SharedConfName() and ConfPath.parent.as_posix() in TheSetup.Outputs
Kind == "In" and DirPath in TheSetup.Outputs and TheSetup.SharesConf(DirPath)
)
OutputPath = ConfPath.parent.as_posix() if Kind in ("In", "Out") else "/Output"
OutputPath = DirPath if Kind in ("In", "Out") else "/Output"
FormButton = ChipButton("Form", lambda e: self._SetMode("Form"))
RawButton = ChipButton("Raw", lambda e: self._SetMode("Raw"))
+5 -1
View File
@@ -36,7 +36,11 @@ own settings, and where SaraSortd lives. The SaraSortd tab also exports and impo
configs as one file: GlobalConf.toml, both templates and every directory's own config, comments included.
Importing backs the current configs up to `~/.config/SaraSuite/SaraSort/Backups/` first. If directories it
uses don't exist, you can cancel, give any of them a new path (the configs and arrows follow), or have them
made. Run as an AppImage, the header also has an "Add to app menu"
made. The same tab sets where SaraSortd keeps directory configs (7.2): inside each directory, or all
in one folder like SaraTag's Lexicons (`DirConfDir`, e.g. `Pictures_CatsConfig.toml`), and moves the
existing configs and history files there so no rules are lost. Single directories can also get their own
file through `[DirConfs]` in GlobalConf.toml, which SaraSort reads and edits like any other config.
Run as an AppImage, the header also has an "Add to app menu"
button, like SaraSuite's.
After a change to `GlobalConf.toml` (new inputs or outputs) SaraSort restarts SaraSortd, since SaraSortd
+51
View File
@@ -112,6 +112,49 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None:
SortdFileField = ft.TextField(value=str(Home / "SaraSortd-Configs.toml"), label="SaraSortd export file path")
# Where directory configs live (SaraSortd 7.2's DirConfDir), like SaraTag's Lexicons folder.
try:
CurrentSetup = Setup(Config)
CurrentFolder = str(CurrentSetup.Dirs.get("DirConfDir", "") or "")
except SortdError:
CurrentSetup, CurrentFolder = None, ""
LocationPicker = ft.RadioGroup(
ft.Row([
ft.Radio(value="Inside", label="Inside each directory"),
ft.Radio(value="Folder", label="In one folder"),
]),
value="Folder" if CurrentFolder else "Inside",
disabled=CurrentSetup is None,
)
FolderRow, FolderField = DirField(TheApp, "Config folder (relative paths count from GlobalConf.toml's folder)",
CurrentFolder or "Dirs")
FolderRow.visible = bool(CurrentFolder)
def OnLocationChanged(e) -> None:
FolderRow.visible = LocationPicker.value == "Folder"
FolderRow.update()
LocationPicker.on_change = OnLocationChanged
def OnMoveConfs(e) -> None:
NewFolder = (FolderField.value or "").strip() if LocationPicker.value == "Folder" else ""
if LocationPicker.value == "Folder" and not NewFolder:
TheApp.Notify("Pick a folder for the configs.", "warning")
return
if NewFolder == CurrentFolder:
TheApp.Notify("The configs are already there.", "info")
return
try:
Moved = Setup(Config).MoveDirConfs(NewFolder)
except (OSError, SortdError) as Error:
TheApp.Notify(f"Couldn't move the configs: {Error}", "error")
return
Page.pop_dialog()
OnSaved()
TheApp.AfterGlobalChange()
TheApp.Notify(f"Moved {Moved} file{'s' if Moved != 1 else ''}; directory configs now live "
f"{'in ' + NewFolder if NewFolder else 'inside each directory'}.", "success")
def OnSortdExport(e) -> None:
PathText = (SortdFileField.value or "").strip()
if not PathText:
@@ -206,6 +249,14 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None:
RefreshField,
LogLinesField,
ft.Divider(height=1, color=Palette.Surface1),
_Note("Where SaraSortd keeps each directory's config (and history file). In one folder, the "
"directories stay clean; each config is named after its directory, e.g. "
"Pictures_CatsConfig.toml. Needs SaraSortd 7.2. Moving takes the existing configs along."),
LocationPicker,
FolderRow,
ft.OutlinedButton(content="Move configs", style=ChipButtonStyle(), on_click=OnMoveConfs,
disabled=CurrentSetup is None),
ft.Divider(height=1, color=Palette.Surface1),
_Note("Export/import all of SaraSortd's configs as one file: GlobalConf.toml, both templates "
"and every input/output directory's own config. Importing replaces the current ones "
"(they're backed up first)."),
+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."))
+7
View File
@@ -172,6 +172,13 @@ def ApplyBundle(Config: AppConfig, TheBundle: Bundle, Remap: dict[str, str]) ->
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)