Bootstrap project and safe work-item workflow
This commit is contained in:
214
webapp/query.py
Normal file
214
webapp/query.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Read-only DB access for the web UI: FTS5 search, facets, stats, one-photo.
|
||||
|
||||
The analysis pipeline (photo_analyzer.py) owns writes; this module only reads.
|
||||
Album = leaf folder relative to the library (same rule as photo_analyzer.album_label).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from . import PAGE_SIZE
|
||||
|
||||
# Columns sent to the browser for a card / lightbox. raw_response is intentionally
|
||||
# omitted from lists (big); the lightbox fetches it via /photo if ever needed.
|
||||
CARD_COLS = ("path", "status", "description", "tags", "people_count", "setting",
|
||||
"time_of_day", "season", "mood", "location_hint", "approx_year")
|
||||
|
||||
DONE = ("analyzed", "exif_written")
|
||||
|
||||
|
||||
def connect(db_path: str) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(db_path, check_same_thread=False) # server is multi-threaded
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def album_of(path: str, library: Path | None) -> str:
|
||||
"""Leaf folder relative to the library — mirrors photo_analyzer.album_label."""
|
||||
parent = Path(path).parent
|
||||
if library:
|
||||
try:
|
||||
rel = parent.relative_to(library)
|
||||
return "(root)" if str(rel) == "." else str(rel)
|
||||
except ValueError:
|
||||
pass
|
||||
return parent.name or str(parent)
|
||||
|
||||
|
||||
def _fts_query(q: str) -> str | None:
|
||||
"""Turn free text into a safe FTS5 MATCH string. Each word becomes a prefix
|
||||
term (AND-ed), so 'beach sun' matches 'beachy sunset'. Returns None if empty
|
||||
(→ caller browses instead of matching), so raw punctuation can't crash MATCH."""
|
||||
tokens = re.findall(r"\w+", q, re.UNICODE)
|
||||
return " ".join(f'"{t}"*' for t in tokens) if tokens else None
|
||||
|
||||
|
||||
def _where(filters: dict) -> tuple[list[str], list]:
|
||||
"""Build a WHERE clause fragment list + params from the facet filters."""
|
||||
clauses, params = [], []
|
||||
if v := filters.get("setting"):
|
||||
clauses.append("p.setting = ?"); params.append(v)
|
||||
if v := filters.get("tod"):
|
||||
clauses.append("p.time_of_day = ?"); params.append(v)
|
||||
if v := filters.get("season"):
|
||||
clauses.append("p.season = ?"); params.append(v)
|
||||
if v := filters.get("status"):
|
||||
clauses.append("p.status = ?"); params.append(v)
|
||||
people = filters.get("people")
|
||||
if people == "3+":
|
||||
clauses.append("p.people_count >= 3")
|
||||
elif people in ("0", "1", "2"):
|
||||
clauses.append("p.people_count = ?"); params.append(int(people))
|
||||
if filters.get("year_min"):
|
||||
clauses.append("p.approx_year >= ?"); params.append(int(filters["year_min"]))
|
||||
if filters.get("year_max"):
|
||||
clauses.append("p.approx_year <= ?"); params.append(int(filters["year_max"]))
|
||||
if filters.get("has_location"):
|
||||
clauses.append("p.location_hint IS NOT NULL AND TRIM(p.location_hint) != '' "
|
||||
"AND LOWER(p.location_hint) != 'null'")
|
||||
if album := filters.get("album"):
|
||||
# album is an absolute leaf-folder path; match it and its subfolders
|
||||
clauses.append("(p.path LIKE ? OR p.path LIKE ?)")
|
||||
params += [f"{album}/%", f"{album}%/%"]
|
||||
return clauses, params
|
||||
|
||||
|
||||
_SORTS = {
|
||||
"year": "p.approx_year DESC NULLS LAST, p.path",
|
||||
"people": "p.people_count DESC NULLS LAST, p.path",
|
||||
"recent": "p.analyzed_at DESC NULLS LAST, p.path",
|
||||
"path": "p.path",
|
||||
}
|
||||
|
||||
|
||||
def _row_to_card(r: sqlite3.Row) -> dict:
|
||||
d = {k: r[k] for k in CARD_COLS}
|
||||
try:
|
||||
d["tags"] = json.loads(r["tags"]) if r["tags"] else []
|
||||
except (ValueError, TypeError):
|
||||
d["tags"] = []
|
||||
return d
|
||||
|
||||
|
||||
def search(conn, q="", filters=None, sort="relevance", offset=0, limit=PAGE_SIZE):
|
||||
"""Paged result set. FTS5 when `q` is present, plain filter/browse otherwise.
|
||||
Returns {rows, total, offset, limit}."""
|
||||
filters = filters or {}
|
||||
clauses, params = _where(filters)
|
||||
match = _fts_query(q) if q else None
|
||||
|
||||
if match:
|
||||
base = ("FROM photos p JOIN photos_fts f ON p.id = f.rowid "
|
||||
"WHERE photos_fts MATCH ?")
|
||||
params = [match] + params
|
||||
order = "f.rank" if sort in ("relevance", "") else _SORTS.get(sort, "f.rank")
|
||||
else:
|
||||
base = "FROM photos p WHERE 1=1"
|
||||
order = _SORTS.get(sort if sort != "relevance" else "path", "p.path")
|
||||
if clauses:
|
||||
base += " AND " + " AND ".join(clauses)
|
||||
|
||||
total = conn.execute(f"SELECT COUNT(*) {base}", params).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
f"SELECT p.* {base} ORDER BY {order} LIMIT ? OFFSET ?",
|
||||
params + [limit, offset],
|
||||
).fetchall()
|
||||
return {"rows": [_row_to_card(r) for r in rows], "total": total,
|
||||
"offset": offset, "limit": limit}
|
||||
|
||||
|
||||
def photo(conn, path: str) -> dict | None:
|
||||
r = conn.execute("SELECT * FROM photos WHERE path = ?", (path,)).fetchone()
|
||||
if not r:
|
||||
return None
|
||||
d = {k: r[k] for k in r.keys() if k != "raw_response"}
|
||||
try:
|
||||
d["tags"] = json.loads(r["tags"]) if r["tags"] else []
|
||||
except (ValueError, TypeError):
|
||||
d["tags"] = []
|
||||
return d
|
||||
|
||||
|
||||
def _col_counts(conn, col: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
f"SELECT {col} AS v, COUNT(*) AS n FROM photos "
|
||||
f"WHERE {col} IS NOT NULL AND TRIM({col}) != '' GROUP BY {col} ORDER BY n DESC"
|
||||
).fetchall()
|
||||
return [{"value": r["v"], "count": r["n"]} for r in rows]
|
||||
|
||||
|
||||
def facets(conn, library: Path | None) -> dict:
|
||||
"""Distinct filter values + counts, plus the album tree, for the toolbar."""
|
||||
year = conn.execute(
|
||||
"SELECT MIN(approx_year), MAX(approx_year) FROM photos WHERE approx_year IS NOT NULL"
|
||||
).fetchone()
|
||||
albums: dict[str, int] = {}
|
||||
for (p,) in conn.execute("SELECT path FROM photos"):
|
||||
a = album_of(p, library)
|
||||
albums[a] = albums.get(a, 0) + 1
|
||||
return {
|
||||
"setting": _col_counts(conn, "setting"),
|
||||
"tod": _col_counts(conn, "time_of_day"),
|
||||
"season": _col_counts(conn, "season"),
|
||||
"status": _col_counts(conn, "status"),
|
||||
"year_min": year[0], "year_max": year[1],
|
||||
"albums": [{"album": a, "count": n} for a, n in sorted(albums.items())],
|
||||
}
|
||||
|
||||
|
||||
def _people_buckets(conn) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT CASE WHEN people_count >= 3 THEN '3+' ELSE CAST(people_count AS TEXT) END AS b, "
|
||||
"COUNT(*) AS n FROM photos WHERE people_count IS NOT NULL GROUP BY b"
|
||||
).fetchall()
|
||||
return [{"value": r["b"], "count": r["n"]} for r in rows]
|
||||
|
||||
|
||||
def _top_tags(conn, limit=40) -> list[dict]:
|
||||
counts: dict[str, int] = {}
|
||||
for (t,) in conn.execute("SELECT tags FROM photos WHERE tags IS NOT NULL"):
|
||||
try:
|
||||
for tag in json.loads(t):
|
||||
tag = str(tag).strip()
|
||||
if tag:
|
||||
counts[tag] = counts.get(tag, 0) + 1
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
top = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:limit]
|
||||
return [{"value": t, "count": n} for t, n in top]
|
||||
|
||||
|
||||
def stats(conn, library: Path | None) -> dict:
|
||||
status = {r["status"]: r["n"] for r in conn.execute(
|
||||
"SELECT status, COUNT(*) AS n FROM photos GROUP BY status")}
|
||||
total = sum(status.values())
|
||||
years = [{"value": r["y"], "count": r["n"]} for r in conn.execute(
|
||||
"SELECT approx_year AS y, COUNT(*) AS n FROM photos "
|
||||
"WHERE approx_year IS NOT NULL GROUP BY approx_year ORDER BY approx_year")]
|
||||
albums: dict[str, dict] = {}
|
||||
for r in conn.execute("SELECT path, status FROM photos"):
|
||||
a = album_of(r["path"], library)
|
||||
d = albums.setdefault(a, {"album": a, "done": 0, "total": 0})
|
||||
d["total"] += 1
|
||||
if r["status"] in DONE:
|
||||
d["done"] += 1
|
||||
errors = [{"path": r["path"], "error": r["error_message"]} for r in conn.execute(
|
||||
"SELECT path, error_message FROM photos WHERE status = 'error' ORDER BY path")]
|
||||
return {
|
||||
"total": total, "status": status,
|
||||
"setting": _col_counts(conn, "setting"),
|
||||
"time_of_day": _col_counts(conn, "time_of_day"),
|
||||
"season": _col_counts(conn, "season"),
|
||||
"people": _people_buckets(conn),
|
||||
"years": years,
|
||||
"top_tags": _top_tags(conn),
|
||||
"albums": sorted(albums.values(), key=lambda d: d["album"]),
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def all_paths(conn) -> set:
|
||||
"""Every registered path — the allow-set for /img and /exif (path validation)."""
|
||||
return {r[0] for r in conn.execute("SELECT path FROM photos")}
|
||||
Reference in New Issue
Block a user