pax 250b144806 Decouple bookmark folders from library folders, add move-aware save + submenu pickers everywhere
Bookmark folders and library folders used to share identity through
_db.get_folders() — the same string was both a row in favorite_folders
and a directory under saved_dir. They look like one concept but they're
two stores, and the cross-bleed produced a duplicate-on-move bug and
made "Save to Library" silently re-file the bookmark too.

Now they're independent name spaces:
  - library_folders() in core.config reads filesystem subdirs of
    saved_dir; the source of truth for every Save-to-Library menu
  - find_library_files(post_id) walks the library shallowly and is the
    new "is this saved?" / delete primitive
  - bookmark folders stay DB-backed and are only used for bookmark
    organization (filter combo, Move to Folder)
  - delete_from_library no longer takes a folder hint — walks every
    library folder by post id and deletes every match (also cleans up
    duplicates left by the old save-to-folder copy bug)
  - _save_to_library is move-aware: if the post is already in another
    library folder, atomic Path.rename() into the destination instead
    of re-copying from cache (the duplicate bug fix)
  - bookmark "Move to Folder" no longer also calls _copy_to_library;
    Save to Library no longer also calls move_bookmark_to_folder
  - settings export/import unchanged; favorite_folders table preserved
    so no migration

UI additions:
  - Library tab right-click: Move to Folder submenu (single + multi),
    uses Path.rename for atomic moves
  - Bookmarks tab: − Folder button next to + Folder for deleting the
    selected bookmark folder (DB-only, library filesystem untouched)
  - Browse tab right-click: "Bookmark" replaced with "Bookmark as"
    submenu when not yet bookmarked (Unfiled / folders / + New); flat
    "Remove Bookmark" when already bookmarked
  - Embedded preview Bookmark button: same submenu shape via new
    bookmark_to_folder signal + set_bookmark_folders_callback
  - Popout Bookmark button: same shape — works in both browse and
    bookmarks tab modes
  - Popout Save button: Save-to-Library submenu via new save_to_folder
    + unsave_requested signals (drops save_toggle_requested + the
    _save_toggle_from_popout indirection)
  - Popout in library mode: Save button stays visible as Unsave; the
    rest of the toolbar (Bookmark / BL Tag / BL Post) is hidden

State plumbing:
  - _update_fullscreen_state mirrors the embedded preview's
    _is_bookmarked / _is_saved instead of re-querying DB+filesystem,
    eliminating the popout state drift during async bookmark adds
  - Library tab Save button reads "Unsave" the entire time; Save
    button width bumped 60→75 so the label doesn't clip on tight themes
  - Embedded preview tracks _is_bookmarked alongside _is_saved so the
    new Bookmark-as submenu can flip to a flat unbookmark when active

Naming:
  - "Unsorted" renamed to "Unfiled" everywhere user-facing — library
    Unfiled and bookmarks Unfiled now share one label. Internal
    comparison in library.py:_scan_files updated to match the combo.
2026-04-07 19:50:39 -05:00

157 lines
5.2 KiB
Python

