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]>
192 lines
8.9 KiB
Python
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")
|