mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-13 18:28:18 +02:00
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:
1 parent
60da6dd9ab
commit
918e1d68cf
3 files changed
+232
-37
No files matched your search
+109
-28
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user