library: scaffold filename templates + DB column
Adds the foundation that the unified save flow refactor builds on. No
behavior change at this commit — empty default template means every save
site still produces {id}{ext} like v0.2.3.
- core/db.py: library_meta.filename column with non-breaking migration
for legacy databases. Index on filename. New
get_library_post_id_by_filename() lookup. filename kwarg on
save_library_meta (defaults to "" for legacy callers).
library_filename_template added to _DEFAULTS.
- core/config.py: render_filename_template() with %id% %md5% %ext%
%rating% %score% %artist% %character% %copyright% %general% %meta%
%species% tokens. Sanitizes filesystem-reserved chars, collapses
whitespace, strips leading dots/.., caps the rendered stem at 200
characters, falls back to post id when sanitization yields empty.
- gui/settings.py: Library filename template input field next to the
Library directory row, with a help label listing tokens and noting
that Gelbooru/Moebooru can only resolve the basic ones.
This commit is contained in:
parent
003a2b221e
commit
6075f31917
@ -4,8 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .api.base import Post
|
||||||
|
|
||||||
APPNAME = "booru-viewer"
|
APPNAME = "booru-viewer"
|
||||||
IS_WINDOWS = sys.platform == "win32"
|
IS_WINDOWS = sys.platform == "win32"
|
||||||
@ -149,6 +154,101 @@ def find_library_files(post_id: int) -> list[Path]:
|
|||||||
return matches
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def render_filename_template(template: str, post: "Post", ext: str) -> str:
|
||||||
|
"""Render a filename template against a Post into a filesystem-safe basename.
|
||||||
|
|
||||||
|
Tokens supported:
|
||||||
|
%id% post id
|
||||||
|
%md5% md5 hash extracted from file_url (empty if URL doesn't carry one)
|
||||||
|
%ext% extension without the leading dot
|
||||||
|
%rating% post.rating or empty
|
||||||
|
%score% post.score
|
||||||
|
%artist% underscore-joined names from post.tag_categories["artist"]
|
||||||
|
%character% same, character category
|
||||||
|
%copyright% same, copyright category
|
||||||
|
%general% same, general category
|
||||||
|
%meta% same, meta category
|
||||||
|
%species% same, species category
|
||||||
|
|
||||||
|
The returned string is a basename including the extension. If `template`
|
||||||
|
is empty or post-sanitization the rendered stem is empty, falls back to
|
||||||
|
f"{post.id}{ext}" so callers always get a usable name.
|
||||||
|
|
||||||
|
The rendered stem is capped at 200 characters before the extension is
|
||||||
|
appended. This stays under the 255-byte ext4/NTFS filename limit for
|
||||||
|
typical ASCII/Latin-1 templates; users typing emoji-heavy templates may
|
||||||
|
still hit the limit but won't see a hard error from this function.
|
||||||
|
|
||||||
|
Sanitization replaces filesystem-reserved characters (`/\\:*?"<>|`) with
|
||||||
|
underscores, collapses whitespace runs to a single underscore, and strips
|
||||||
|
leading/trailing dots/spaces and `..` prefixes so the rendered name can't
|
||||||
|
escape the destination directory or trip Windows' trailing-dot quirk.
|
||||||
|
"""
|
||||||
|
if not template:
|
||||||
|
return f"{post.id}{ext}"
|
||||||
|
|
||||||
|
cats = post.tag_categories or {}
|
||||||
|
|
||||||
|
def _join_cat(name: str) -> str:
|
||||||
|
items = cats.get(name) or []
|
||||||
|
return "_".join(items)
|
||||||
|
|
||||||
|
# %md5% — most boorus name files by md5 in the URL path
|
||||||
|
# (e.g. https://cdn.donmai.us/original/0a/1b/0a1b...md5...{ext}).
|
||||||
|
# Extract the URL stem and accept it only if it's 32 hex chars.
|
||||||
|
md5 = ""
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
url_path = urlparse(post.file_url).path
|
||||||
|
url_stem = Path(url_path).stem
|
||||||
|
if len(url_stem) == 32 and all(c in "0123456789abcdef" for c in url_stem.lower()):
|
||||||
|
md5 = url_stem
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
has_ext_token = "%ext%" in template
|
||||||
|
replacements = {
|
||||||
|
"%id%": str(post.id),
|
||||||
|
"%md5%": md5,
|
||||||
|
"%ext%": ext.lstrip("."),
|
||||||
|
"%rating%": post.rating or "",
|
||||||
|
"%score%": str(post.score),
|
||||||
|
"%artist%": _join_cat("artist"),
|
||||||
|
"%character%": _join_cat("character"),
|
||||||
|
"%copyright%": _join_cat("copyright"),
|
||||||
|
"%general%": _join_cat("general"),
|
||||||
|
"%meta%": _join_cat("meta"),
|
||||||
|
"%species%": _join_cat("species"),
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered = template
|
||||||
|
for token, value in replacements.items():
|
||||||
|
rendered = rendered.replace(token, value)
|
||||||
|
|
||||||
|
# Sanitization: filesystem-reserved chars first, then control chars,
|
||||||
|
# then whitespace collapse, then leading-cleanup.
|
||||||
|
for ch in '/\\:*?"<>|':
|
||||||
|
rendered = rendered.replace(ch, "_")
|
||||||
|
rendered = "".join(c if ord(c) >= 32 else "_" for c in rendered)
|
||||||
|
rendered = re.sub(r"\s+", "_", rendered)
|
||||||
|
while rendered.startswith(".."):
|
||||||
|
rendered = rendered[2:]
|
||||||
|
rendered = rendered.lstrip("._")
|
||||||
|
rendered = rendered.rstrip("._ ")
|
||||||
|
|
||||||
|
# Length cap on the stem (before any system-appended extension).
|
||||||
|
if len(rendered) > 200:
|
||||||
|
rendered = rendered[:200].rstrip("._ ")
|
||||||
|
|
||||||
|
if not rendered:
|
||||||
|
return f"{post.id}{ext}"
|
||||||
|
|
||||||
|
if not has_ext_token:
|
||||||
|
rendered = rendered + ext
|
||||||
|
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
# Defaults
|
# Defaults
|
||||||
DEFAULT_THUMBNAIL_SIZE = (200, 200)
|
DEFAULT_THUMBNAIL_SIZE = (200, 200)
|
||||||
DEFAULT_PAGE_SIZE = 40
|
DEFAULT_PAGE_SIZE = 40
|
||||||
|
|||||||
@ -98,8 +98,13 @@ CREATE TABLE IF NOT EXISTS library_meta (
|
|||||||
rating TEXT,
|
rating TEXT,
|
||||||
source TEXT,
|
source TEXT,
|
||||||
file_url TEXT,
|
file_url TEXT,
|
||||||
saved_at TEXT
|
saved_at TEXT,
|
||||||
|
filename TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
-- The idx_library_meta_filename index is created in _migrate(), not here.
|
||||||
|
-- _SCHEMA runs before _migrate against legacy databases that don't yet have
|
||||||
|
-- the filename column, so creating the index here would fail with "no such
|
||||||
|
-- column" before the migration could ALTER the column in.
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@ -138,6 +143,7 @@ _DEFAULTS = {
|
|||||||
"slideshow_monitor": "",
|
"slideshow_monitor": "",
|
||||||
"library_dir": "",
|
"library_dir": "",
|
||||||
"infinite_scroll": "0",
|
"infinite_scroll": "0",
|
||||||
|
"library_filename_template": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -236,6 +242,13 @@ class Database:
|
|||||||
meta_cols = {row[1] for row in cur.fetchall()}
|
meta_cols = {row[1] for row in cur.fetchall()}
|
||||||
if "tag_categories" not in meta_cols:
|
if "tag_categories" not in meta_cols:
|
||||||
self._conn.execute("ALTER TABLE library_meta ADD COLUMN tag_categories TEXT DEFAULT ''")
|
self._conn.execute("ALTER TABLE library_meta ADD COLUMN tag_categories TEXT DEFAULT ''")
|
||||||
|
# Add filename column. Empty-string default acts as the
|
||||||
|
# "unknown" sentinel for legacy v0.2.3 rows whose on-disk
|
||||||
|
# filenames are digit stems — library scan code falls
|
||||||
|
# back to int(stem) when filename is empty.
|
||||||
|
if "filename" not in meta_cols:
|
||||||
|
self._conn.execute("ALTER TABLE library_meta ADD COLUMN filename TEXT NOT NULL DEFAULT ''")
|
||||||
|
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_library_meta_filename ON library_meta(filename)")
|
||||||
# Add tag_categories to favorites if missing
|
# Add tag_categories to favorites if missing
|
||||||
if "tag_categories" not in cols:
|
if "tag_categories" not in cols:
|
||||||
self._conn.execute("ALTER TABLE favorites ADD COLUMN tag_categories TEXT DEFAULT ''")
|
self._conn.execute("ALTER TABLE favorites ADD COLUMN tag_categories TEXT DEFAULT ''")
|
||||||
@ -559,17 +572,38 @@ class Database:
|
|||||||
|
|
||||||
def save_library_meta(self, post_id: int, tags: str = "", tag_categories: dict = None,
|
def save_library_meta(self, post_id: int, tags: str = "", tag_categories: dict = None,
|
||||||
score: int = 0, rating: str = None, source: str = None,
|
score: int = 0, rating: str = None, source: str = None,
|
||||||
file_url: str = None) -> None:
|
file_url: str = None, filename: str = "") -> None:
|
||||||
cats_json = json.dumps(tag_categories) if tag_categories else ""
|
cats_json = json.dumps(tag_categories) if tag_categories else ""
|
||||||
with self._write():
|
with self._write():
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT OR REPLACE INTO library_meta "
|
"INSERT OR REPLACE INTO library_meta "
|
||||||
"(post_id, tags, tag_categories, score, rating, source, file_url, saved_at) "
|
"(post_id, tags, tag_categories, score, rating, source, file_url, saved_at, filename) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(post_id, tags, cats_json, score, rating, source, file_url,
|
(post_id, tags, cats_json, score, rating, source, file_url,
|
||||||
datetime.now(timezone.utc).isoformat()),
|
datetime.now(timezone.utc).isoformat(), filename),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_library_post_id_by_filename(self, filename: str) -> int | None:
|
||||||
|
"""Look up which post a saved-library file belongs to, by basename.
|
||||||
|
|
||||||
|
Returns the post_id if a `library_meta` row exists with that
|
||||||
|
filename, or None if no row matches. Used by the unified save
|
||||||
|
flow's same-post-on-disk check to make re-saves idempotent and
|
||||||
|
to apply sequential `_1`, `_2`, ... suffixes only when a name
|
||||||
|
collides with a *different* post.
|
||||||
|
|
||||||
|
Empty-string filenames (the legacy v0.2.3 sentinel) deliberately
|
||||||
|
do not match — callers fall back to the digit-stem heuristic for
|
||||||
|
those rows.
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
return None
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT post_id FROM library_meta WHERE filename = ? LIMIT 1",
|
||||||
|
(filename,),
|
||||||
|
).fetchone()
|
||||||
|
return row["post_id"] if row else None
|
||||||
|
|
||||||
def get_library_meta(self, post_id: int) -> dict | None:
|
def get_library_meta(self, post_id: int) -> dict | None:
|
||||||
row = self.conn.execute("SELECT * FROM library_meta WHERE post_id = ?", (post_id,)).fetchone()
|
row = self.conn.execute("SELECT * FROM library_meta WHERE post_id = ?", (post_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
|
|||||||
@ -430,6 +430,26 @@ class SettingsDialog(QDialog):
|
|||||||
layout.addWidget(QLabel("Library directory:"))
|
layout.addWidget(QLabel("Library directory:"))
|
||||||
layout.addLayout(lib_row)
|
layout.addLayout(lib_row)
|
||||||
|
|
||||||
|
# Library filename template (editable). Applies to every save action
|
||||||
|
# — Save to Library, Save As, batch downloads, multi-select bulk
|
||||||
|
# operations, and bookmark→library copies. Empty = post id.
|
||||||
|
layout.addWidget(QLabel("Library filename template:"))
|
||||||
|
self._library_filename_template = QLineEdit(
|
||||||
|
self._db.get_setting("library_filename_template") or ""
|
||||||
|
)
|
||||||
|
self._library_filename_template.setPlaceholderText("e.g. %artist%_%id% (leave blank for post id)")
|
||||||
|
layout.addWidget(self._library_filename_template)
|
||||||
|
tmpl_help = QLabel(
|
||||||
|
"Tokens: %id% %md5% %ext% %rating% %score% "
|
||||||
|
"%artist% %character% %copyright% %general% %meta% %species%\n"
|
||||||
|
"Applies to every save action: Save to Library, Save As, Batch Download, "
|
||||||
|
"multi-select bulk operations, and bookmark→library copies.\n"
|
||||||
|
"Note: Gelbooru and Moebooru only support %id% / %md5% / %score% / %rating% / %ext%."
|
||||||
|
)
|
||||||
|
tmpl_help.setWordWrap(True)
|
||||||
|
tmpl_help.setStyleSheet("color: palette(mid); font-size: 10pt;")
|
||||||
|
layout.addWidget(tmpl_help)
|
||||||
|
|
||||||
open_btn = QPushButton("Open Data Folder")
|
open_btn = QPushButton("Open Data Folder")
|
||||||
open_btn.clicked.connect(self._open_data_folder)
|
open_btn.clicked.connect(self._open_data_folder)
|
||||||
layout.addWidget(open_btn)
|
layout.addWidget(open_btn)
|
||||||
@ -761,6 +781,7 @@ class SettingsDialog(QDialog):
|
|||||||
self._db.set_setting("infinite_scroll", "1" if self._infinite_scroll.isChecked() else "0")
|
self._db.set_setting("infinite_scroll", "1" if self._infinite_scroll.isChecked() else "0")
|
||||||
self._db.set_setting("slideshow_monitor", self._monitor_combo.currentText())
|
self._db.set_setting("slideshow_monitor", self._monitor_combo.currentText())
|
||||||
self._db.set_setting("library_dir", self._library_dir.text().strip())
|
self._db.set_setting("library_dir", self._library_dir.text().strip())
|
||||||
|
self._db.set_setting("library_filename_template", self._library_filename_template.text().strip())
|
||||||
self._db.set_setting("max_cache_mb", str(self._max_cache.value()))
|
self._db.set_setting("max_cache_mb", str(self._max_cache.value()))
|
||||||
self._db.set_setting("max_thumb_cache_mb", str(self._max_thumb_cache.value()))
|
self._db.set_setting("max_thumb_cache_mb", str(self._max_thumb_cache.value()))
|
||||||
self._db.set_setting("auto_evict", "1" if self._auto_evict.isChecked() else "0")
|
self._db.set_setting("auto_evict", "1" if self._auto_evict.isChecked() else "0")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user