From 918e1d68cfcfd7379b5a2ae2668e570e5a146607 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Wed, 7 Oct 2026 22:50:11 +0200 Subject: [PATCH] Flow edit mode: edit arrow rules, delete directories Each rule on an arrow can be edited (pattern, NewFileName, case-sensitive, overwrite); a rule shared with other inputs is split so only this arrow changes. Connecting an input with a pattern an output already has no longer renames it for the other inputs. Boxes get a delete button that removes the directory from SaraSortd (GlobalConf, other outputs' rules, its config and history files) but never the folder itself. Co-Authored-By: Claude Opus 5.5 --- FlowPage.py | 137 +++++++++++++++++++++++++++++++++++++++++----------- README.md | 7 ++- Sortd.py | 125 ++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 232 insertions(+), 37 deletions(-) diff --git a/FlowPage.py b/FlowPage.py index 416ed17..2c61e6e 100644 --- a/FlowPage.py +++ b/FlowPage.py @@ -178,6 +178,11 @@ class FlowPage(ft.Column): ft.Row([ ft.Icon(ft.Icons.FOLDER_OUTLINED if Exists else ft.Icons.FOLDER_OFF_OUTLINED, color=Color, size=16), ft.Text(Name, size=15, color=Palette.Text, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS, expand=True), + *([ft.Container( + ft.Icon(ft.Icons.DELETE_OUTLINE, color=Palette.Red, size=16), + tooltip="Delete from SaraSortd (the folder stays)", border_radius=4, + on_click=lambda e, PathText=Node.Path: self._DeleteDialog(PathText), + )] if self.EditMode else []), ], spacing=6), ft.Text(DisplayPath(os.path.dirname(Node.Path)), size=11, color=Palette.Overlay0, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS), @@ -325,7 +330,8 @@ class FlowPage(ft.Column): elif self.Selected: self.Hint.value = f"Now click the directory {os.path.basename(self.Selected)} should sort into (or it again to cancel)." else: - self.Hint.value = "Edit mode: click a directory, then the one it should sort into. Click a label to change or remove an arrow." + self.Hint.value = ("Edit mode: click a directory, then the one it should sort into. Click a label to edit " + "or remove an arrow's rules; the bin on a box deletes it from SaraSortd.") self.Hint.color = Palette.Yellow if self.EditMode else Palette.Overlay0 def _ToggleEdit(self, e) -> None: @@ -404,28 +410,85 @@ class FlowPage(ft.Column): def _EdgeDialog(self, FoundEdge: Edge) -> None: TheSetup = self.TheSetup + Entries = (TheSetup.LoadDirConf(FoundEdge.Output, "Out") or {}).get("Files", []) + Editors: list[tuple] = [] # (Rule, Entry, PatternField, NameField, CaseSwitch, OverwriteSwitch) Rows = [] for Rule in FoundEdge.Rules: - Scope = "only from this input" if Rule.OwnInputs else "from every input this output accepts" - Row = [ - ft.Text(Rule.Pattern, color=Palette.Mauve, width=150, selectable=True), + Entry = Entries[Rule.Index] if Rule.Index < len(Entries) else {} + Others = [os.path.basename(Other) for Other in TheSetup.RuleInputs(FoundEdge.Output, Rule.Index) + if Other != FoundEdge.Input] + if not self.EditMode: + Scope = f"also from {', '.join(Others)}" if Others else "only from this input" + Rows.append(ft.Row([ + ft.Text(Rule.Pattern, color=Palette.Mauve, width=150, selectable=True), + ft.Column([ft.Text(f"named {Rule.NewFileName or '~^'}", size=12), Note(Scope, 11)], spacing=0, expand=True), + ])) + continue + PatternField = ft.TextField(label="Pattern", value=Rule.Pattern, dense=True, width=220) + CaseSwitch = ft.Switch(label="Case-sensitive", value=Entry.get("CaseSensitive", 0) == 1) + OverwriteSwitch = ft.Switch(label="Overwrite", value=Entry.get("Overwrite", 1) == 1) + Name = NameField(TheSetup, Rule.NewFileName or "~^", FoundEdge.Output, Entry) + Editors.append((Rule, Entry, PatternField, Name, CaseSwitch, OverwriteSwitch)) + Rows.append(ft.Container( ft.Column([ - ft.Text(f"named {Rule.NewFileName or '~^'}", size=12), - Note(Scope, 11), - ], spacing=0, expand=True), - ] - if self.EditMode: - Row.append(ft.IconButton( - ft.Icons.DELETE_OUTLINE, icon_color=Palette.Red, tooltip="Stop sending this pattern here", - on_click=lambda e, Index=Rule.Index: RemoveOne(Index), - )) - Rows.append(ft.Row(Row)) + ft.Row([ + PatternField, CaseSwitch, OverwriteSwitch, ft.Container(expand=True), + ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_color=Palette.Red, tooltip="Stop sending this pattern here", + on_click=lambda e, Index=Rule.Index: RemoveOne(Index)), + ], vertical_alignment=ft.CrossAxisAlignment.CENTER), + Name, + Note(f"Also used by {', '.join(Others)}: changing it here gives this arrow its own copy." if Others + else "Only this arrow uses it.", 11), + ], spacing=6), + padding=10, bgcolor=Palette.Base, border=ft.Border.all(1, Palette.Surface1), border_radius=6, + )) + + def Reopen() -> None: + """After a change: show the same arrow again, if it's still there.""" + self.Refresh() + Again = next((Other for Other in self.EdgeList + if Other.Input == FoundEdge.Input and Other.Output == FoundEdge.Output), None) + if Again: + self._EdgeDialog(Again) def RemoveOne(Index: int) -> None: self.App.Close() Message = TheSetup.RemoveRule(FoundEdge.Input, FoundEdge.Output, Index) if Message: self.App.Notify(Message, "warning") + Reopen() + + def SaveRules(e) -> None: + Edits = [] + for Rule, Entry, PatternField, Name, CaseSwitch, OverwriteSwitch in Editors: + Pattern = (PatternField.value or "").strip() + if not Pattern: + PatternField.error = "A pattern is needed, e.g. *.png" + PatternField.update() + return + Wanted = { + "Pattern": Pattern, + "NewFileName": Name.value or "~^", + "CaseSensitive": 1 if CaseSwitch.value else 0, + "Overwrite": 1 if OverwriteSwitch.value else 0, + } + Changes = {Key: Value for Key, Value in Wanted.items() if Entry.get(Key) != Value} + if Changes: + Edits.append((Rule.Index, Changes)) + self.App.Close() + Messages = [] + # Highest index first: a split inserts a copy, which would shift later entries. + for Index, Changes in sorted(Edits, key=lambda Item: Item[0], reverse=True): + try: + Message = TheSetup.EditRule(FoundEdge.Input, FoundEdge.Output, Index, Changes) + except OSError as Error: + Message = f"Couldn't save: {Error}" + if Message: + Messages.append(Message) + if Messages: + self.App.Notify(" ".join(Messages), "warning") + elif Edits: + self.App.Notify("Saved; SaraSortd uses it on its next check.", "success") self.Refresh() def RemoveAll() -> None: @@ -449,17 +512,21 @@ class FlowPage(ft.Column): ChipButton("Remove arrow", lambda e: (self.App.Close(), self.App.Confirm( "Remove arrow", f"Stop sorting from {DisplayPath(FoundEdge.Input)} into {DisplayPath(FoundEdge.Output)}?", RemoveAll, )), ft.Icons.LINK_OFF, Accent=Palette.Red), + ChipButton("Cancel", lambda e: self.App.Close()), + ChipButton("Save", SaveRules, ft.Icons.SAVE_OUTLINED), ] - Actions.append(ChipButton("Close", lambda e: self.App.Close())) + else: + Actions.append(ChipButton("Close", lambda e: self.App.Close())) + ListHeight = (186 if self.EditMode else 48) * len(Rows) + 8 self.App.ShowDialog( "Arrow", ft.Column([ ft.Text(f"{DisplayPath(FoundEdge.Input)} -> {DisplayPath(FoundEdge.Output)}", color=Palette.Mauve, selectable=True), ft.Divider(height=1, color=Palette.Surface1), - ft.Column(Rows, scroll=ft.ScrollMode.AUTO, height=min(360, 48 * len(Rows) + 8), spacing=4), + ft.Column(Rows, scroll=ft.ScrollMode.AUTO, height=min(460, ListHeight), spacing=8), Note("SaraSortd tries outputs in OutputDir order and patterns top to bottom; the first match wins."), ], tight=True, spacing=8), - Actions, Width=620, + Actions, Width=760 if self.EditMode else 620, ) def _SaveTemplateDialog(self, FoundEdge: Edge) -> None: @@ -506,18 +573,9 @@ class FlowPage(ft.Column): if len(set(TheSetup.ConfFile(PathText, K) for K in Kinds)) == 1: break - def Remove() -> None: - if TheSetup.RemoveDir(PathText): - Positions = LoadLayout() - Positions.pop(PathText, None) - SaveLayout(Positions) - self._AfterChange(True) - Actions = ConfButtons + [ChipButton("Open folder", lambda e: self.App.OpenFolder(PathText), ft.Icons.FOLDER_OPEN_OUTLINED)] - Actions.append(ChipButton("Remove", lambda e: (self.App.Close(), self.App.Confirm( - "Remove directory", - f"Take {DisplayPath(PathText)} out of SaraSortd? The folder, its files and its config stay on disk.", Remove, - )), ft.Icons.DELETE_OUTLINE, Accent=Palette.Red)) + Actions.append(ChipButton("Delete", lambda e: (self.App.Close(), self._DeleteDialog(PathText)), + ft.Icons.DELETE_OUTLINE, Accent=Palette.Red)) Actions.append(ChipButton("Close", lambda e: self.App.Close())) self.App.ShowDialog( os.path.basename(PathText) or PathText, @@ -531,6 +589,29 @@ class FlowPage(ft.Column): Actions, Width=620, ) + def _DeleteDialog(self, PathText: str) -> None: + TheSetup = self.TheSetup + ConfNames = sorted({TheSetup.ConfFile(PathText, Kind).name for Kind in ("In", "Out")}) + + def Delete() -> None: + try: + Changed = TheSetup.DeleteDirectory(PathText) + except OSError as Error: + self.App.Notify(f"Couldn't delete it: {Error}", "error") + return + Positions = LoadLayout() + Positions.pop(PathText, None) + SaveLayout(Positions) + self._AfterChange(Changed) + + self.App.Confirm( + "Delete directory", + f"Delete {DisplayPath(PathText)} from SaraSortd?\n\n" + f"It's taken out of GlobalConf.toml and other outputs' rules, and its config " + f"({', '.join(ConfNames)}) and history file are deleted. The folder and its files stay.", + Delete, "Delete", + ) + def _AddDirectoryDialog(self, e) -> None: if not self.TheSetup: return diff --git a/README.md b/README.md index eb17c63..4cc53bb 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,11 @@ Needs SaraSortd **7.1** or newer for arrows with their own patterns (per-entry ` columns and can be dragged (positions are kept in `Layout.toml`). In **Edit mode**: click a directory, then the one it should sort into, and type the patterns. Each pattern becomes its own `[[Files]]` entry in the output's config, with its own `ValidInputDirs`, and - the other settings come from `OutputDirConf.toml` (the template). Click a label to add or remove - patterns, remove the arrow, or save it as a template. Click a box to remove it or open its config. + the other settings come from `OutputDirConf.toml` (the template). Click a label to edit its rules + (pattern, NewFileName, case-sensitive, overwrite), add or remove patterns, remove the arrow, or save it + as a template. A rule other inputs use too is split when you change it, so only this arrow changes. + The bin on a box deletes the directory from SaraSortd: it leaves GlobalConf.toml and other outputs' + rules, and its config and history files are deleted. The folder and its files stay. - **Service**: running/stopped, start/stop/restart, start on login, SaraSortd's version (and the SaraSuite install it came from), and its logs: its own log files or the systemd journal. - **Configs**: `GlobalConf.toml`, the two templates and every directory's own config, as a form (with diff --git a/Sortd.py b/Sortd.py index e655d09..2d0e75b 100644 --- a/Sortd.py +++ b/Sortd.py @@ -427,14 +427,21 @@ class Setup: OutDoc["Files"] = AoT([]) Files = OutDoc["Files"] Defaults = self.TemplateFileDefaults() + Desired = {"NewFileName": Defaults["NewFileName"], **(EntryOverrides or {})} + Splits: list[str] = [] # patterns this input already gets, but named differently now for Pattern in Patterns: - Existing = next((Entry for Entry in Files if Entry.get("Pattern") == Pattern), None) - if Existing is not None: - Own = Existing.get("ValidInputDirs") - if Own is not None and not self._EntryAccepts(Existing.unwrap(), OutputPath, InputPath): - Own.append(self.Shorthand(InputPath)) - for Key, Value in (EntryOverrides or {}).items(): - Existing[Key] = Value + Current = next((Entry for Entry in Files if Entry.get("Pattern") == Pattern + and self._EntryAccepts(Entry.unwrap(), OutputPath, InputPath)), None) + if Current is not None: + if EntryOverrides and any(Current.get(Key) != Value for Key, Value in EntryOverrides.items()): + Splits.append(Pattern) + continue + # An entry other inputs use with the same pattern and naming can take this input too. + Reusable = next((Entry for Entry in Files if Entry.get("Pattern") == Pattern + and isinstance(Entry.get("ValidInputDirs"), list) + and all(Entry.get(Key) == Value for Key, Value in Desired.items())), None) + if Reusable is not None: + Reusable["ValidInputDirs"].append(self.Shorthand(InputPath)) continue Entry = tomlkit.table() Entry["Pattern"] = Pattern @@ -446,6 +453,11 @@ class Setup: Entry["ValidInputDirs"] = [self.Shorthand(InputPath)] Files.append(Entry) WriteToml(self.ConfFile(OutputPath, "Out"), OutDoc) + for Pattern in Splits: + Entries = (self.LoadDirConf(OutputPath, "Out") or {}).get("Files", []) + Index = next(Position for Position, Entry in enumerate(Entries) if Entry.get("Pattern") == Pattern + and self._EntryAccepts(Entry, OutputPath, InputPath)) + self.EditRule(InputPath, OutputPath, Index, dict(EntryOverrides)) # The input side: it has to let these files through at all. InDoc, IsNew = self.EnsureDirConf(InputPath, "In") @@ -511,6 +523,105 @@ class Setup: Messages.append(Message) return Messages + def RuleInputs(self, OutputPath: str, Index: int) -> list[str]: + """The inputs the [[Files]] entry at Index takes files from.""" + OutConf = self.LoadDirConf(OutputPath, "Out") or {} + Entries = OutConf.get("Files", []) + if Index >= len(Entries): + return [] + return [Input for Input in self.ConnectedInputs(OutputPath) if self._EntryAccepts(Entries[Index], OutputPath, Input)] + + def EditRule(self, InputPath: str, OutputPath: str, Index: int, Changes: dict) -> Optional[str]: + """Changes (Pattern, NewFileName, CaseSensitive, Overwrite) the [[Files]] + entry at Index for the arrow InputPath -> OutputPath. An entry other + inputs use too is split: this arrow gets its own changed copy just + before it (so it matches first), the others keep the original. + Returns a note when it couldn't be split.""" + ConfPath = self.ConfFile(OutputPath, "Out") + Document = ReadToml(ConfPath) + Files = Document.get("Files") + if Files is None or Index >= len(Files): + return "That rule no longer exists." + Entry = Files[Index] + Others = [Input for Input in self.RuleInputs(OutputPath, Index) if Input != InputPath] + Message = None + if Others: + Own = Entry.get("ValidInputDirs") + Remaining = None + 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] + 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 + Message = (f"{Entry.get('Pattern')} reaches this input through a wildcard, so the change " + f"applies to {', '.join(os.path.basename(Other) for Other in Others)} too.") + if Remaining is not None: + Copy = tomlkit.table() + for Key, Value in Entry.unwrap().items(): + Copy[Key] = Value + for Key, Value in Changes.items(): + Copy[Key] = Value + Copy["ValidInputDirs"] = [self.Shorthand(InputPath)] + Entry["ValidInputDirs"] = Remaining + Files.insert(Index, Copy) + WriteToml(ConfPath, Document) + return None + for Key, Value in Changes.items(): + Entry[Key] = Value + WriteToml(ConfPath, Document) + return Message + + def DeleteDirectory(self, PathText: str) -> bool: + """Takes a directory out of SaraSortd completely: out of InputDir and + OutputDir, out of other outputs' ValidInputDirs (entries left with no + input are removed), and its config and history files are deleted. + The directory and its files stay. True if GlobalConf changed.""" + for OutputPath in dict.fromkeys(self.Outputs): + if OutputPath == PathText: + continue + ConfPath = self.ConfFile(OutputPath, "Out") + try: + Document = ReadToml(ConfPath) + except (OSError, tomlkit.exceptions.TOMLKitError): + continue + 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: + 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: + del Own[Position] + if len(Own) != Before: + Changed = True + if len(Own) == 0: + del Files[Index] + if Changed: + 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: + FilePath.unlink(missing_ok=True) + return GlobalChanged + # What would happen to a file def Simulate(self, InputPath: str, FileName: str) -> SimulationResult: