Files
photoanalyzer/nsfwtag/webapp.py

92 lines
4.6 KiB
Python

"""Render the review page.
The HTML/CSS/JS lives in review.html (loaded once at import). This module fills
in the %%TOKENS%%: the folder-tree sidebar, the image cards, totals. The server
contract is preserved: each .nsfw checkbox's value is the image's absolute path,
thumbnails load via GET /img?path=, actions POST to /apply /untag /tagged.
"""
import html
import os
from pathlib import Path
from urllib.parse import quote
# Inline Lucide-style SVG icons (no emoji, theme-able via currentColor).
_SVG_EXPAND = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3M8 21H5a2 2 0 0 1-2-2v-3m18 0v3a2 2 0 0 1-2 2h-3"/></svg>'
_SVG_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
_PAGE = (Path(__file__).parent / "review.html").read_text(encoding="utf-8")
def folder_tree_html(cands):
"""Indented folder list for the sidebar file browser, built from the candidate
paths. Each row filters the grid to that folder + its subfolders (prefix match);
'All folders' resets. Counts are images directly in that folder."""
folders = {}
for p, _ in cands:
folders[str(p.parent)] = folders.get(str(p.parent), 0) + 1
if not folders:
return ""
paths = sorted(folders)
root = os.path.commonpath(paths) if len(paths) > 1 else paths[0]
rows = [
f'<button type="button" class="fitem on" data-folder="" onclick="pickFolder(this)">'
f'<span class="fname">All folders</span><span class="fcount">{sum(folders.values())}</span></button>'
]
for folder in paths:
rel = os.path.relpath(folder, root)
depth = 0 if rel == "." else rel.count(os.sep) + 1
name = os.path.basename(folder) or folder
rows.append(
f'<button type="button" class="fitem" data-folder="{html.escape(folder.lower(), quote=True)}" '
f'style="padding-left:{depth * 14 + 12}px" title="{html.escape(folder, quote=True)}" '
f'onclick="pickFolder(this)"><span class="fname">{html.escape(name)}</span>'
f'<span class="fcount">{folders[folder]}</span></button>'
)
return "".join(rows)
def render_page(cands, threshold):
"""Build the review page. cands: list of (Path, score) sorted desc."""
cards, seen = [], set()
for p, score in cands:
sp = str(p)
if sp in seen: # never list the same path twice
continue
seen.add(sp)
# No pre-selection at render. The per-folder scan decides state so persisted
# marks win: nsfw -> pink, sfw -> green, undecided high-score -> red.
# (A dismissed/sfw image must never come back pre-selected red on reload.)
checked = ""
src = f"/img?path={quote(sp)}"
esc = html.escape(sp, quote=True)
name = html.escape(p.name)
folder = html.escape(str(p.parent))
folder_attr = html.escape(str(p.parent), quote=True)
name_low = html.escape(p.name.lower(), quote=True)
folder_low = html.escape(str(p.parent).lower(), quote=True)
cards.append(
f'<div class="card" data-score="{score:.4f}" data-name="{name_low}" '
f'data-folder="{folder_low}" data-hay="{name_low} {folder_low}" '
f'data-tagged="?" data-sfw="?">'
f'<div class="thumb">'
f'<img loading="lazy" src="{src}" alt="">'
f'<span class="score" data-s="{score:.4f}">{score:.2f}</span>'
f'<button type="button" class="icon zoom" aria-label="View full size" '
f'data-src="{src}" data-name="{name}" data-score="{score:.2f}" '
f'onclick="openLb(this)">{_SVG_EXPAND}</button>'
f'<span class="tagged" hidden>{_SVG_CHECK}<span>tagged</span></span>'
f'<span class="safe" hidden>{_SVG_CHECK}<span>tagged</span></span>'
f'<button type="button" class="tick" aria-label="Mark {name} NSFW or SFW" '
f'onclick="toggleCard(this)">{_SVG_CHECK}</button>'
f'<input type="checkbox" class="nsfw" value="{esc}" {checked} tabindex="-1" aria-hidden="true">'
f'</div>'
f'<div class="info"><div class="name" title="{esc}">{name}</div>'
f'<div class="path" title="{folder_attr}">{folder}</div></div>'
f'</div>'
)
return (_PAGE
.replace("%%TREE%%", folder_tree_html(cands))
.replace("%%CARDS%%", "".join(cards))
.replace("%%TOTAL%%", str(len(cards)))
.replace("%%THRESHOLD%%", f"{threshold:.2f}"))