Files
SaraSort/FlowPage.py
T
sarahandClaude Opus 5.5 918e1d68cf 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]>
2026-10-07 22:50:11 +02:00

642 lines
30 KiB
Python

"""The flowchart: every input (red), output (blue) and both (purple)
directory as a box, with an arrow per input -> output pair labelled with the
file patterns that travel along it.
Boxes start in columns (inputs left, both middle, outputs right) and can be
dragged; dragged positions are kept in Layout.toml. In edit mode, clicking two
boxes connects them, and clicking an arrow's label edits or removes it.
"""
import math
import os
from typing import Optional
import flet as ft
import flet.canvas as cv
import tomlkit
from Core import ChipButton, ChipButtonStyle, DisplayPath, Heading, LayoutPath, Note, Palette
from Sortd import DirNode, Edge, Setup
from Templates import SaveUserTemplate, SortTemplate
from Widgets import App, DirField, EmptyState, ExpandPath, KindColor, NameField
NodeWidth = 210
NodeHeight = 58
ColumnGap = 330
RowGap = 96
Margin = 40
LabelHeight = 24
CharWidth = 7.5 # Fixedsys at size 12
def LoadLayout() -> dict[str, tuple[float, float]]:
try:
Data = tomlkit.parse(LayoutPath.read_text()).unwrap()
except (OSError, tomlkit.exceptions.TOMLKitError):
return {}
return {Key: (float(Value[0]), float(Value[1])) for Key, Value in Data.get("Positions", {}).items()
if isinstance(Value, list) and len(Value) == 2}
def SaveLayout(Positions: dict[str, tuple[float, float]]) -> None:
LayoutPath.parent.mkdir(parents=True, exist_ok=True)
LayoutPath.write_text(tomlkit.dumps({"Positions": {Key: [round(X), round(Y)] for Key, (X, Y) in Positions.items()}}))
def ClipToBox(CenterX: float, CenterY: float, TowardX: float, TowardY: float) -> tuple[float, float]:
"""Where the line from a box's centre toward a point leaves the box."""
DX, DY = TowardX - CenterX, TowardY - CenterY
if DX == 0 and DY == 0:
return CenterX, CenterY
Scale = min(
(NodeWidth / 2) / abs(DX) if DX else math.inf,
(NodeHeight / 2) / abs(DY) if DY else math.inf,
)
return CenterX + DX * Scale, CenterY + DY * Scale
class FlowPage(ft.Column):
def __init__(self, TheApp: App):
super().__init__(expand=True, spacing=0)
self.App = TheApp
self.EditMode = False
self.Selected: Optional[str] = None
self.Positions: dict[str, tuple[float, float]] = {}
self.Nodes: dict[str, DirNode] = {}
self.EdgeList: list[Edge] = []
self.TheSetup: Optional[Setup] = None
self.NodeControls: dict[str, ft.GestureDetector] = {}
self.DragStart: dict = {}
self.EditButton = ChipButton("Edit mode", self._ToggleEdit, ft.Icons.EDIT_OUTLINED)
self.AddButton = ChipButton("Add directory", self._AddDirectoryDialog, ft.Icons.CREATE_NEW_FOLDER_OUTLINED)
self.AddButton.visible = False
self.Hint = Note("")
self.Canvas = cv.Canvas(shapes=[])
self.Board = ft.Stack([], width=800, height=600)
self.Body = ft.Container(expand=True)
def Legend(Text: str, Color: str) -> ft.Row:
return ft.Row([
ft.Container(width=14, height=14, bgcolor=ft.Colors.with_opacity(0.25, Color), border=ft.Border.all(2, Color), border_radius=3),
ft.Text(Text, size=12),
], spacing=6)
self.controls = [
ft.Container(
ft.Row([
Heading("Flow"),
ft.Container(width=12),
Legend("Input", Palette.Red), Legend("Output", Palette.Blue), Legend("Both", Palette.Mauve),
ft.Container(expand=True),
self.AddButton,
ChipButton("Tidy layout", self._ResetLayout, ft.Icons.AUTO_FIX_HIGH_OUTLINED),
ChipButton("Reload", lambda e: self.Refresh(), ft.Icons.REFRESH),
self.EditButton,
], vertical_alignment=ft.CrossAxisAlignment.CENTER),
padding=ft.Padding.only(left=16, right=16, top=12, bottom=4),
),
ft.Container(self.Hint, padding=ft.Padding.only(left=16, bottom=8)),
ft.Divider(height=1, color=Palette.Surface1),
self.Body,
]
# Loading and layout
def Refresh(self) -> None:
TheSetup, Error = self.App.LoadSetup()
self.TheSetup = TheSetup
if TheSetup is None:
self.Body.content = EmptyState("Can't read SaraSortd's config.", f"{Error}\nCheck the paths in Settings.")
self.update()
return
self.Nodes = TheSetup.Nodes()
self.EdgeList = TheSetup.Edges()
self._Layout()
self._BuildBoard()
self.Body.content = ft.Column(
[ft.Row([self.Board], scroll=ft.ScrollMode.AUTO)],
scroll=ft.ScrollMode.AUTO, expand=True,
) if self.Nodes else EmptyState("No directories yet.", "Turn on Edit mode and add one, or install a template.")
self._UpdateHint()
self.update()
def _AutoPositions(self) -> dict[str, tuple[float, float]]:
Columns: dict[str, list[str]] = {"Input": [], "Both": [], "Output": []}
for PathText, Node in self.Nodes.items():
Columns[Node.Kind].append(PathText)
Used = [Kind for Kind in ("Input", "Both", "Output") if Columns[Kind]]
Result = {}
for ColumnIndex, Kind in enumerate(Used):
for RowIndex, PathText in enumerate(Columns[Kind]):
Result[PathText] = (Margin + ColumnIndex * ColumnGap, Margin + 30 + RowIndex * RowGap)
return Result
def _Layout(self) -> None:
Auto = self._AutoPositions()
Saved = LoadLayout()
self.Positions = {PathText: Saved.get(PathText, Auto[PathText]) for PathText in self.Nodes}
def _ResetLayout(self, e) -> None:
self.Positions = self._AutoPositions()
SaveLayout({})
self._BuildBoard()
self.Board.update()
def _BoardSize(self) -> tuple[float, float]:
if not self.Positions:
return 800, 600
Width = max(X for X, Y in self.Positions.values()) + NodeWidth + Margin + 80
Height = max(Y for X, Y in self.Positions.values()) + NodeHeight + Margin + 40
return max(Width, 800), max(Height, 500)
# Drawing
def _BuildBoard(self) -> None:
self.NodeControls = {PathText: self._NodeControl(Node) for PathText, Node in self.Nodes.items()}
self._Redraw(Update=False)
def _Redraw(self, Update: bool = True) -> None:
Shapes, Labels = self._EdgeDrawing()
self.Canvas.shapes = Shapes
Width, Height = self._BoardSize()
self.Canvas.width, self.Canvas.height = Width, Height
self.Board.width, self.Board.height = Width, Height
for PathText, Control in self.NodeControls.items():
Control.left, Control.top = self.Positions[PathText]
self.Board.controls = [self.Canvas] + Labels + list(self.NodeControls.values())
if Update:
self.Board.update()
def _NodeControl(self, Node: DirNode) -> ft.GestureDetector:
Color = KindColor(Node.Kind)
Selected = Node.Path == self.Selected
Name = os.path.basename(Node.Path) or Node.Path
Exists = os.path.isdir(Node.Path)
Box = ft.Container(
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),
], spacing=2, alignment=ft.MainAxisAlignment.CENTER),
width=NodeWidth, height=NodeHeight,
padding=ft.Padding.symmetric(horizontal=10, vertical=6),
bgcolor=ft.Colors.with_opacity(0.18, Color) if not Selected else ft.Colors.with_opacity(0.4, Color),
border=ft.Border.all(3 if Selected else 2, Palette.Yellow if Selected else Color),
border_radius=6,
tooltip=f"{Node.Path}\n{Node.Kind}" + ("" if Exists else "\n(doesn't exist yet)"),
)
return ft.GestureDetector(
content=Box,
mouse_cursor=ft.MouseCursor.CLICK,
drag_interval=16,
on_tap=lambda e, PathText=Node.Path: self._NodeClicked(PathText),
on_pan_start=lambda e, PathText=Node.Path: self._DragStart(PathText, e),
on_pan_update=lambda e, PathText=Node.Path: self._DragUpdate(PathText, e),
on_pan_end=lambda e: SaveLayout(self.Positions),
)
def _DragStart(self, PathText: str, e) -> None:
self.DragStart = {"Pointer": (e.global_position.x, e.global_position.y), "Box": self.Positions[PathText]}
def _DragUpdate(self, PathText: str, e) -> None:
if not self.DragStart:
return
StartX, StartY = self.DragStart["Pointer"]
BoxX, BoxY = self.DragStart["Box"]
self.Positions[PathText] = (
max(0, BoxX + e.global_position.x - StartX),
max(0, BoxY + e.global_position.y - StartY),
)
self._Redraw()
def _EdgeDrawing(self) -> tuple[list[cv.Shape], list[ft.Control]]:
Shapes: list[cv.Shape] = []
Labels: list[ft.Control] = []
Segments: list = []
Pairs = {(FoundEdge.Input, FoundEdge.Output) for FoundEdge in self.EdgeList}
for FoundEdge in self.EdgeList:
if FoundEdge.Input not in self.Positions or FoundEdge.Output not in self.Positions:
continue
Color = Palette.Overlay0
Paint = ft.Paint(color=Color, stroke_width=2, style=ft.PaintingStyle.STROKE)
AX, AY = self.Positions[FoundEdge.Input]
BX, BY = self.Positions[FoundEdge.Output]
if FoundEdge.Input == FoundEdge.Output:
# Sorting into itself: a loop over the top of the box.
Left, Top = AX + NodeWidth * 0.3, AY
Right = AX + NodeWidth * 0.7
Shapes.append(cv.Path([
cv.Path.MoveTo(Left, Top),
cv.Path.CubicTo(Left - 10, Top - 50, Right + 10, Top - 50, Right, Top),
], paint=Paint))
Shapes.extend(self._ArrowHead(Right + 6, Top - 14, Right, Top, Color))
Labels.append(self._Label(FoundEdge, AX + NodeWidth / 2, Top - 46))
continue
ACX, ACY = AX + NodeWidth / 2, AY + NodeHeight / 2
BCX, BCY = BX + NodeWidth / 2, BY + NodeHeight / 2
# Two arrows between the same boxes (both ways) are pushed apart.
OffsetX = OffsetY = 0.0
if (FoundEdge.Output, FoundEdge.Input) in Pairs:
Length = math.hypot(BCX - ACX, BCY - ACY) or 1
OffsetX, OffsetY = -(BCY - ACY) / Length * 12, (BCX - ACX) / Length * 12
StartX, StartY = ClipToBox(ACX, ACY, BCX, BCY)
EndX, EndY = ClipToBox(BCX, BCY, ACX, ACY)
StartX, StartY, EndX, EndY = StartX + OffsetX, StartY + OffsetY, EndX + OffsetX, EndY + OffsetY
Shapes.append(cv.Line(StartX, StartY, EndX, EndY, paint=Paint))
Shapes.extend(self._ArrowHead(StartX, StartY, EndX, EndY, Color))
Segments.append((FoundEdge, StartX, StartY, EndX, EndY))
Labels.extend(self._PlaceLabels(Segments))
return Shapes, Labels
def _LabelText(self, FoundEdge: Edge) -> tuple[str, str, float]:
Full = ", ".join(FoundEdge.Patterns)
Limit = max(6, self.App.Config.LabelLength)
Short = Full if len(Full) <= Limit else Full[: Limit - 1] + "…"
return Short, Full, len(Short) * CharWidth + 18
def _PlaceLabels(self, Segments: list) -> list[ft.Control]:
"""Each label goes on its own arrow, slid along it until it's clear of
boxes, other labels and other arrows (or as clear as it gets)."""
Boxes = [(X, Y, X + NodeWidth, Y + NodeHeight) for X, Y in self.Positions.values()]
Placed: list[tuple[float, float, float, float]] = []
Labels = []
def Overlaps(A, B) -> bool:
return A[0] < B[2] and B[0] < A[2] and A[1] < B[3] and B[1] < A[3]
def Crossings(Rect, Own) -> int:
Count = 0
for Other in Segments:
if Other is Own:
continue
_, SX, SY, EX, EY = Other
if any(Rect[0] <= SX + (EX - SX) * Step / 24 <= Rect[2] and Rect[1] <= SY + (EY - SY) * Step / 24 <= Rect[3]
for Step in range(25)):
Count += 1
return Count
for Segment in Segments:
FoundEdge, SX, SY, EX, EY = Segment
_, _, Width = self._LabelText(FoundEdge)
Best = None
for Fraction in (0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75):
CX, CY = SX + (EX - SX) * Fraction, SY + (EY - SY) * Fraction
Rect = (CX - Width / 2 - 4, CY - LabelHeight / 2 - 2, CX + Width / 2 + 4, CY + LabelHeight / 2 + 2)
Score = 10 * sum(Overlaps(Rect, Other) for Other in Placed + Boxes) + Crossings(Rect, Segment)
if Best is None or Score < Best[0]:
Best = (Score, CX, CY, Rect)
if Score == 0:
break
Placed.append(Best[3])
Labels.append(self._Label(FoundEdge, Best[1], Best[2]))
return Labels
def _ArrowHead(self, FromX: float, FromY: float, ToX: float, ToY: float, Color: str) -> list[cv.Shape]:
Angle = math.atan2(ToY - FromY, ToX - FromX)
Size = 11
Points = [
(ToX - Size * math.cos(Angle - 0.45), ToY - Size * math.sin(Angle - 0.45)),
(ToX - Size * math.cos(Angle + 0.45), ToY - Size * math.sin(Angle + 0.45)),
]
return [cv.Path([
cv.Path.MoveTo(ToX, ToY), cv.Path.LineTo(*Points[0]), cv.Path.LineTo(*Points[1]), cv.Path.Close(),
], paint=ft.Paint(color=Color, style=ft.PaintingStyle.FILL))]
def _Label(self, FoundEdge: Edge, CenterX: float, CenterY: float) -> ft.Container:
Short, Full, Width = self._LabelText(FoundEdge)
return ft.Container(
ft.Text(Short, size=12, color=Palette.Text, no_wrap=True),
left=CenterX - Width / 2, top=CenterY - LabelHeight / 2, width=Width, height=LabelHeight,
alignment=ft.Alignment.CENTER,
bgcolor=Palette.Mantle, border=ft.Border.all(1, Palette.Mauve if self.EditMode else Palette.Surface1),
border_radius=4, tooltip=Full if Short != Full else None,
on_click=lambda e: self._EdgeDialog(FoundEdge),
)
# Interaction
def _UpdateHint(self) -> None:
if not self.EditMode:
self.Hint.value = "Click a box for details, an arrow's label for all of its patterns. Drag boxes to move them."
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 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:
self.EditMode = not self.EditMode
self.Selected = None
self.EditButton.content = "Done" if self.EditMode else "Edit mode"
self.EditButton.style = ChipButtonStyle(Palette.Yellow if self.EditMode else "")
self.AddButton.visible = self.EditMode
self._UpdateHint()
if self.TheSetup:
self._BuildBoard()
self.update()
def _NodeClicked(self, PathText: str) -> None:
if not self.EditMode:
self._NodeDialog(PathText)
return
if self.Selected is None:
self.Selected = PathText
elif self.Selected == PathText:
self.Selected = None
else:
Source, self.Selected = self.Selected, None
self._ConnectDialog(Source, PathText)
self._UpdateHint()
self._BuildBoard()
self.update()
def _AfterChange(self, GlobalChanged: bool) -> None:
if GlobalChanged:
self.App.AfterGlobalChange()
self.Refresh()
def _ConnectDialog(self, InputPath: str, OutputPath: str, Preset: Optional[list[str]] = None) -> None:
TheSetup = self.TheSetup
Defaults = TheSetup.TemplateFileDefaults()
PatternField = ft.TextField(
label="File types (patterns), separated by commas", value=", ".join(Preset or []),
hint_text="*.png, *.jp*g", autofocus=True, dense=True,
)
Name = NameField(TheSetup, str(Defaults.get("NewFileName", "~^")), OutputPath, Defaults)
Move = ft.Switch(label="Move files (off: copy them)", value=True)
OutConf = TheSetup.LoadDirConf(OutputPath, "Out") or {}
Move.value = OutConf.get("DeleteOrg", 1) == 1
def Save(e) -> None:
Patterns = [Part.strip() for Part in (PatternField.value or "").split(",") if Part.strip()]
if not Patterns:
PatternField.error = "Add at least one pattern, e.g. *.png"
PatternField.update()
return
self.App.Close()
Overrides = {}
if Name.value and Name.value != Defaults.get("NewFileName"):
Overrides["NewFileName"] = Name.value
try:
Changed = TheSetup.Connect(InputPath, OutputPath, Patterns, Overrides, {"DeleteOrg": 1 if Move.value else 0})
except Exception as Error:
self.App.Notify(f"Couldn't connect: {Error}", "error")
return
self._AfterChange(Changed)
self.App.ShowDialog(
"Connect",
ft.Column([
ft.Text(f"{DisplayPath(InputPath)} -> {DisplayPath(OutputPath)}", color=Palette.Mauve, selectable=True),
PatternField,
Note("* is any text, ? one character. Each pattern becomes its own [[Files]] entry; the other "
"settings (NextNum, Overwrite...) come from OutputDirConf.toml."),
Name,
Move,
], tight=True, spacing=10),
[ChipButton("Cancel", lambda e: self.App.Close()), ChipButton("Connect", Save)],
Width=620,
)
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:
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.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:
Messages = TheSetup.Disconnect(FoundEdge.Input, FoundEdge.Output)
if Messages:
self.App.Notify(" ".join(Messages), "warning")
self.Refresh()
def AddMore(e) -> None:
self.App.Close()
self._ConnectDialog(FoundEdge.Input, FoundEdge.Output)
def SaveTemplate(e) -> None:
self.App.Close()
self._SaveTemplateDialog(FoundEdge)
Actions = [ChipButton("Save as template", SaveTemplate, ft.Icons.BOOKMARK_ADD_OUTLINED)]
if self.EditMode:
Actions += [
ChipButton("Add patterns", AddMore, ft.Icons.ADD),
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),
]
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(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=760 if self.EditMode else 620,
)
def _SaveTemplateDialog(self, FoundEdge: Edge) -> None:
NameInput = ft.TextField(label="Template name", value=os.path.basename(FoundEdge.Output), autofocus=True, dense=True)
Description = ft.TextField(label="Description", dense=True)
NewNames = {Rule.NewFileName for Rule in FoundEdge.Rules}
def Save(e) -> None:
if not (NameInput.value or "").strip():
return
self.App.Close()
OutConf = self.TheSetup.LoadDirConf(FoundEdge.Output, "Out") or {}
Settings = {Key: OutConf[Key] for Key in ("DeleteOrg", "Unzip") if Key in OutConf}
Saved = SaveUserTemplate(SortTemplate(
NameInput.value.strip(), Description.value or "", Patterns=FoundEdge.Patterns,
NewFileName=NewNames.pop() if len(NewNames) == 1 else "",
SuggestedOutput=DisplayPath(FoundEdge.Output), OutputSettings=Settings,
))
self.App.Notify(f"Saved {Saved.name} in Templates.", "success")
self.App.ShowDialog("Save as template", ft.Column([NameInput, Description], tight=True),
[ChipButton("Cancel", lambda e: self.App.Close()), ChipButton("Save", Save)], Width=440)
def _NodeDialog(self, PathText: str) -> None:
Node = self.Nodes[PathText]
TheSetup = self.TheSetup
Incoming = [FoundEdge for FoundEdge in self.EdgeList if FoundEdge.Output == PathText]
Outgoing = [FoundEdge for FoundEdge in self.EdgeList if FoundEdge.Input == PathText]
def Lines(Edges: list[Edge], Other: str) -> list[ft.Control]:
return [ft.Text(f"{os.path.basename(getattr(FoundEdge, Other))}: {', '.join(FoundEdge.Patterns)}", size=12, selectable=True)
for FoundEdge in Edges] or [Note("none")]
Kinds = (["In"] if Node.IsInput else []) + (["Out"] if Node.IsOutput else [])
ConfButtons = []
for Kind in dict.fromkeys(Kinds):
ConfPath = TheSetup.ConfFile(PathText, Kind)
if ConfPath.exists():
ConfButtons.append(ChipButton(
f"Edit {'input' if Kind == 'In' else 'output'} config" if len(set(TheSetup.ConfFile(PathText, K) for K in Kinds)) > 1 else "Edit config",
lambda e, ConfPath=ConfPath: (self.App.Close(), self.App.Navigate("Configs", Open=str(ConfPath))),
ft.Icons.TUNE,
))
if len(set(TheSetup.ConfFile(PathText, K) for K in Kinds)) == 1:
break
Actions = ConfButtons + [ChipButton("Open folder", lambda e: self.App.OpenFolder(PathText), ft.Icons.FOLDER_OPEN_OUTLINED)]
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,
ft.Column([
ft.Text(PathText, selectable=True, color=KindColor(Node.Kind)),
Note(f"{Node.Kind}" + ("" if os.path.isdir(PathText) else " - doesn't exist yet")),
ft.Divider(height=1, color=Palette.Surface1),
ft.Text("Receives", color=Palette.Mauve), *Lines(Incoming, "Input"),
ft.Text("Sends", color=Palette.Mauve), *Lines(Outgoing, "Output"),
], tight=True, spacing=6, scroll=ft.ScrollMode.AUTO),
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
Row, Field = DirField(self.App, "Directory")
Kind = ft.RadioGroup(ft.Row([
ft.Radio(value="InputDir", label="Input"), ft.Radio(value="OutputDir", label="Output"),
ft.Radio(value="Both", label="Both"),
]), value="InputDir")
def Add(e) -> None:
PathText = ExpandPath(Field.value or "")
if not PathText:
return
self.App.Close()
Keys = ["InputDir", "OutputDir"] if Kind.value == "Both" else [Kind.value]
try:
Changed = self.TheSetup.AddDirectory(PathText, Keys)
except OSError as Error:
self.App.Notify(f"Couldn't add it: {Error}", "error")
return
self._AfterChange(Changed)
self.App.ShowDialog("Add directory", ft.Column([
Row, Kind,
Note("Its config is made from InputDirConf.toml / OutputDirConf.toml, like SaraSortd does. "
"A new output takes every file (the template's \"*\") until you change it."),
], tight=True, spacing=10), [ChipButton("Cancel", lambda e: self.App.Close()), ChipButton("Add", Add)], Width=560)