"""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")