mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-14 18:18:12 +02:00
Add SaraSortd integration: show incoming/outgoing sort rules for the browsed directory
This commit is contained in:
1 parent
31b6c554e6
commit
d588d445b4
4 files changed
+436
-9
No files matched your search
+135
-9
@@ -16,6 +16,7 @@ from typing import Callable, Optional
|
||||
|
||||
import flet as ft
|
||||
import send2trash
|
||||
import toml
|
||||
from PIL import Image
|
||||
|
||||
from Bookmarks import AddBookmark
|
||||
@@ -24,10 +25,11 @@ from Bookmarks import IconChoices as BookmarkIconChoices
|
||||
from Bookmarks import IconLabelOverrides
|
||||
from Books import ClearSaraTagCache, IsInternalSaraTagFile
|
||||
from Core import (
|
||||
AppConfig, ApplyHeightBounds, ChipButtonStyle, DragGroup, FormatBatchName, NotificationStyle, Palette,
|
||||
ResizeHandle,
|
||||
AppConfig, ApplyHeightBounds, ChipButtonStyle, DragGroup, ExportSettings, FormatBatchName, ImportSettings,
|
||||
NotificationStyle, Palette, ResizeHandle,
|
||||
)
|
||||
from Preview import HumanSize, Preview, TextExtensions
|
||||
from SaraSortdInfo import GetSortRules
|
||||
from Settings import BuildSettingsDialog
|
||||
from Tags import TagPanel
|
||||
from Thumbnails import GetThumbnail, IsThumbnailable
|
||||
@@ -299,6 +301,18 @@ class Browser(ft.Row):
|
||||
expand=True,
|
||||
)
|
||||
self.EntryList = self._MakeEntryListContainer()
|
||||
# SaraSortd integration (Features/Major): "*.png -> ~/Pictures" style
|
||||
# lines for the currently-browsed directory. Mutated in place by
|
||||
# _RefreshSortBanner on every NavigateTo, rather than rebuilt as part
|
||||
# of _BuildChrome, since a plain directory change doesn't otherwise
|
||||
# touch FileListColumn.controls.
|
||||
self.SortBanner = ft.Container(
|
||||
visible=False,
|
||||
padding=ft.Padding.symmetric(horizontal=12, vertical=6),
|
||||
bgcolor=Palette.Surface0,
|
||||
border=ft.Border.all(1, Palette.Surface1),
|
||||
content=ft.Column([], spacing=2),
|
||||
)
|
||||
# One shared popup host, opened programmatically (see _RequestMenu) so
|
||||
# a right-click resolves to exactly one menu. Its built-in triggers
|
||||
# can't be switched off: Flet 1.0.1's client reads a None trigger as
|
||||
@@ -483,6 +497,7 @@ class Browser(ft.Row):
|
||||
# bottom toolbar sat right under the last entry. Inside that fixed
|
||||
# height, the inner Container's bgcolor makes it paint, and so
|
||||
# hit-test, everywhere, including below the last entry.
|
||||
Items.append(self.SortBanner)
|
||||
Items.append(
|
||||
ft.Container(
|
||||
expand=True,
|
||||
@@ -543,6 +558,8 @@ class Browser(ft.Row):
|
||||
# unbounded/scrollable axis and an expanding child on that same axis
|
||||
# don't mix. Right-alignment happens one level up instead, via the
|
||||
# Container ActionsToolbar sits in.
|
||||
if self.Config.ToolbarHamburger:
|
||||
return self._BuildHamburgerToolbar()
|
||||
Items: list[ft.Control] = []
|
||||
if self.Config.ShowNewDirectoryButton:
|
||||
Items.append(self.NewDirectoryButton)
|
||||
@@ -554,6 +571,40 @@ class Browser(ft.Row):
|
||||
Items.append(self.OptionsButton)
|
||||
return Items
|
||||
|
||||
def _BuildHamburgerToolbar(self) -> list[ft.Control]:
|
||||
"""Same buttons, collapsed into one overflow menu (Features/Minor:
|
||||
"Hamburger menu option instead of buttons for toolbar")."""
|
||||
Items: list[ft.PopupMenuItem] = []
|
||||
if self.Config.ShowNewDirectoryButton:
|
||||
Items.append(
|
||||
ft.PopupMenuItem(
|
||||
content="New Directory",
|
||||
icon=ft.Icons.CREATE_NEW_FOLDER_OUTLINED,
|
||||
on_click=lambda e: self._ShowNewDirectoryDialog(),
|
||||
)
|
||||
)
|
||||
if self.Config.ShowPasteButton:
|
||||
Items.append(
|
||||
ft.PopupMenuItem(content="Paste", icon=ft.Icons.CONTENT_PASTE_OUTLINED, on_click=lambda e: self._Paste())
|
||||
)
|
||||
if self.Config.ShowViewToggleButton:
|
||||
Items.append(
|
||||
ft.PopupMenuItem(
|
||||
content="Grid" if self.Config.ViewMode == "list" else "List",
|
||||
icon=ft.Icons.GRID_VIEW_OUTLINED if self.Config.ViewMode == "list" else ft.Icons.VIEW_LIST_OUTLINED,
|
||||
on_click=lambda e: self._ToggleViewMode(),
|
||||
)
|
||||
)
|
||||
if self.Config.ShowOptionsButton:
|
||||
Items.append(
|
||||
ft.PopupMenuItem(
|
||||
content="Options", icon=ft.Icons.SETTINGS_OUTLINED, on_click=lambda e: self._ShowSettingsDialog()
|
||||
)
|
||||
)
|
||||
if not Items:
|
||||
return []
|
||||
return [ft.PopupMenuButton(icon=ft.Icons.MENU, items=Items)]
|
||||
|
||||
def RefreshToolbarVisibility(self) -> None:
|
||||
self.ActionsToolbar.controls = self._BuildActionsToolbar()
|
||||
self.ActionsToolbar.update()
|
||||
@@ -580,6 +631,7 @@ class Browser(ft.Row):
|
||||
self.ViewToggleButton.icon = (
|
||||
ft.Icons.VIEW_LIST_OUTLINED if self.Config.ViewMode == "grid" else ft.Icons.GRID_VIEW_OUTLINED
|
||||
)
|
||||
self.RefreshToolbarVisibility() # the hamburger menu's "Grid"/"List" label needs the same refresh
|
||||
|
||||
self.EntryList = self._MakeEntryListContainer()
|
||||
self.EntryContextMenu.content = self._BuildEntryListContent()
|
||||
@@ -748,8 +800,26 @@ class Browser(ft.Row):
|
||||
|
||||
def _Refresh(self) -> None:
|
||||
self.Breadcrumbs.controls = self._BuildBreadcrumbs(self.CurrentPath)
|
||||
self._RefreshSortBanner()
|
||||
self._RefreshEntries()
|
||||
|
||||
def _RefreshSortBanner(self) -> None:
|
||||
Lines = GetSortRules(self.CurrentPath, self.Config)
|
||||
if not Lines:
|
||||
self.SortBanner.visible = False
|
||||
return
|
||||
self.SortBanner.visible = True
|
||||
self.SortBanner.content = ft.Column(
|
||||
[
|
||||
ft.Row(
|
||||
[ft.Icon(ft.Icons.SYNC_ALT, size=14, color=Palette.Mauve), ft.Text(Line, size=self.MetaFontSize, color=Palette.Text)],
|
||||
spacing=6,
|
||||
)
|
||||
for Line in Lines
|
||||
],
|
||||
spacing=2,
|
||||
)
|
||||
|
||||
def _RefreshEntries(self) -> None:
|
||||
self.EntryList.controls = self._BuildEntries(self.CurrentPath)
|
||||
self.update()
|
||||
@@ -820,7 +890,20 @@ class Browser(ft.Row):
|
||||
return [self._BuildListRow(Entry) for Entry in Entries]
|
||||
|
||||
MinColumnWidth = 40 # floor for header-divider drags, so a column can't vanish
|
||||
ListHeaderHeight = 28 # 12px text + 2*6px vertical padding — see _BuildListHeader
|
||||
|
||||
@property
|
||||
def MetaFontSize(self) -> int:
|
||||
"""Secondary text (metadata columns, the header) scales with the
|
||||
configurable name font size but stays a couple sizes smaller, same
|
||||
visual hierarchy as the fixed 14/12 before it was configurable."""
|
||||
return max(9, self.Config.FontSize - 2)
|
||||
|
||||
@property
|
||||
def ListHeaderHeight(self) -> int:
|
||||
"""Text line height + 2*6px vertical padding — see _BuildListHeader.
|
||||
Derived from MetaFontSize (not fixed) so the header's own resize
|
||||
divider still matches its actual height at any FontSize."""
|
||||
return self.MetaFontSize + 16
|
||||
|
||||
def _MetadataCell(self, Text: str, Width: int) -> tuple[ft.Container, ft.GestureDetector]:
|
||||
"""A fixed-width metadata column (Size/Modified/...): left-click
|
||||
@@ -829,7 +912,7 @@ class Browser(ft.Row):
|
||||
things than name shouldn't be right-clickable... it should behave
|
||||
like you're right-clicking the background")."""
|
||||
Cell = ft.Container(
|
||||
content=ft.Text(Text, size=12, color=Palette.Overlay0, overflow=ft.TextOverflow.ELLIPSIS),
|
||||
content=ft.Text(Text, size=self.MetaFontSize, color=Palette.Overlay0, overflow=ft.TextOverflow.ELLIPSIS),
|
||||
width=Width,
|
||||
padding=ft.Padding.symmetric(horizontal=8, vertical=8),
|
||||
)
|
||||
@@ -849,10 +932,11 @@ class Browser(ft.Row):
|
||||
# after the last shown column, before the triple-dot (still pinned
|
||||
# right, as before), is the background gap.
|
||||
NameLabel = ft.Text(
|
||||
Entry.name, size=14, color=Palette.Text, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1
|
||||
Entry.name, size=self.Config.FontSize, color=Palette.Text,
|
||||
overflow=ft.TextOverflow.ELLIPSIS, max_lines=1,
|
||||
)
|
||||
NameCell = ft.Container(
|
||||
content=ft.Row([self._LeadingFor(Entry, Size=32), NameLabel], spacing=12),
|
||||
content=ft.Row([self._LeadingFor(Entry, Size=self.Config.ListIconSize), NameLabel], spacing=12),
|
||||
padding=ft.Padding.symmetric(horizontal=12, vertical=8),
|
||||
bgcolor=Palette.Surface0 if Entry in self.SelectedPaths else None,
|
||||
width=self.Config.ListWidthName,
|
||||
@@ -943,7 +1027,7 @@ class Browser(ft.Row):
|
||||
PreviousField: Optional[str] = None
|
||||
for Label, FieldName, LeftPad in Fields:
|
||||
Cell = ft.Container(
|
||||
ft.Text(Label, size=12, color=Palette.Overlay0),
|
||||
ft.Text(Label, size=self.MetaFontSize, color=Palette.Overlay0),
|
||||
width=getattr(self.Config, f"ListWidth{FieldName}"),
|
||||
padding=ft.Padding.only(left=LeftPad, right=8),
|
||||
)
|
||||
@@ -962,7 +1046,7 @@ class Browser(ft.Row):
|
||||
def _BuildGridCell(self, Entry: Path) -> ft.Control:
|
||||
IconControl = self._LeadingFor(Entry, Size=self.Config.GridIconSize)
|
||||
NameControl = ft.Text(
|
||||
Entry.name, size=11, text_align=ft.TextAlign.CENTER,
|
||||
Entry.name, size=self.Config.FontSize, text_align=ft.TextAlign.CENTER,
|
||||
max_lines=2, overflow=ft.TextOverflow.ELLIPSIS,
|
||||
)
|
||||
CellItems = [IconControl, NameControl] if self.Config.GridNameBelowIcon else [NameControl, IconControl]
|
||||
@@ -1017,6 +1101,7 @@ class Browser(ft.Row):
|
||||
ft.PopupMenuItem(
|
||||
content="Cut", on_click=lambda e, p=Entry: self._SetClipboard(self._TargetSelection(p), "cut")
|
||||
),
|
||||
ft.PopupMenuItem(content="Copy Path", on_click=lambda e, p=Entry: self._HandleCopyPath(p)),
|
||||
ft.PopupMenuItem(content="Properties", on_click=lambda e, p=Entry: self._HandleProperties(p)),
|
||||
ft.PopupMenuItem(content="Delete", on_click=lambda e, p=Entry: self._HandleDelete(p)),
|
||||
ft.PopupMenuItem(content="Compress", on_click=lambda e, p=Entry: self._HandleCompress(p)),
|
||||
@@ -1146,6 +1231,8 @@ class Browser(ft.Row):
|
||||
self._ToggleDotfiles()
|
||||
elif Event.key == "T" and Event.ctrl:
|
||||
self._ToggleTags()
|
||||
elif Event.key == "C" and Event.ctrl and Event.shift and self.SelectedPaths:
|
||||
self._HandleCopyPath(self.SelectedPath)
|
||||
elif Event.key == "C" and Event.ctrl and self.SelectedPaths:
|
||||
self._SetClipboard(sorted(self.SelectedPaths, key=lambda p: p.name.lower()), "copy")
|
||||
elif Event.key == "X" and Event.ctrl and self.SelectedPaths:
|
||||
@@ -1165,6 +1252,17 @@ class Browser(ft.Row):
|
||||
self.PasteButton.disabled = False
|
||||
self.update()
|
||||
|
||||
def _HandleCopyPath(self, Entry: Optional[Path]) -> None:
|
||||
if Entry is None:
|
||||
return
|
||||
Targets = self._TargetSelection(Entry)
|
||||
Text = "\n".join(str(P) for P in Targets)
|
||||
self.page.run_task(self._DoCopyPath, Text, len(Targets))
|
||||
|
||||
async def _DoCopyPath(self, Text: str, Count: int) -> None:
|
||||
await ft.Clipboard().set(Text)
|
||||
self._Notify(f"Copied {Count} path(s)." if Count > 1 else "Copied path.", Kind="success")
|
||||
|
||||
def _Paste(self) -> None:
|
||||
if not self.ClipboardPaths:
|
||||
return
|
||||
@@ -1418,6 +1516,7 @@ class Browser(ft.Row):
|
||||
self.Config.ViewMode,
|
||||
*(getattr(self.Config, ConfigFlag) for ConfigFlag, _Name, _Fn in ListColumns),
|
||||
self.Config.GridIconSize, self.Config.GridNameBelowIcon,
|
||||
self.Config.ListIconSize, self.Config.FontSize,
|
||||
)
|
||||
|
||||
def _ShowSettingsDialog(self) -> None:
|
||||
@@ -1439,6 +1538,7 @@ class Browser(ft.Row):
|
||||
)
|
||||
self.EntryList = self._MakeEntryListContainer()
|
||||
self.EntryContextMenu.content = self._BuildEntryListContent()
|
||||
self._RefreshSortBanner() # a just-toggled Enabled/GlobalConfPath should take effect immediately
|
||||
self.FileListColumn.controls = self._BuildChrome()
|
||||
self.FileListColumn.update()
|
||||
self.controls = self._BuildLayout()
|
||||
@@ -1450,7 +1550,33 @@ class Browser(ft.Row):
|
||||
self.OnSidebarSettingsChanged()
|
||||
self._Notify("Options saved.", Kind="success")
|
||||
|
||||
Dialog = BuildSettingsDialog(self.Config, OnSave=OnSave, OnDismiss=self._DismissDialog)
|
||||
def OnExport(PathText: str) -> None:
|
||||
if not PathText:
|
||||
return
|
||||
try:
|
||||
ExportSettings(self.Config, Path(PathText))
|
||||
except OSError as Error:
|
||||
self._Notify(f"Couldn't export settings: {Error}")
|
||||
return
|
||||
self._Notify("Settings exported.", Kind="success")
|
||||
|
||||
def OnImport(PathText: str) -> None:
|
||||
if not PathText:
|
||||
return
|
||||
try:
|
||||
ImportSettings(self.Config, Path(PathText))
|
||||
except (OSError, toml.TomlDecodeError) as Error:
|
||||
self._Notify(f"Couldn't import settings: {Error}")
|
||||
return
|
||||
# The open dialog's widgets still show the pre-import values —
|
||||
# closing it rather than leaving it open to a stale Save that
|
||||
# would immediately overwrite what was just imported.
|
||||
self._DismissDialog()
|
||||
self._Notify("Settings imported — reopen Options to see them, restart for Theme/Font/Title bar.", Kind="success")
|
||||
|
||||
Dialog = BuildSettingsDialog(
|
||||
self.Config, OnSave=OnSave, OnDismiss=self._DismissDialog, OnExport=OnExport, OnImport=OnImport
|
||||
)
|
||||
self._ShowDialog(Dialog)
|
||||
|
||||
def _HandleProperties(self, Entry: Path) -> None:
|
||||
|
||||
@@ -155,6 +155,16 @@ class SaraTagIntegrationConfig:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SaraSortdIntegrationConfig:
|
||||
"""Points at a SaraSortd checkout's GlobalConf.toml (read-only — SaraFem
|
||||
never writes to it). Disabled until pointed at one, e.g.
|
||||
GlobalConfPath = "/home/sarah/Applications/SaraSortd/GlobalConf.toml"."""
|
||||
|
||||
Enabled: bool = False
|
||||
GlobalConfPath: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
"""Persisted user settings. Defaults match SaraFem's existing behaviour
|
||||
@@ -192,6 +202,9 @@ class AppConfig:
|
||||
# Grid view.
|
||||
GridIconSize: int = 64
|
||||
GridNameBelowIcon: bool = True
|
||||
ListIconSize: int = 32
|
||||
FontSize: int = 14 # entry name text, list and grid alike; metadata/header text scales with it
|
||||
ToolbarHamburger: bool = False # collapse the toolbar's buttons into one overflow menu
|
||||
|
||||
# Batch-rename pattern for multi-select rename, syntax modeled on
|
||||
# SaraSortd's own placeholder characters (see ~/Applications/SaraSortd's
|
||||
@@ -257,6 +270,7 @@ class AppConfig:
|
||||
# MaxHeight when on, since a fixed height and an even flex split conflict.
|
||||
SplitPreviewTagsEvenly: bool = False
|
||||
SaraTag: SaraTagIntegrationConfig = field(default_factory=SaraTagIntegrationConfig)
|
||||
SaraSortd: SaraSortdIntegrationConfig = field(default_factory=SaraSortdIntegrationConfig)
|
||||
|
||||
def Save(self) -> None:
|
||||
ConfigDir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -285,6 +299,11 @@ def LoadConfig() -> AppConfig:
|
||||
SaraTagFiltered = {Key: Value for Key, Value in SaraTagData.items() if Key in KnownSaraTagFields}
|
||||
Filtered["SaraTag"] = SaraTagIntegrationConfig(**SaraTagFiltered)
|
||||
|
||||
SaraSortdData = Filtered.pop("SaraSortd", {})
|
||||
KnownSaraSortdFields = {Field for Field in SaraSortdIntegrationConfig.__dataclass_fields__}
|
||||
SaraSortdFiltered = {Key: Value for Key, Value in SaraSortdData.items() if Key in KnownSaraSortdFields}
|
||||
Filtered["SaraSortd"] = SaraSortdIntegrationConfig(**SaraSortdFiltered)
|
||||
|
||||
return AppConfig(**Filtered)
|
||||
|
||||
|
||||
@@ -330,6 +349,62 @@ def EffectiveSettings(Config: AppConfig) -> SharedSettings:
|
||||
return LoadSharedSettings(Config) if Config.UseSharedSettings else _OwnSettings(Config)
|
||||
|
||||
|
||||
def ExportSettings(Config: AppConfig, DestPath: Path) -> None:
|
||||
"""One portable file bundling both the cross-program shared settings
|
||||
(Theme/Font/TitleBar) and SaraFem's own, under separate top-level
|
||||
sections (Features/Major: "Exporting and importing settings as one
|
||||
file... applies to every other SaraSuite program"). A future SaraView/
|
||||
SaraTag/etc. import would read the same [SaraSuite] section plus its
|
||||
own top-level one, ignoring [SaraFem] — the same convention
|
||||
ImportSettings below uses to ignore sections that aren't its own."""
|
||||
Data = {"SaraSuite": asdict(_OwnSettings(Config)), "SaraFem": asdict(Config)}
|
||||
DestPath.write_text(toml.dumps(Data))
|
||||
|
||||
|
||||
def ImportSettings(Config: AppConfig, SrcPath: Path) -> None:
|
||||
"""Applies a [SaraSuite] section (if present) to the shared settings
|
||||
file and a [SaraFem] section (if present) onto Config in place, field
|
||||
by field — a field the import doesn't mention is left as it was, rather
|
||||
than reset to default, and unknown sections/fields (another program's,
|
||||
or a newer SaraFem version's) are silently skipped: the "should stack
|
||||
fine with others if installed or not... one file works anywhere on the
|
||||
same version no matter what you have installed" compatibility the
|
||||
feature asked for. Raises toml.TomlDecodeError on an unreadable file —
|
||||
callers show that to the user rather than silently doing nothing."""
|
||||
Data = toml.loads(SrcPath.read_text())
|
||||
|
||||
SharedData = Data.get("SaraSuite")
|
||||
if isinstance(SharedData, dict):
|
||||
KnownSharedFields = {F for F in SharedSettings.__dataclass_fields__}
|
||||
Filtered = {K: V for K, V in SharedData.items() if K in KnownSharedFields}
|
||||
Merged = asdict(_OwnSettings(Config))
|
||||
Merged.update(Filtered)
|
||||
SharedSettings(**Merged).Save()
|
||||
|
||||
OwnData = Data.get("SaraFem")
|
||||
if isinstance(OwnData, dict):
|
||||
KnownFields = {F for F in AppConfig.__dataclass_fields__}
|
||||
Filtered = {K: V for K, V in OwnData.items() if K in KnownFields}
|
||||
|
||||
SaraTagData = Filtered.pop("SaraTag", None)
|
||||
if isinstance(SaraTagData, dict):
|
||||
KnownSaraTagFields = {F for F in SaraTagIntegrationConfig.__dataclass_fields__}
|
||||
SaraTagFiltered = {K: V for K, V in SaraTagData.items() if K in KnownSaraTagFields}
|
||||
for Key, Value in SaraTagFiltered.items():
|
||||
setattr(Config.SaraTag, Key, Value)
|
||||
|
||||
SaraSortdData = Filtered.pop("SaraSortd", None)
|
||||
if isinstance(SaraSortdData, dict):
|
||||
KnownSaraSortdFields = {F for F in SaraSortdIntegrationConfig.__dataclass_fields__}
|
||||
SaraSortdFiltered = {K: V for K, V in SaraSortdData.items() if K in KnownSaraSortdFields}
|
||||
for Key, Value in SaraSortdFiltered.items():
|
||||
setattr(Config.SaraSortd, Key, Value)
|
||||
|
||||
for Key, Value in Filtered.items():
|
||||
setattr(Config, Key, Value)
|
||||
Config.Save()
|
||||
|
||||
|
||||
def ApplyAppearance(Settings: SharedSettings) -> None:
|
||||
"""Theme colours and font. Must run before anything reads Palette.X or
|
||||
calls BuildTheme(). The window title bar is applied by Main."""
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Read-only SaraSortd integration (Features/Major: "Shows what types of files
|
||||
go to somewhere else, or where types of files come from"). Reads SaraSortd's
|
||||
own GlobalConf.toml plus each relevant directory's [email protected] sidecar file
|
||||
directly as TOML — never imports or runs SaraSortd itself, and never writes to
|
||||
any of its files.
|
||||
|
||||
Only replicates the one substitution InputDir/OutputDir/ValidInputDirs entries
|
||||
actually use in practice: %Name% tokens resolved against [DirectoryPaths], and
|
||||
the Parent placeholder ("@" by default) swapped for a directory's own name.
|
||||
SaraSortd's fuller Parse() also handles NextNum/Year/OrgFileName/etc, but those
|
||||
only matter for file *renaming*, not this read-only "what goes where" view."""
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from Core import AppConfig
|
||||
|
||||
DefaultParentChar = "@"
|
||||
DefaultDirConfName = ".@Config"
|
||||
|
||||
|
||||
def _LoadToml(TomlPath: Path) -> Optional[dict]:
|
||||
try:
|
||||
return tomllib.loads(TomlPath.read_text())
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _ResolveRootDirTokens(Value: str, DirectoryPaths: dict) -> str:
|
||||
def Replace(Match: re.Match) -> str:
|
||||
Name = Match.group(1)
|
||||
Found = DirectoryPaths.get(Name) if Name else None
|
||||
return Found if isinstance(Found, str) else Match.group(0)
|
||||
|
||||
return re.sub(r"%(.*?)%", Replace, Value)
|
||||
|
||||
|
||||
def _DirConfFileName(NamePattern: str, ParentChar: str, DirName: str) -> str:
|
||||
return f"{NamePattern.replace(ParentChar, DirName)}.toml"
|
||||
|
||||
|
||||
def _ValidInputDirMatches(Pattern: str, ParentChar: str, OutputDirName: str, InputPath: Path) -> bool:
|
||||
Resolved = Pattern.replace(ParentChar, OutputDirName)
|
||||
return fnmatch.fnmatchcase(str(InputPath), Resolved)
|
||||
|
||||
|
||||
def _DisplayPath(DirPath: Path) -> str:
|
||||
try:
|
||||
return f"~/{DirPath.relative_to(Path.home())}"
|
||||
except ValueError:
|
||||
return str(DirPath)
|
||||
|
||||
|
||||
def GetSortRules(CurrentPath: Path, Config: AppConfig) -> list[str]:
|
||||
"""Returns display lines such as "*.png -> ~/Pictures" (CurrentPath is a
|
||||
configured SaraSortd input directory) and/or "*.png <- ~/Downloads"
|
||||
(CurrentPath is a configured output directory). Empty list if the
|
||||
integration is off, unconfigured (including SaraSortd's own un-set-up
|
||||
RootDir = "Unset"), or nothing matches this directory. Never raises —
|
||||
a malformed or foreign-format SaraSortd config degrades to no info
|
||||
rather than breaking the browser."""
|
||||
if not Config.SaraSortd.Enabled or not Config.SaraSortd.GlobalConfPath:
|
||||
return []
|
||||
|
||||
try:
|
||||
return _GetSortRulesUnsafe(CurrentPath, Config)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _GetSortRulesUnsafe(CurrentPath: Path, Config: AppConfig) -> list[str]:
|
||||
GlobalConfPath = Path(Config.SaraSortd.GlobalConfPath).expanduser()
|
||||
GlobalConf = _LoadToml(GlobalConfPath)
|
||||
if not GlobalConf:
|
||||
return []
|
||||
|
||||
DirectoryPaths = GlobalConf.get("DirectoryPaths")
|
||||
if not isinstance(DirectoryPaths, dict):
|
||||
return []
|
||||
|
||||
RootDir = DirectoryPaths.get("RootDir")
|
||||
if not isinstance(RootDir, str) or not RootDir or RootDir == "Unset":
|
||||
return []
|
||||
|
||||
Names = GlobalConf.get("Names") or {}
|
||||
ParentChar = (GlobalConf.get("Variables") or {}).get("Parent", DefaultParentChar)
|
||||
OutDirConfName = Names.get("OutDirConfName", DefaultDirConfName)
|
||||
|
||||
def ResolvedDirs(Key: str) -> list[Path]:
|
||||
Raw = DirectoryPaths.get(Key, [])
|
||||
if not isinstance(Raw, list):
|
||||
return []
|
||||
return [
|
||||
Path(_ResolveRootDirTokens(Entry, DirectoryPaths)).expanduser()
|
||||
for Entry in Raw if isinstance(Entry, str)
|
||||
]
|
||||
|
||||
InputDirs = ResolvedDirs("InputDir")
|
||||
OutputDirs = ResolvedDirs("OutputDir")
|
||||
|
||||
CurrentResolved = CurrentPath.expanduser().resolve()
|
||||
|
||||
IsInputDir = any(D.exists() and D.resolve() == CurrentResolved for D in InputDirs)
|
||||
IsOutputDir = any(D.exists() and D.resolve() == CurrentResolved for D in OutputDirs)
|
||||
|
||||
Results: list[str] = []
|
||||
|
||||
if IsInputDir:
|
||||
for OutputDir in OutputDirs:
|
||||
if not OutputDir.is_dir():
|
||||
continue
|
||||
OutputDirName = OutputDir.name
|
||||
OutConf = _LoadToml(OutputDir / _DirConfFileName(OutDirConfName, ParentChar, OutputDirName))
|
||||
if not OutConf:
|
||||
continue
|
||||
ValidInputDirs = OutConf.get("ValidInputDirs", ["*"])
|
||||
if not isinstance(ValidInputDirs, list):
|
||||
continue
|
||||
if not any(
|
||||
_ValidInputDirMatches(V, ParentChar, OutputDirName, CurrentResolved)
|
||||
for V in ValidInputDirs if isinstance(V, str)
|
||||
):
|
||||
continue
|
||||
for FileRule in OutConf.get("Files") or []:
|
||||
if isinstance(FileRule, dict):
|
||||
Results.append(f"{FileRule.get('Pattern', '*')} -> {_DisplayPath(OutputDir)}")
|
||||
|
||||
if IsOutputDir:
|
||||
OutputDirName = CurrentResolved.name
|
||||
OutConf = _LoadToml(CurrentResolved / _DirConfFileName(OutDirConfName, ParentChar, OutputDirName))
|
||||
if OutConf:
|
||||
ValidInputDirs = OutConf.get("ValidInputDirs", ["*"])
|
||||
Patterns = [
|
||||
FileRule.get("Pattern", "*")
|
||||
for FileRule in (OutConf.get("Files") or [])
|
||||
if isinstance(FileRule, dict)
|
||||
]
|
||||
if isinstance(ValidInputDirs, list) and Patterns:
|
||||
for InputDir in InputDirs:
|
||||
if not InputDir.is_dir():
|
||||
continue
|
||||
if not any(
|
||||
_ValidInputDirMatches(V, ParentChar, OutputDirName, InputDir.resolve())
|
||||
for V in ValidInputDirs if isinstance(V, str)
|
||||
):
|
||||
continue
|
||||
for Pattern in Patterns:
|
||||
Results.append(f"{Pattern} <- {_DisplayPath(InputDir)}")
|
||||
|
||||
Seen: set[str] = set()
|
||||
Deduped = []
|
||||
for Line in Results:
|
||||
if Line not in Seen:
|
||||
Seen.add(Line)
|
||||
Deduped.append(Line)
|
||||
return Deduped
|
||||
+67
@@ -5,6 +5,7 @@ look good... sidebar with areas, then a much bigger main box"), with the
|
||||
left/right/top/bottom-style choices as icon-button pickers instead of text
|
||||
dropdowns ("some way to choose layout in a more graphical... way")."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import flet as ft
|
||||
@@ -66,6 +67,8 @@ def BuildSettingsDialog(
|
||||
Config: AppConfig,
|
||||
OnSave: Callable[[], None],
|
||||
OnDismiss: Callable[[], None],
|
||||
OnExport: Callable[[str], None],
|
||||
OnImport: Callable[[str], None],
|
||||
) -> ft.AlertDialog:
|
||||
Themes = AvailableThemes()
|
||||
Fonts = InstalledFontFamilies()
|
||||
@@ -91,6 +94,14 @@ def BuildSettingsDialog(
|
||||
SharedFontDropdown = FontPicker(Shared.FontFamily)
|
||||
SharedTitleBarSwitch = ft.Switch(label="Hide window title bar", value=Shared.HideWindowTitleBar)
|
||||
|
||||
# Export/import as one portable file (Features/Major) — separate from
|
||||
# the shared settings above: that's the always-on, automatic sync every
|
||||
# SaraSuite program reads on startup, this is a one-off snapshot you
|
||||
# choose a path for, to back up or carry to another machine.
|
||||
SettingsFileField = ft.TextField(
|
||||
value=str(Path.home() / "SaraFem-Settings.toml"), label="Settings file path"
|
||||
)
|
||||
|
||||
# "Middle" tab: the main file-list pane itself, not the panels around it.
|
||||
ViewModePicker, GetViewMode = _OptionPicker(
|
||||
[("list", "List", "VIEW_LIST_OUTLINED"), ("grid", "Grid", "GRID_VIEW_OUTLINED")], Config.ViewMode
|
||||
@@ -111,6 +122,15 @@ def BuildSettingsDialog(
|
||||
[("below", "Below", "SOUTH"), ("above", "Above", "NORTH")],
|
||||
"below" if Config.GridNameBelowIcon else "above",
|
||||
)
|
||||
ListIconSizeField = ft.TextField(
|
||||
value=str(Config.ListIconSize), label="List icon size", keyboard_type=ft.KeyboardType.NUMBER
|
||||
)
|
||||
FontSizeField = ft.TextField(
|
||||
value=str(Config.FontSize), label="Name font size", keyboard_type=ft.KeyboardType.NUMBER
|
||||
)
|
||||
ToolbarHamburgerSwitch = ft.Switch(
|
||||
label="Collapse toolbar buttons into one menu", value=Config.ToolbarHamburger
|
||||
)
|
||||
|
||||
# SaraFem's own values ("General" tab), only used with shared settings off.
|
||||
ThemeDropdown = ThemePicker(Config.Theme)
|
||||
@@ -229,6 +249,13 @@ def BuildSettingsDialog(
|
||||
hint_text="/home/sarah/Applications/SaraTag/.saratag",
|
||||
)
|
||||
|
||||
SaraSortdSwitch = ft.Switch(label="Enable SaraSortd integration", value=Config.SaraSortd.Enabled)
|
||||
SaraSortdPathField = ft.TextField(
|
||||
value=Config.SaraSortd.GlobalConfPath,
|
||||
label="SaraSortd GlobalConf.toml path",
|
||||
hint_text="/home/sarah/Applications/SaraSortd/GlobalConf.toml",
|
||||
)
|
||||
|
||||
Categories: dict[str, ft.Control] = {
|
||||
"SaraSuite": ft.Column(
|
||||
[
|
||||
@@ -239,6 +266,22 @@ def BuildSettingsDialog(
|
||||
SharedThemeDropdown,
|
||||
SharedFontDropdown,
|
||||
SharedTitleBarSwitch,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
_SectionLabel("Export/import settings as one file — this tab's shared "
|
||||
"settings plus everything else in Options"),
|
||||
SettingsFileField,
|
||||
ft.Row(
|
||||
[
|
||||
ft.OutlinedButton(
|
||||
content="Export", style=ChipButtonStyle(),
|
||||
on_click=lambda e: OnExport(SettingsFileField.value.strip()),
|
||||
),
|
||||
ft.OutlinedButton(
|
||||
content="Import", style=ChipButtonStyle(),
|
||||
on_click=lambda e: OnImport(SettingsFileField.value.strip()),
|
||||
),
|
||||
]
|
||||
),
|
||||
],
|
||||
spacing=12,
|
||||
),
|
||||
@@ -269,6 +312,10 @@ def BuildSettingsDialog(
|
||||
ListContentSwitch,
|
||||
ListAccessSwitch,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
_SectionLabel("Font and icon sizes"),
|
||||
FontSizeField,
|
||||
ListIconSizeField,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
_SectionLabel("Grid"),
|
||||
GridIconSizeField,
|
||||
_SectionLabel("Name position"),
|
||||
@@ -288,6 +335,8 @@ def BuildSettingsDialog(
|
||||
PasteButtonSwitch,
|
||||
ViewToggleButtonSwitch,
|
||||
OptionsButtonSwitch,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
ToolbarHamburgerSwitch,
|
||||
],
|
||||
spacing=12,
|
||||
),
|
||||
@@ -341,6 +390,18 @@ def BuildSettingsDialog(
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
"SaraTag": ft.Column([SaraTagSwitch, SaraTagPathField], spacing=12),
|
||||
"SaraSortd": ft.Column(
|
||||
[
|
||||
SaraSortdSwitch,
|
||||
SaraSortdPathField,
|
||||
_SectionLabel(
|
||||
'Shows what SaraSortd does with the directory you\'re browsing, e.g. '
|
||||
'"*.png -> ~/Pictures" for a watched input directory, or '
|
||||
'"*.png <- ~/Downloads" for one of its output directories.'
|
||||
),
|
||||
],
|
||||
spacing=12,
|
||||
),
|
||||
}
|
||||
|
||||
DetailArea = ft.Container(Categories["General"], expand=True, padding=ft.Padding.only(left=12))
|
||||
@@ -378,6 +439,7 @@ def BuildSettingsDialog(
|
||||
Config.ListShowContent = ListContentSwitch.value
|
||||
Config.ListShowAccess = ListAccessSwitch.value
|
||||
Config.GridNameBelowIcon = GetGridNamePosition() == "below"
|
||||
Config.ToolbarHamburger = ToolbarHamburgerSwitch.value
|
||||
Config.UseSharedSettings = UseSharedSwitch.value
|
||||
Config.Theme = ThemeDropdown.value
|
||||
Config.FontFamily = FontDropdown.value or Config.FontFamily
|
||||
@@ -421,6 +483,9 @@ def BuildSettingsDialog(
|
||||
Config.SaraTag.Enabled = SaraTagSwitch.value
|
||||
Config.SaraTag.CoreModulePath = SaraTagPathField.value.strip()
|
||||
|
||||
Config.SaraSortd.Enabled = SaraSortdSwitch.value
|
||||
Config.SaraSortd.GlobalConfPath = SaraSortdPathField.value.strip()
|
||||
|
||||
for Field, TargetAttr in (
|
||||
(SidebarWidthField, "SidebarWidth"),
|
||||
(BookmarksMinHeightField, "BookmarksMinHeight"),
|
||||
@@ -437,6 +502,8 @@ def BuildSettingsDialog(
|
||||
(TagPanelMinHeightField, "TagPanelMinHeight"),
|
||||
(TagPanelMaxHeightField, "TagPanelMaxHeight"),
|
||||
(GridIconSizeField, "GridIconSize"),
|
||||
(ListIconSizeField, "ListIconSize"),
|
||||
(FontSizeField, "FontSize"),
|
||||
):
|
||||
try:
|
||||
setattr(Config, TargetAttr, int(Field.value))
|
||||
|
||||
Reference in new issue
Block a user