mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-13 18:28:18 +02:00
SaraSort finds configs and history files where SaraSortd 7.2 keeps them (DirConfDir, [DirConfs]), and Settings > SaraSortd moves the existing ones between "inside each directory" and one folder. Imports keep [DirConfs] entries with moved directories, and Warnings flag a config folder inside an input or a SaraSortd older than 7.2. Co-Authored-By: Claude Opus 5.5 <[email protected]>
412 lines
18 KiB
Python
412 lines
18 KiB
Python
"""Settings dialog: the shared SaraSuite settings (with export/import),
|
|
SaraSort's own, and where SaraSortd lives. Laid out like SaraFem's and
|
|
SaraSuite's Settings (a category list on the left, the chosen category on the
|
|
right)."""
|
|
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
import flet as ft
|
|
import tomlkit
|
|
|
|
from Core import (
|
|
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, Home,
|
|
ImportSettings, LoadSharedSettings, Palette, SharedSettings,
|
|
)
|
|
from Sortd import MajorVersion, Setup, SortdError
|
|
from SortdTransfer import ApplyBundle, Bundle, BundleDirectories, ExportSortd, MissingDirectories, ReadBundleSafely
|
|
from Widgets import App, DirField, ExpandPath
|
|
|
|
DialogWidth = 700
|
|
DialogHeight = 520
|
|
CategoryListWidth = 150
|
|
RestartNote = "Theme, font and title bar changes apply the next time SaraSort starts."
|
|
StartPages = ["Flow", "Service", "Configs", "Templates", "Tools"]
|
|
|
|
|
|
def _Note(Text: str) -> ft.Text:
|
|
return ft.Text(Text, color=Palette.Overlay0, size=12)
|
|
|
|
|
|
def _Fonts() -> list[str]:
|
|
"""Installed font families (fontconfig), with the bundled font first."""
|
|
try:
|
|
Result = subprocess.run(
|
|
["fc-list", ":", "family"], capture_output=True, text=True, timeout=5, env=ChildEnvironment(),
|
|
)
|
|
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 _NumberField(Label: str, Value: int) -> ft.TextField:
|
|
return ft.TextField(label=Label, value=str(Value), keyboard_type=ft.KeyboardType.NUMBER)
|
|
|
|
|
|
def _ReadInt(Field: ft.TextField, Fallback: int, Minimum: int) -> int:
|
|
try:
|
|
return max(Minimum, int(Field.value))
|
|
except (TypeError, ValueError):
|
|
return Fallback # keep the previous value rather than crash on bad input
|
|
|
|
|
|
def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None:
|
|
Page = TheApp.Page
|
|
Config: AppConfig = TheApp.Config
|
|
Themes = AvailableThemes()
|
|
Fonts = _Fonts()
|
|
|
|
def ThemePicker(Value: str) -> ft.Dropdown:
|
|
Names = Themes if Value in Themes else [Value] + Themes
|
|
return ft.Dropdown(value=Value, label="Theme", options=[ft.DropdownOption(key=Name, text=Name) for Name in Names])
|
|
|
|
def FontPicker(Value: str) -> ft.Dropdown:
|
|
# Type to filter the long list.
|
|
Names = Fonts if Value in Fonts else [Value] + Fonts
|
|
return ft.Dropdown(
|
|
value=Value, label="Font", editable=True, enable_filter=True, menu_height=320,
|
|
options=[ft.DropdownOption(key=Name, text=Name) for Name in Names],
|
|
)
|
|
|
|
# SaraSuite: ~/.config/SaraSuite/Config.toml, read by every SaraSuite program.
|
|
Shared = LoadSharedSettings(Config)
|
|
UseSharedSwitch = ft.Switch(label="SaraSort uses these shared settings", value=Config.UseSharedSettings)
|
|
SharedTheme = ThemePicker(Shared.Theme)
|
|
SharedFont = FontPicker(Shared.FontFamily)
|
|
SharedTitleBar = ft.Switch(label="Hide window title bar", value=Shared.HideWindowTitleBar)
|
|
SettingsFileField = ft.TextField(value=str(Home / "SaraSort-Settings.toml"), label="Settings file path")
|
|
|
|
# SaraSort's own appearance, only used with shared settings off.
|
|
OwnTheme = ThemePicker(Config.Theme)
|
|
OwnFont = FontPicker(Config.FontFamily)
|
|
OwnTitleBar = ft.Switch(label="Hide window title bar", value=Config.HideWindowTitleBar)
|
|
OwnAppearance = ft.Column(
|
|
[_Note("SaraSort's own appearance, used when shared settings are off"), OwnTheme, OwnFont, OwnTitleBar],
|
|
spacing=12,
|
|
disabled=Config.UseSharedSettings,
|
|
opacity=0.5 if Config.UseSharedSettings else 1,
|
|
)
|
|
|
|
def OnUseSharedChanged(e) -> None:
|
|
OwnAppearance.disabled = UseSharedSwitch.value
|
|
OwnAppearance.opacity = 0.5 if UseSharedSwitch.value else 1
|
|
OwnAppearance.update()
|
|
|
|
UseSharedSwitch.on_change = OnUseSharedChanged
|
|
|
|
StartPage = ft.Dropdown(
|
|
label="Start page", value=Config.StartPage if Config.StartPage in StartPages else "Flow",
|
|
options=[ft.DropdownOption(key=Name, text=Name) for Name in StartPages],
|
|
)
|
|
LabelLength = _NumberField("Arrow label length before it's shortened", Config.LabelLength)
|
|
|
|
ConfigDirField = ft.TextField(label="SaraSortd config folder", value=Config.SortdConfigDir)
|
|
AppDirField = ft.TextField(label="SaraSortd program folder", value=Config.SortdAppDir)
|
|
UnitField = ft.TextField(label="systemd user unit", value=Config.ServiceName)
|
|
AutoRestartSwitch = ft.Switch(label="Restart SaraSortd after changes to GlobalConf.toml", value=Config.AutoRestart)
|
|
RefreshField = _NumberField("Service page refresh (seconds)", Config.RefreshSeconds)
|
|
LogLinesField = _NumberField("Log lines to show", Config.LogLines)
|
|
|
|
SortdFileField = ft.TextField(value=str(Home / "SaraSortd-Configs.toml"), label="SaraSortd export file path")
|
|
|
|
# Where directory configs live (SaraSortd 7.2's DirConfDir), like SaraTag's Lexicons folder.
|
|
try:
|
|
CurrentSetup = Setup(Config)
|
|
CurrentFolder = str(CurrentSetup.Dirs.get("DirConfDir", "") or "")
|
|
except SortdError:
|
|
CurrentSetup, CurrentFolder = None, ""
|
|
LocationPicker = ft.RadioGroup(
|
|
ft.Row([
|
|
ft.Radio(value="Inside", label="Inside each directory"),
|
|
ft.Radio(value="Folder", label="In one folder"),
|
|
]),
|
|
value="Folder" if CurrentFolder else "Inside",
|
|
disabled=CurrentSetup is None,
|
|
)
|
|
FolderRow, FolderField = DirField(TheApp, "Config folder (relative paths count from GlobalConf.toml's folder)",
|
|
CurrentFolder or "Dirs")
|
|
FolderRow.visible = bool(CurrentFolder)
|
|
|
|
def OnLocationChanged(e) -> None:
|
|
FolderRow.visible = LocationPicker.value == "Folder"
|
|
FolderRow.update()
|
|
|
|
LocationPicker.on_change = OnLocationChanged
|
|
|
|
def OnMoveConfs(e) -> None:
|
|
NewFolder = (FolderField.value or "").strip() if LocationPicker.value == "Folder" else ""
|
|
if LocationPicker.value == "Folder" and not NewFolder:
|
|
TheApp.Notify("Pick a folder for the configs.", "warning")
|
|
return
|
|
if NewFolder == CurrentFolder:
|
|
TheApp.Notify("The configs are already there.", "info")
|
|
return
|
|
try:
|
|
Moved = Setup(Config).MoveDirConfs(NewFolder)
|
|
except (OSError, SortdError) as Error:
|
|
TheApp.Notify(f"Couldn't move the configs: {Error}", "error")
|
|
return
|
|
Page.pop_dialog()
|
|
OnSaved()
|
|
TheApp.AfterGlobalChange()
|
|
TheApp.Notify(f"Moved {Moved} file{'s' if Moved != 1 else ''}; directory configs now live "
|
|
f"{'in ' + NewFolder if NewFolder else 'inside each directory'}.", "success")
|
|
|
|
def OnSortdExport(e) -> None:
|
|
PathText = (SortdFileField.value or "").strip()
|
|
if not PathText:
|
|
return
|
|
try:
|
|
Count = ExportSortd(Config, Path(PathText).expanduser())
|
|
except (OSError, SortdError) as Error:
|
|
TheApp.Notify(f"Couldn't export SaraSortd's configs: {Error}", "error")
|
|
return
|
|
TheApp.Notify(f"Exported {Count} SaraSortd configs.", "success")
|
|
|
|
def OnSortdImport(e) -> None:
|
|
PathText = (SortdFileField.value or "").strip()
|
|
if not PathText:
|
|
return
|
|
TheBundle, Error = ReadBundleSafely(Path(PathText).expanduser())
|
|
if TheBundle is None:
|
|
TheApp.Notify(Error, "error")
|
|
return
|
|
# Same as importing SaraSort's own settings: the Settings dialog
|
|
# closes, so a stale Save can't undo what the import wrote.
|
|
Page.pop_dialog()
|
|
ShowSortdImportDialog(TheApp, TheBundle, OnSaved)
|
|
|
|
def OnExport(e) -> None:
|
|
PathText = (SettingsFileField.value or "").strip()
|
|
if not PathText:
|
|
return
|
|
try:
|
|
ExportSettings(Config, Path(PathText).expanduser())
|
|
except OSError as Error:
|
|
TheApp.Notify(f"Couldn't export settings: {Error}", "error")
|
|
return
|
|
TheApp.Notify("Settings exported.", "success")
|
|
|
|
def OnImport(e) -> None:
|
|
PathText = (SettingsFileField.value or "").strip()
|
|
if not PathText:
|
|
return
|
|
try:
|
|
ImportSettings(Config, Path(PathText).expanduser())
|
|
except (OSError, tomlkit.exceptions.TOMLKitError) as Error:
|
|
TheApp.Notify(f"Couldn't import settings: {Error}", "error")
|
|
return
|
|
# The open dialog still shows the pre-import values; closing it
|
|
# rather than leaving a stale Save that would overwrite the import.
|
|
Page.pop_dialog()
|
|
OnSaved()
|
|
TheApp.Notify("Settings imported. Reopen Settings to see them; restart for Theme/Font/Title bar.", "success")
|
|
|
|
Categories: dict[str, ft.Control] = {
|
|
"SaraSuite": ft.Column(
|
|
[
|
|
_Note("Shared by every SaraSuite program (~/.config/SaraSuite/Config.toml). "
|
|
"Other programs pick changes up the next time they start."),
|
|
UseSharedSwitch,
|
|
ft.Divider(height=1, color=Palette.Surface1),
|
|
SharedTheme,
|
|
SharedFont,
|
|
SharedTitleBar,
|
|
ft.Divider(height=1, color=Palette.Surface1),
|
|
_Note("Export/import settings as one file: this tab's shared settings plus everything else "
|
|
"in Settings. Same format as SaraFem's."),
|
|
SettingsFileField,
|
|
ft.Row([
|
|
ft.OutlinedButton(content="Export", style=ChipButtonStyle(), on_click=OnExport),
|
|
ft.OutlinedButton(content="Import", style=ChipButtonStyle(), on_click=OnImport),
|
|
]),
|
|
],
|
|
spacing=12,
|
|
scroll=ft.ScrollMode.AUTO,
|
|
),
|
|
"General": ft.Column(
|
|
[
|
|
StartPage,
|
|
LabelLength,
|
|
ft.Divider(height=1, color=Palette.Surface1),
|
|
OwnAppearance,
|
|
],
|
|
spacing=12,
|
|
scroll=ft.ScrollMode.AUTO,
|
|
),
|
|
"SaraSortd": ft.Column(
|
|
[
|
|
_Note("Where SaraSortd lives. The defaults are where SaraSuite installs it."),
|
|
ConfigDirField,
|
|
AppDirField,
|
|
UnitField,
|
|
AutoRestartSwitch,
|
|
_Note("SaraSortd only reads GlobalConf.toml when it starts."),
|
|
ft.Divider(height=1, color=Palette.Surface1),
|
|
RefreshField,
|
|
LogLinesField,
|
|
ft.Divider(height=1, color=Palette.Surface1),
|
|
_Note("Where SaraSortd keeps each directory's config (and history file). In one folder, the "
|
|
"directories stay clean; each config is named after its directory, e.g. "
|
|
"Pictures_CatsConfig.toml. Needs SaraSortd 7.2. Moving takes the existing configs along."),
|
|
LocationPicker,
|
|
FolderRow,
|
|
ft.OutlinedButton(content="Move configs", style=ChipButtonStyle(), on_click=OnMoveConfs,
|
|
disabled=CurrentSetup is None),
|
|
ft.Divider(height=1, color=Palette.Surface1),
|
|
_Note("Export/import all of SaraSortd's configs as one file: GlobalConf.toml, both templates "
|
|
"and every input/output directory's own config. Importing replaces the current ones "
|
|
"(they're backed up first)."),
|
|
SortdFileField,
|
|
ft.Row([
|
|
ft.OutlinedButton(content="Export", style=ChipButtonStyle(), on_click=OnSortdExport),
|
|
ft.OutlinedButton(content="Import", style=ChipButtonStyle(), on_click=OnSortdImport),
|
|
]),
|
|
],
|
|
spacing=12,
|
|
scroll=ft.ScrollMode.AUTO,
|
|
),
|
|
}
|
|
|
|
DetailArea = ft.Container(Categories["General"], expand=True, padding=ft.Padding.only(left=12))
|
|
CategoryTiles: dict[str, ft.ListTile] = {}
|
|
|
|
def SelectCategory(Name: str) -> None:
|
|
for CategoryName, Tile in CategoryTiles.items():
|
|
Tile.selected = CategoryName == Name
|
|
DetailArea.content = Categories[Name]
|
|
DetailArea.update()
|
|
CategoryList.update()
|
|
|
|
for Name in Categories:
|
|
CategoryTiles[Name] = ft.ListTile(
|
|
title=Name,
|
|
selected=Name == "General",
|
|
selected_tile_color=Palette.Surface1,
|
|
selected_color=Palette.Mauve,
|
|
on_click=lambda e, n=Name: SelectCategory(n),
|
|
)
|
|
CategoryList = ft.Column(list(CategoryTiles.values()), spacing=0, scroll=ft.ScrollMode.AUTO)
|
|
|
|
def Save(e) -> None:
|
|
Config.UseSharedSettings = UseSharedSwitch.value
|
|
Config.Theme = OwnTheme.value or Config.Theme
|
|
Config.FontFamily = OwnFont.value or Config.FontFamily
|
|
Config.HideWindowTitleBar = OwnTitleBar.value
|
|
Config.StartPage = StartPage.value or Config.StartPage
|
|
Config.LabelLength = _ReadInt(LabelLength, Config.LabelLength, 6)
|
|
Config.SortdConfigDir = (ConfigDirField.value or "").strip() or Config.SortdConfigDir
|
|
Config.SortdAppDir = (AppDirField.value or "").strip() or Config.SortdAppDir
|
|
Config.ServiceName = (UnitField.value or "").strip() or Config.ServiceName
|
|
Config.AutoRestart = AutoRestartSwitch.value
|
|
Config.RefreshSeconds = _ReadInt(RefreshField, Config.RefreshSeconds, 1)
|
|
Config.LogLines = _ReadInt(LogLinesField, Config.LogLines, 10)
|
|
Config.Save()
|
|
SharedSettings(
|
|
Theme=SharedTheme.value,
|
|
FontFamily=SharedFont.value or Shared.FontFamily,
|
|
HideWindowTitleBar=SharedTitleBar.value,
|
|
).Save()
|
|
Page.pop_dialog()
|
|
OnSaved()
|
|
TheApp.Notify("Settings saved.", "success")
|
|
|
|
Page.show_dialog(ft.AlertDialog(
|
|
title="Settings",
|
|
content=ft.Column(
|
|
[
|
|
ft.Row(
|
|
[
|
|
ft.Container(
|
|
CategoryList, width=CategoryListWidth, bgcolor=Palette.Surface0, border_radius=4, padding=4,
|
|
),
|
|
DetailArea,
|
|
],
|
|
expand=True,
|
|
vertical_alignment=ft.CrossAxisAlignment.STRETCH,
|
|
),
|
|
ft.Divider(height=1, color=Palette.Surface1),
|
|
_Note(RestartNote),
|
|
],
|
|
width=DialogWidth,
|
|
height=DialogHeight,
|
|
),
|
|
actions=[
|
|
ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: Page.pop_dialog()),
|
|
ft.OutlinedButton(content="Save", style=ChipButtonStyle(), on_click=Save),
|
|
],
|
|
))
|
|
|
|
|
|
def ShowSortdImportDialog(TheApp: App, TheBundle: Bundle, OnDone: Callable[[], None]) -> None:
|
|
"""Confirms a SaraSortd import. Directories it uses that don't exist are
|
|
listed with a path field each: keep the path to have it made, or change it
|
|
to use another directory instead."""
|
|
Page = TheApp.Page
|
|
Config = TheApp.Config
|
|
try:
|
|
Missing = MissingDirectories(Config, TheBundle)
|
|
Total = len(BundleDirectories(Config, TheBundle))
|
|
except Exception as Error: # e.g. a GlobalConf in the export without the keys SaraSortd needs
|
|
TheApp.Notify(f"Can't use that export: {Error}", "error")
|
|
return
|
|
|
|
Fields: dict[str, ft.TextField] = {}
|
|
Rows: list[ft.Control] = []
|
|
for DirPath in Missing:
|
|
Row, Field = DirField(TheApp, f"{Path(DirPath).name or DirPath} (doesn't exist)", DirPath)
|
|
Fields[DirPath] = Field
|
|
Rows.append(Row)
|
|
|
|
Info = [
|
|
_Note(f"From an export of SaraSortd {TheBundle.SortdVersion or '?'}"
|
|
+ (f", made {TheBundle.Exported.replace('T', ' ')}" if TheBundle.Exported else "") + "."),
|
|
ft.Text(f"This replaces SaraSortd's configs in {Config.SortdConfigDir} and in {Total} "
|
|
f"director{'y' if Total == 1 else 'ies'}. The current ones are backed up to "
|
|
"~/.config/SaraSuite/SaraSort/Backups/ first.", size=13),
|
|
]
|
|
try:
|
|
Daemon = Setup(Config).DaemonVersion()
|
|
except SortdError:
|
|
Daemon = None
|
|
if Daemon and TheBundle.SortdVersion and MajorVersion(Daemon) != MajorVersion(TheBundle.SortdVersion):
|
|
Info.append(ft.Text(f"These configs are from SaraSortd {TheBundle.SortdVersion}, but {Daemon} is installed: "
|
|
"the first number differs, so they may not work.", color=Palette.Red, size=13))
|
|
if Missing:
|
|
Info += [
|
|
ft.Divider(height=1, color=Palette.Surface1),
|
|
ft.Row([ft.Icon(ft.Icons.WARNING_AMBER, color=Palette.Yellow),
|
|
ft.Text(f"{len(Missing)} director{'y' if len(Missing) == 1 else 'ies'} "
|
|
f"{'doesn' if len(Missing) == 1 else 'don'}'t exist:", color=Palette.Yellow)]),
|
|
_Note("Keep a path to have it made, or change it to use another directory instead "
|
|
"(the configs and arrows follow it)."),
|
|
# Only the list scrolls, so the dialog stays as small as its content.
|
|
ft.Column(Rows, spacing=8, scroll=ft.ScrollMode.AUTO, height=min(300, 58 * len(Rows))),
|
|
]
|
|
|
|
def Continue(e) -> None:
|
|
Remap = {Old: ExpandPath(Field.value or "") or Old for Old, Field in Fields.items()}
|
|
Page.pop_dialog()
|
|
try:
|
|
ApplyBundle(Config, TheBundle, Remap)
|
|
except (OSError, SortdError) as Error:
|
|
TheApp.Notify(f"Import failed: {Error}", "error")
|
|
return
|
|
OnDone()
|
|
TheApp.AfterGlobalChange() # its toast says whether SaraSortd restarted
|
|
|
|
Page.show_dialog(ft.AlertDialog(
|
|
title="Import SaraSortd configs",
|
|
content=ft.Column(Info, width=620, tight=True, spacing=10),
|
|
actions=[
|
|
ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: Page.pop_dialog()),
|
|
ft.OutlinedButton(
|
|
content="Make directories and import" if Missing else "Import",
|
|
style=ChipButtonStyle(Palette.Yellow if Missing else ""), on_click=Continue,
|
|
),
|
|
],
|
|
))
|