mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-13 18:28:18 +02:00
Flowchart of inputs/outputs with editable arrows, service page (status, controls, autostart, version, logs), config editor (form + raw), templates, tools (warnings, filename tester, failed files) and settings with SaraFem-compatible export/import. AppImage build and a SaraSuite manifest in Packaging/. Co-Authored-By: Claude Opus 5.5 <[email protected]>
561 lines
26 KiB
Python
561 lines
26 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),
|
|
], 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 change or remove an arrow."
|
|
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
|
|
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),
|
|
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))
|
|
|
|
def RemoveOne(Index: int) -> None:
|
|
self.App.Close()
|
|
Message = TheSetup.RemoveRule(FoundEdge.Input, FoundEdge.Output, Index)
|
|
if Message:
|
|
self.App.Notify(Message, "warning")
|
|
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),
|
|
]
|
|
Actions.append(ChipButton("Close", lambda e: self.App.Close()))
|
|
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),
|
|
Note("SaraSortd tries outputs in OutputDir order and patterns top to bottom; the first match wins."),
|
|
], tight=True, spacing=8),
|
|
Actions, Width=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
|
|
|
|
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("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 _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)
|