"""Settings, paths, constants, platform detection."""
from __future__ import annotations
import os
import platform
import sys
from pathlib import Path
APPNAME = "booru-viewer"
IS_WINDOWS = sys.platform == "win32"
def hypr_rules_enabled() -> bool:
"""Whether the in-code hyprctl dispatches that change window state
should run.
Returns False when BOORU_VIEWER_NO_HYPR_RULES is set in the environment.
Callers should skip any hyprctl `dispatch` that would mutate window
state (resize, move, togglefloating, setprop no_anim, the floating
"prime" sequence). Read-only queries (`hyprctl clients -j`) are still
fine — only mutations are blocked.
The popout's keep_aspect_ratio enforcement is gated by the separate
popout_aspect_lock_enabled() — it's a different concern.
"""
return not os.environ.get("BOORU_VIEWER_NO_HYPR_RULES")
def popout_aspect_lock_enabled() -> bool:
"""Whether the popout's keep_aspect_ratio setprop should run.
Returns False when BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK is set in the
environment. Independent of hypr_rules_enabled() so a ricer can free
up the popout's shape (e.g. for fixed-square or panoramic popouts)
while keeping the rest of the in-code hyprctl behavior, or vice versa.
"""
return not os.environ.get("BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK")
def data_dir() -> Path:
"""Return the platform-appropriate data/cache directory."""
if IS_WINDOWS:
base = Path.home() / "AppData" / "Roaming"
else:
base = Path(
__import__("os").environ.get(
"XDG_DATA_HOME", str(Path.home() / ".local" / "share")
)
)
path = base / APPNAME
path.mkdir(parents=True, exist_ok=True)
return path
def cache_dir() -> Path:
"""Return the image cache directory."""
path = data_dir() / "cache"
path.mkdir(parents=True, exist_ok=True)
return path
def thumbnails_dir() -> Path:
"""Return the thumbnail cache directory."""
path = data_dir() / "thumbnails"
path.mkdir(parents=True, exist_ok=True)
return path
_library_dir_override: Path | None = None
def set_library_dir(path: Path | None) -> None:
global _library_dir_override
_library_dir_override = path
def saved_dir() -> Path:
"""Return the saved images directory."""
if _library_dir_override:
path = _library_dir_override
else:
path = data_dir() / "saved"
path.mkdir(parents=True, exist_ok=True)
return path
def saved_folder_dir(folder: str) -> Path:
"""Return a subfolder inside saved images, refusing path traversal.
Folder names should normally be filtered by `db._validate_folder_name`
before reaching the filesystem, but this is a defense-in-depth check:
resolve the candidate path and ensure it's still inside `saved_dir()`.
Anything that escapes (`..`, absolute paths, symlink shenanigans) raises
ValueError instead of silently writing to disk wherever the string points.
"""
base = saved_dir().resolve()
candidate = (base / folder).resolve()
try:
candidate.relative_to(base)
except ValueError as e:
raise ValueError(f"Folder escapes saved directory: {folder!r}") from e
candidate.mkdir(parents=True, exist_ok=True)
return candidate
def db_path() -> Path:
"""Return the path to the SQLite database."""
return data_dir() / "booru.db"
def library_folders() -> list[str]:
"""List library folder names — direct subdirectories of saved_dir().
The library is filesystem-truth: a folder exists iff there is a real
directory on disk. There is no separate DB list of folder names. This
is the source the "Save to Library → folder" menus everywhere should
read from. Bookmark folders (DB-backed) are a different concept.
"""
root = saved_dir()
if not root.is_dir():
return []
return sorted(d.name for d in root.iterdir() if d.is_dir())
def find_library_files(post_id: int) -> list[Path]:
"""Return all library files matching `post_id` across every folder.
The library has a flat shape: root + one level of subdirectories.
We walk it shallowly (one iterdir of root + one iterdir per subdir)
looking for any media file whose stem equals str(post_id). Used by:
- "is this post saved?" badges (any match → yes)
- delete_from_library (delete every match — handles duplicates left
by the old save-to-folder copy bug in a single click)
- the move-aware _save_to_library / library "Move to Folder" actions
"""
matches: list[Path] = []
root = saved_dir()
if not root.is_dir():
return matches
stem = str(post_id)
for entry in root.iterdir():
if entry.is_file() and entry.stem == stem and entry.suffix.lower() in MEDIA_EXTENSIONS:
matches.append(entry)
elif entry.is_dir():
for sub in entry.iterdir():
if sub.is_file() and sub.stem == stem and sub.suffix.lower() in MEDIA_EXTENSIONS:
matches.append(sub)
return matches
# Defaults
DEFAULT_THUMBNAIL_SIZE = (200, 200)
DEFAULT_PAGE_SIZE = 40
USER_AGENT = f"booru-viewer/0.1 ({platform.system()})"
MEDIA_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".webm", ".mkv", ".avi", ".mov")