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 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-07 22:50:11 +02:00
1 parent 60da6dd9ab
commit 918e1d68cf
3 files changed
+232 -37

No files matched your search

+109 -28
View File
@@ -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