Files
SaraSort/ToolsPage.py
T
sarahandClaude Opus 5.5 f7ed23edf9 SaraSort: Flet GUI for configuring SaraSortd
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]>
2026-10-07 22:23:36 +02:00

192 lines
8.9 KiB
Python

"""Tools: warnings about the current setup, a filename tester that shows where
a file would go, and the files that failed to sort (with a retry)."""
import datetime
import os
import shutil
from pathlib import Path
import flet as ft
from Core import ChipButton, ChipButtonStyle, DisplayPath, Heading, Note, Palette
from Sortd import Setup
from Widgets import App, EmptyState
LevelColors = {"Error": Palette.Red, "Warning": Palette.Yellow, "Info": Palette.Blue}
LevelIcons = {"Error": ft.Icons.ERROR_OUTLINE, "Warning": ft.Icons.WARNING_AMBER, "Info": ft.Icons.INFO_OUTLINE}
def HumanSize(Size: int) -> str:
for Unit in ("B", "KB", "MB", "GB"):
if Size < 1024 or Unit == "GB":
return f"{Size:.0f} {Unit}" if Unit == "B" else f"{Size:.1f} {Unit}"
Size /= 1024
return str(Size)
def FreeName(Folder: Path, Name: str) -> Path:
"""Folder/Name, or Folder/"Name (2)" etc. if that's taken."""
Target = Folder / Name
Stem, Suffix = os.path.splitext(Name)
Count = 2
while Target.exists():
Target = Folder / f"{Stem} ({Count}){Suffix}"
Count += 1
return Target
class ToolsPage(ft.Column):
def __init__(self, TheApp: App):
super().__init__(expand=True, spacing=0)
self.App = TheApp
self.Section = "Warnings"
self.TheSetup: Setup | None = None
self.Buttons = {Name: ChipButton(Name, lambda e, Name=Name: self._Show(Name))
for Name in ("Warnings", "Tester", "Failed files")}
self.Body = ft.Container(expand=True, padding=16)
# Tester
self.TestInput = ft.Dropdown(label="Input directory", dense=True, width=360)
self.TestName = ft.TextField(label="File name", value="Holiday photo.png", dense=True, expand=True,
on_submit=lambda e: self._RunTest())
self.TestResult = ft.Column(spacing=6)
# Failed files
self.RetryInput = ft.Dropdown(label="Retry into", dense=True, width=360)
self.FailedList = ft.ListView(spacing=2, expand=True)
self.controls = [
ft.Container(
ft.Row([Heading("Tools"), ft.Container(width=12), *self.Buttons.values(), ft.Container(expand=True),
ChipButton("Reload", lambda e: self.Refresh(), ft.Icons.REFRESH)]),
padding=ft.Padding.only(left=16, right=16, top=12, bottom=8),
),
ft.Divider(height=1, color=Palette.Surface1),
self.Body,
]
def Refresh(self) -> None:
self.TheSetup, Error = self.App.LoadSetup()
if self.TheSetup is None:
self.Body.content = EmptyState("Can't read SaraSortd's config.", Error)
self.update()
return
Inputs = list(dict.fromkeys(self.TheSetup.Inputs))
for Picker in (self.TestInput, self.RetryInput):
Current = Picker.value
Picker.options = [ft.DropdownOption(key=PathText, text=DisplayPath(PathText)) for PathText in Inputs]
Picker.value = Current if Current in Inputs else (Inputs[0] if Inputs else None)
self._Show(self.Section)
def _Show(self, Name: str) -> None:
self.Section = Name
for ButtonName, Button in self.Buttons.items():
Button.style = ChipButtonStyle(Palette.Mauve if ButtonName == Name else Palette.Surface1)
if self.TheSetup is not None:
self.Body.content = {"Warnings": self._Warnings, "Tester": self._Tester, "Failed files": self._Failed}[Name]()
self.update()
# Warnings
def _Warnings(self) -> ft.Control:
Found = self.TheSetup.Warnings()
if not Found:
return EmptyState("Nothing to warn about.", "SaraSortd's setup looks fine.")
Counts = {Level: sum(1 for Item in Found if Item[0] == Level) for Level in LevelColors}
return ft.Column([
Note(f"{Counts['Error']} errors, {Counts['Warning']} warnings, {Counts['Info']} notes"),
ft.ListView([
ft.Container(
ft.Row([
ft.Icon(LevelIcons[Level], color=LevelColors[Level], size=18),
ft.Text(Text, size=13, selectable=True, expand=True),
], vertical_alignment=ft.CrossAxisAlignment.START),
padding=10, bgcolor=Palette.Mantle, border=ft.Border.only(left=ft.BorderSide(3, LevelColors[Level])),
border_radius=4,
)
for Level, Text in Found
], spacing=6, expand=True),
], expand=True)
# Tester
def _Tester(self) -> ft.Control:
return ft.Column([
Note("Where would a file go? Follows SaraSortd's own rules without moving anything."),
ft.Row([self.TestInput, self.TestName, ChipButton("Test", lambda e: self._RunTest(), ft.Icons.PLAY_ARROW)]),
ft.Divider(height=1, color=Palette.Surface1),
ft.Column([self.TestResult], scroll=ft.ScrollMode.AUTO, expand=True),
], expand=True, spacing=10)
def _RunTest(self) -> None:
if not self.TestInput.value or not (self.TestName.value or "").strip():
return
Result = self.TheSetup.Simulate(self.TestInput.value, self.TestName.value.strip())
Color = {"Sorted": Palette.Green, "Failed": Palette.Red, "Skipped": Palette.Yellow}[Result.Outcome]
self.TestResult.controls = [
ft.Text({"Sorted": "Sorted to", "Failed": "Fails, goes to", "Skipped": "Not sorted"}[Result.Outcome], color=Color, size=16),
ft.Text(DisplayPath(Result.Destination) if Result.Destination else "-", selectable=True, size=15),
ft.Container(height=6),
*[ft.Text(f"{Index + 1}. {Step}", size=12, color=Palette.Overlay0, selectable=True) for Index, Step in enumerate(Result.Steps)],
]
self.TestResult.update()
# Failed files
def _FailedDir(self) -> Path | None:
return self.TheSetup.SpecialDir("FailedDir")
def _Failed(self) -> ft.Control:
FailedDir = self._FailedDir()
Files = sorted((Item for Item in FailedDir.iterdir() if not Item.name.startswith(".")),
key=lambda Item: Item.stat().st_mtime, reverse=True) if FailedDir and FailedDir.is_dir() else []
Rows = []
for Item in Files:
Stat = Item.stat()
Rows.append(ft.Container(ft.Row([
ft.Icon(ft.Icons.FOLDER_OUTLINED if Item.is_dir() else ft.Icons.INSERT_DRIVE_FILE_OUTLINED, size=18, color=Palette.Overlay0),
ft.Text(Item.name, size=13, expand=True, selectable=True, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS),
Note(HumanSize(Stat.st_size) if Item.is_file() else "folder", 11),
Note(datetime.datetime.fromtimestamp(Stat.st_mtime).strftime("%Y-%m-%d %H:%M"), 11),
ft.IconButton(ft.Icons.TROUBLESHOOT, tooltip="Test where it would go now",
on_click=lambda e, Name=Item.name: self._TestFailed(Name)),
ft.IconButton(ft.Icons.REPLAY, tooltip="Move back into the input to retry",
on_click=lambda e, Item=Item: self._Retry([Item])),
]), padding=ft.Padding.symmetric(horizontal=8), bgcolor=Palette.Mantle, border_radius=4))
self.FailedList.controls = Rows or [Note("No failed files.")]
return ft.Column([
ft.Row([
ft.Text(DisplayPath(str(FailedDir)) if FailedDir else "No FailedDir set", color=Palette.Mauve, expand=True),
self.RetryInput,
ChipButton("Retry all", lambda e: self._Retry(Files), ft.Icons.REPLAY, Disabled=not Files),
ChipButton("Open folder", lambda e: self.App.OpenFolder(str(FailedDir)), ft.Icons.FOLDER_OPEN_OUTLINED,
Disabled=not (FailedDir and FailedDir.is_dir())),
]),
Note("Retrying moves files back into the input; if no output takes them, SaraSortd puts them back here. "
"Fix the arrows first (the test button shows what would happen)."),
self.FailedList,
], expand=True, spacing=10)
def _TestFailed(self, Name: str) -> None:
self.TestName.value = Name
if self.RetryInput.value:
self.TestInput.value = self.RetryInput.value
self._Show("Tester")
self._RunTest()
def _Retry(self, Items: list[Path]) -> None:
Target = self.RetryInput.value
if not Target:
self.App.Notify("Pick an input to retry into.", "warning")
return
Moved = 0
for Item in Items:
try:
shutil.move(str(Item), str(FreeName(Path(Target), Item.name)))
Moved += 1
except OSError as Error:
self.App.Notify(f"Couldn't move {Item.name}: {Error}", "error")
if Moved:
self.App.Notify(f"Moved {Moved} file{'s' if Moved != 1 else ''} back into {DisplayPath(Target)}.", "success")
self._Show("Failed files")