Files
SaraFem/Core.py
T

589 lines
25 KiB
Python

"""Shared app-wide constants: palette, theme, cache paths.
Palette and role-mapping deliberately mirror SaraView's (see
SaraView/Backend/DefaultThemes/CatppuccinMocha.toml and Frontend/src/Styles/Theme.css):
one accent colour (Purple/Mauve) used everywhere, not a mauve+lavender mix.
"""
import shutil
import subprocess
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Callable, Optional
import flet as ft
import toml
class Palette:
"""Live values for the active theme (see ApplyTheme). Starts as Catppuccin
Mocha so anything constructed before ApplyTheme() runs still looks right.
Role names/values are mutated in place from ~/.config/SaraSuite/Themes/*.toml,
which uses the exact same [Palette] schema as SaraView's own theme files —
see SaraView/Backend/DefaultThemes/*.toml and Frontend/src/ThemeRoles.js."""
Mauve = "#cba6f7" # SaraView's "Purple" role: the one accent colour
Red = "#f38ba8"
Peach = "#fab387" # SaraView's "Orange"
Yellow = "#f9e2af"
Green = "#a6e3a1"
Blue = "#89b4fa"
Text = "#cdd6f4"
White = "#cdd6f4"
Overlay0 = "#6c7086" # SaraView's "TextMuted"
Surface1 = "#45475a" # SaraView's "Border"
Surface0 = "#313244" # SaraView's "SurfaceAlt"
Base = "#1e1e2e" # SaraView's "Background"
Mantle = "#181825" # SaraView's "Surface"
Crust = "#11111b" # SaraView's "Black"
AppName = "SaraFem"
# Bundled in assets/fonts and always registered, so it works as FontFamily
# without being installed. Any other FontFamily is looked up among the
# system's installed fonts.
BundledFontFamily = "Fixedsys Excelsior"
ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme
CacheDir = Path.home() / ".cache" / "SaraFem"
ThumbnailCacheDir = CacheDir / "Thumbnails"
# Config moved from ~/.config/SaraFem/ into a SaraSuite-wide layout so other
# Sara* programs (SaraTag, SaraView) can eventually read from the same place.
SharedConfigDir = Path.home() / ".config" / "SaraSuite"
ThemesDir = SharedConfigDir / "Themes"
ConfigDir = SharedConfigDir / "SaraFem"
ConfigPath = ConfigDir / "Config.toml"
# Settings shared by every SaraSuite program. Same key names as the matching
# fields in each program's own Config.toml.
SharedConfigPath = SharedConfigDir / "Config.toml"
BookmarksPath = ConfigDir / ".sfem_bookmarks.toml"
_OldConfigDir = Path.home() / ".config" / "SaraFem"
# The exact role names SaraView's theme files use, and the SaraFem Palette
# attribute each one maps to.
_ThemeRoleMap = {
"Background": "Base", "Surface": "Mantle", "SurfaceAlt": "Surface0", "Border": "Surface1",
"Text": "Text", "TextMuted": "Overlay0", "Black": "Crust", "White": "White",
"Red": "Red", "Orange": "Peach", "Yellow": "Yellow", "Green": "Green",
"Blue": "Blue", "Purple": "Mauve",
}
# Bundled so SaraFem has real theme choices even before SaraView (or anything
# else) has populated the shared Themes directory. Copied directly from
# SaraView/Backend/DefaultThemes/*.toml so the colours actually match.
BuiltinThemes: dict[str, dict[str, str]] = {
"CatppuccinMocha": {
"Background": "#1e1e2e", "Surface": "#181825", "SurfaceAlt": "#313244", "Border": "#45475a",
"Text": "#cdd6f4", "TextMuted": "#6c7086", "Black": "#11111b", "White": "#cdd6f4",
"Red": "#f38ba8", "Orange": "#fab387", "Yellow": "#f9e2af", "Green": "#a6e3a1",
"Blue": "#89b4fa", "Purple": "#cba6f7",
},
"Forest": {
"Background": "#1b2420", "Surface": "#141b17", "SurfaceAlt": "#243029", "Border": "#334539",
"Text": "#d6e5d0", "TextMuted": "#7c9184", "Black": "#0d120f", "White": "#eef5ea",
"Red": "#e0685a", "Orange": "#e0a458", "Yellow": "#e0c458", "Green": "#7fb069",
"Blue": "#5c9e8f", "Purple": "#9b7fb0",
},
"Gruvbox": {
"Background": "#282828", "Surface": "#1d2021", "SurfaceAlt": "#3c3836", "Border": "#504945",
"Text": "#ebdbb2", "TextMuted": "#a89984", "Black": "#1d2021", "White": "#fbf1c7",
"Red": "#fb4934", "Orange": "#fe8019", "Yellow": "#fabd2f", "Green": "#b8bb26",
"Blue": "#83a598", "Purple": "#d3869b",
},
}
def _MigrateOldConfig() -> None:
"""One-time move from the pre-SaraSuite ~/.config/SaraFem/ location."""
if ConfigDir.exists() or not _OldConfigDir.exists():
return
ConfigDir.mkdir(parents=True, exist_ok=True)
for FileName in ("Config.toml", ".sfem_bookmarks.toml"):
OldFile = _OldConfigDir / FileName
NewFile = ConfigDir / FileName
if OldFile.exists() and not NewFile.exists():
NewFile.write_text(OldFile.read_text())
def EnsureSharedThemes() -> None:
"""Seed the shared Themes directory with the bundled defaults, so the
location is actually populated for other SaraSuite programs to read."""
ThemesDir.mkdir(parents=True, exist_ok=True)
for Name, RoleValues in BuiltinThemes.items():
ThemeFile = ThemesDir / f"{Name}.toml"
if not ThemeFile.exists():
ThemeFile.write_text(toml.dumps({"Palette": RoleValues}))
def AvailableThemes() -> list[str]:
EnsureSharedThemes()
return sorted(F.stem for F in ThemesDir.glob("*.toml"))
def ApplyTheme(ThemeName: str) -> None:
"""Mutate Palette's attributes in place to the named theme. Must run before
any control that reads Palette.X is constructed — Palette.X values are
plain strings baked into controls at build time, not live references."""
EnsureSharedThemes()
RoleValues = dict(BuiltinThemes.get(ThemeName, BuiltinThemes["CatppuccinMocha"]))
ThemeFile = ThemesDir / f"{ThemeName}.toml"
if ThemeFile.exists():
try:
RoleValues.update(toml.loads(ThemeFile.read_text()).get("Palette", {}))
except toml.TomlDecodeError:
pass
for RoleName, AttrName in _ThemeRoleMap.items():
if RoleName in RoleValues:
setattr(Palette, AttrName, RoleValues[RoleName])
@dataclass
class SaraTagIntegrationConfig:
"""Mirrors SaraView's Backend/SaraViewConf.toml [SaraTag] section, so both
apps talk to SaraTag the same opt-in way. Disabled until pointed at a
SaraTag checkout, e.g. CoreModulePath = "/home/sarah/Applications/SaraTag/.saratag"."""
Enabled: bool = False
CoreModulePath: str = ""
HideInternalFiles: bool = True
InternalPatterns: list[str] = field(
default_factory=lambda: [".saratag", ".SaraTag*", "*.book.toml"]
)
@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
(nothing changes visually until the user opts in via Ctrl+B/P/H/T or edits
Config.toml directly)."""
# True: Theme, FontFamily and HideWindowTitleBar come from the shared
# ~/.config/SaraSuite/Config.toml, like every other SaraSuite program.
# False: SaraFem uses its own values below instead.
UseSharedSettings: bool = True
Theme: str = "CatppuccinMocha" # matches a file in ~/.config/SaraSuite/Themes/
FontFamily: str = BundledFontFamily
HideWindowTitleBar: bool = False # the actual OS window title bar, not the toolbar below
ShowDotfiles: bool = True
ViewMode: str = "list" # "list" or "grid"
# List view: which metadata columns show, beyond the always-present Name.
ListShowDescription: bool = False
ListShowSize: bool = True
ListShowModified: bool = True
ListShowOwner: bool = False
ListShowCreated: bool = False
ListShowContent: bool = False
ListShowAccess: bool = False
# List view column widths — drag the header dividers to set these, or
# type a number in Options; both write here.
ListWidthName: int = 220
ListWidthDescription: int = 90
ListWidthSize: int = 80
ListWidthModified: int = 130
ListWidthOwner: int = 90
ListWidthCreated: int = 130
ListWidthContent: int = 90
ListWidthAccess: int = 60
# 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
# Double-click on a text/config/markup file: True opens it in SaraFem's own
# viewer/editor (Editor.py); False (matches pre-existing behaviour) hands it
# to xdg-open instead. Either way, the per-entry "Open" menu item does the
# opposite of this default, so both are always one click away.
OpenFilesInApp: bool = False
# Batch-rename pattern for multi-select rename, syntax modeled on
# SaraSortd's own placeholder characters (see ~/Applications/SaraSortd's
# GlobalConf.toml [Variables]): ~ = typed base name, ! = auto-incrementing
# counter (blank for the first item, " 1", " 2", ... after), ^ = the
# original file's extension.
BatchRenamePattern: str = "~!^"
DeleteZipAfterExtract: bool = False
DefaultArchiveFormat: str = "zip" # "zip" or "targz" — Compress's default format
# The in-app toolbar: breadcrumbs and the button row share one bar now
# (each taking half the width), positioned top or bottom, hideable as a whole.
ShowToolbar: bool = True
ShowBreadcrumbs: bool = True
BreadcrumbsPosition: str = "top" # "top" or "bottom"
ShowNewDirectoryButton: bool = True
ShowPasteButton: bool = True
ShowViewToggleButton: bool = True
ShowOptionsButton: bool = True
# The left-hand panel, which holds three independently-toggleable sections:
# Bookmarks (flat, user-curated, Bookmarks.py), Devices (mounted removable
# media, Devices.py) and Filetree (a real expandable directory tree,
# Filetree.py).
ShowSidebar: bool = True
SidebarSide: str = "left" # "left" or "right"
SidebarWidth: int = 220
ShowBookmarks: bool = True
ShowDevices: bool = True
AutoMountDevices: bool = True # mount USB sticks etc. as soon as they're plugged in
ShowInternalDrives: bool = False # also list internal disks that aren't system mounts
ShowFiletree: bool = True
FiletreeRoot: str = "/"
BookmarksMinHeight: int = 0 # 0 = no constraint
BookmarksMaxHeight: int = 0
DevicesMinHeight: int = 0
DevicesMaxHeight: int = 0
FiletreeMinHeight: int = 0
FiletreeMaxHeight: int = 0
# Same idea as SplitPreviewTagsEvenly: proportional 50/50, overrides the
# heights above, adapts to window size instead of a fixed pixel guess.
SplitBookmarksFiletreeEvenly: bool = False
ShowPreview: bool = False
PreviewSide: str = "right" # "left" or "right"
PreviewWidth: int = 320
# Only applied when Tags shares Preview's column (TagsPosition == "under_preview") —
# otherwise Preview always fills the full height, per Sarah's clarification.
PreviewMinHeight: int = 0
PreviewMaxHeight: int = 0
ShowTags: bool = False
TagsPosition: str = "under_preview" # "under_preview", "top", or "bottom"
TagsLayout: str = "flex" # "flex" (wraps horizontally) or "vertical" (one per row)
TagMinWidth: int = 0 # 0 = no minimum, chip hugs its content
TagMaxWidth: int = 0 # 0 = no maximum
# The tags box's own height (not per-chip). Always applied, regardless of
# TagsPosition — matters most in vertical layout, where it can grow tall.
TagPanelMinHeight: int = 0
TagPanelMaxHeight: int = 0
# Proportional 50/50 split for Preview+Tags when stacked (TagsPosition ==
# "under_preview"), instead of fixed pixel heights — adapts to window size
# automatically. Overrides PreviewMinHeight/MaxHeight and TagPanelMinHeight/
# 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)
ConfigPath.write_text(toml.dumps(asdict(self)))
def LoadConfig() -> AppConfig:
_MigrateOldConfig()
if not ConfigPath.exists():
Config = AppConfig()
Config.Save()
return Config
try:
Data = toml.loads(ConfigPath.read_text())
except toml.TomlDecodeError:
return AppConfig()
Defaults = AppConfig()
KnownFields = {Field for Field in Defaults.__dataclass_fields__}
Filtered = {Key: Value for Key, Value in Data.items() if Key in KnownFields}
SaraTagData = Filtered.pop("SaraTag", {})
KnownSaraTagFields = {Field for Field in SaraTagIntegrationConfig.__dataclass_fields__}
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)
@dataclass
class SharedSettings:
"""~/.config/SaraSuite/Config.toml: the settings every SaraSuite program
shares. A program whose own UseSharedSettings is off reads these same
keys from its own Config.toml instead."""
Theme: str = "CatppuccinMocha"
FontFamily: str = BundledFontFamily
HideWindowTitleBar: bool = False
def Save(self) -> None:
SharedConfigDir.mkdir(parents=True, exist_ok=True)
SharedConfigPath.write_text(toml.dumps(asdict(self)))
def _OwnSettings(Config: AppConfig) -> SharedSettings:
return SharedSettings(Config.Theme, Config.FontFamily, Config.HideWindowTitleBar)
def LoadSharedSettings(Config: AppConfig) -> SharedSettings:
"""The shared file, created from SaraFem's own values the first time so
switching to it doesn't change anything visible."""
if not SharedConfigPath.exists():
Shared = _OwnSettings(Config)
Shared.Save()
return Shared
Result = _OwnSettings(Config) # fallback for keys the file doesn't have
try:
Data = toml.loads(SharedConfigPath.read_text())
except toml.TomlDecodeError:
return Result
for Key in SharedSettings.__dataclass_fields__:
if Key in Data:
setattr(Result, Key, Data[Key])
return Result
def EffectiveSettings(Config: AppConfig) -> SharedSettings:
"""What SaraFem actually uses: the shared settings, or its own."""
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."""
global ActiveFontFamily
ApplyTheme(Settings.Theme)
ActiveFontFamily = Settings.FontFamily or BundledFontFamily
def InstalledFontFamilies() -> list[str]:
"""Font families fontconfig knows about, for the font picker. The
bundled font is always first, since it isn't installed system-wide."""
try:
Result = subprocess.run(["fc-list", ":", "family"], capture_output=True, text=True, timeout=5)
Families = {Line.split(",")[0].strip() for Line in Result.stdout.splitlines() if Line.strip()}
except (OSError, subprocess.SubprocessError):
Families = set()
Families.discard(BundledFontFamily)
return [BundledFontFamily] + sorted(Families, key=str.lower)
def BuildTheme() -> ft.Theme:
ColorScheme = ft.ColorScheme(
primary=Palette.Mauve,
on_primary=Palette.Crust,
primary_container=Palette.Surface0,
on_primary_container=Palette.Mauve,
secondary=Palette.Mauve,
on_secondary=Palette.Crust,
secondary_container=Palette.Surface0,
on_secondary_container=Palette.Mauve,
surface=Palette.Base,
on_surface=Palette.Text,
on_surface_variant=Palette.Overlay0,
surface_container=Palette.Mantle,
surface_container_low=Palette.Mantle,
surface_container_high=Palette.Surface0,
surface_container_highest=Palette.Surface1,
outline=Palette.Surface1,
outline_variant=Palette.Surface1,
error=Palette.Red,
on_error=Palette.Crust,
)
return ft.Theme(
font_family=ActiveFontFamily,
color_scheme=ColorScheme,
use_material3=True,
scaffold_bgcolor=Palette.Base,
canvas_color=Palette.Base,
dialog_theme=ft.DialogTheme(
bgcolor=Palette.Mantle,
shape=ft.RoundedRectangleBorder(radius=8, side=ft.BorderSide(1, Palette.Mauve)),
title_text_style=ft.TextStyle(color=Palette.Mauve, size=18, font_family=ActiveFontFamily),
),
)
def ChipButtonStyle() -> ft.ButtonStyle:
"""SaraView's button look: SurfaceAlt fill, 1px Purple border, small radius."""
return ft.ButtonStyle(
color=Palette.Text,
bgcolor=Palette.Surface0,
side=ft.BorderSide(1, Palette.Mauve),
shape=ft.RoundedRectangleBorder(radius=4),
)
def BlendHex(BaseHex: str, AccentHex: str, Ratio: float) -> str:
"""Solid colour mixing Base and Accent (0 = pure Base, 1 = pure Accent).
Used for tinted-but-still-dark backgrounds derived from the theme's own
roles (e.g. a success toast) instead of inventing colours outside it."""
BaseRGB = tuple(int(BaseHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4))
AccentRGB = tuple(int(AccentHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4))
Mixed = (round(B + (A - B) * Ratio) for B, A in zip(BaseRGB, AccentRGB))
return "#" + "".join(f"{C:02x}" for C in Mixed)
def NotificationStyle(Kind: str) -> tuple[str, str]:
"""(Background, AccentColor) for a Kind of "success" or "error" toast,
both derived from the theme's own Green/Red so it stays coherent."""
Accent = Palette.Green if Kind == "success" else Palette.Red
Background = BlendHex(Palette.Mantle, Accent, 0.12)
return Background, Accent
def ApplyHeightBounds(Content: ft.Control, MinHeight: int, MaxHeight: int) -> ft.Control:
"""Wrap Content in a fixed-height box if a bound is set (Max wins if both are
set, since Flet has no true min/max-range constraint — MaxHeight caps it with
internal scrolling, MinHeight alone gives it a floor). 0/0 means natural sizing."""
if MaxHeight > 0:
return ft.Container(content=Content, height=MaxHeight)
if MinHeight > 0:
return ft.Container(content=Content, height=MinHeight)
return Content
def ResizeHandle(
Axis: str,
Length: Optional[int],
OnDrag: Callable[[float], None],
OnDragEnd: Callable[[], None],
) -> ft.GestureDetector:
"""A thin draggable divider, shared by the file list's column headers,
the sidebar/preview section splits, and the sidebar/preview *panel*
widths. "horizontal" resizes side-by-side things (drag left/right, e.g.
a column or a whole side panel) and needs Length = the row's height, not
just 6px wide with an unset (collapses to zero) height; "vertical"
resizes stacked things (drag up/down, e.g. Bookmarks vs Devices) and
needs Length = the panel's width, for the same reason on the other axis.
Pass Length=None instead when the immediate parent Row/Column already
stretches its children on that cross axis (confirmed, not assumed —
Flet doesn't do this by default) — then an explicit value would fight
that stretch instead of matching it. OnDrag fires with the incremental
delta on every move — cheap for a single header cell or a sidebar
section, but callers with many redraws to do (the file list's rows)
should treat it as a live *preview* and do the expensive rebuild in
OnDragEnd instead, which fires once on release; that's also where
Config gets saved, so a drag isn't disk I/O on every pixel of movement."""
IsHorizontal = Axis == "horizontal"
Handle = ft.GestureDetector(
content=ft.Container(
width=6 if IsHorizontal else Length,
height=Length if IsHorizontal else 6,
bgcolor=Palette.Surface1,
),
mouse_cursor=ft.MouseCursor.RESIZE_COLUMN if IsHorizontal else ft.MouseCursor.RESIZE_ROW,
)
if IsHorizontal:
Handle.on_horizontal_drag_update = lambda e: OnDrag(e.primary_delta or 0)
Handle.on_horizontal_drag_end = lambda e: OnDragEnd()
else:
Handle.on_vertical_drag_update = lambda e: OnDrag(e.primary_delta or 0)
Handle.on_vertical_drag_end = lambda e: OnDragEnd()
return Handle
# The group tag shared by every Draggable source and DragTarget in the app,
# so bookmarks/filetree only accept drops that actually originate from
# SaraFem's own file list (not, say, a Chip or some future draggable widget).
DragGroup = "sarafem_entry"
def UniqueDestination(Destination: Path) -> Path:
if not Destination.exists():
return Destination
Stem, Suffix = Destination.stem, Destination.suffix
Counter = 2
while True:
Candidate = Destination.with_name(f"{Stem} (copy {Counter}){Suffix}")
if not Candidate.exists():
return Candidate
Counter += 1
def FormatBatchName(Pattern: str, BaseName: str, Index: int, OriginalSuffix: str = "") -> str:
"""SaraSortd-style placeholder substitution for batch rename (see
~/Applications/SaraSortd's GlobalConf.toml [Variables] for the reference
syntax this mirrors): ~ = typed base name, ! = auto-incrementing counter
(blank for the first item, " 1", " 2", ... after), ^ = the original
file's extension, preserved unless the pattern omits it."""
CounterText = "" if Index == 0 else f" {Index}"
return Pattern.replace("~", BaseName).replace("!", CounterText).replace("^", OriginalSuffix)
def MoveIntoDirectory(SourcePath: Path, DestinationDir: Path) -> Path:
"""Move SourcePath into DestinationDir (drag-and-drop uses this). Returns
the final destination path. Raises OSError if the move is invalid."""
if SourcePath.parent == DestinationDir:
raise OSError(f'"{SourcePath.name}" is already in this directory.')
ResolvedSource = SourcePath.resolve()
if SourcePath.is_dir() and (
DestinationDir.resolve() == ResolvedSource or ResolvedSource in DestinationDir.resolve().parents
):
raise OSError(f"Can't move \"{SourcePath.name}\" into itself or one of its own subdirectories.")
Destination = UniqueDestination(DestinationDir / SourcePath.name)
shutil.move(str(SourcePath), str(Destination))
return Destination