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
+5 -2
View File
@@ -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
+118 -7
View File
@@ -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: