diff --git a/ConfigPage.py b/ConfigPage.py index 4c13ed9..5db7489 100644 --- a/ConfigPage.py +++ b/ConfigPage.py @@ -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")) diff --git a/README.md b/README.md index 4cc53bb..b72395f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Settings.py b/Settings.py index 1c2dfbc..184f0b3 100644 --- a/Settings.py +++ b/Settings.py @@ -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)."), diff --git a/Sortd.py b/Sortd.py index 2d0e75b..a48f8ed 100644 --- a/Sortd.py +++ b/Sortd.py @@ -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.")) diff --git a/SortdTransfer.py b/SortdTransfer.py index 2d3884e..8700106 100644 --- a/SortdTransfer.py +++ b/SortdTransfer.py @@ -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)