feat: ML tag suggestions, character/fandom integrity, underscores, modal polish

Consolidated merge of feat/tag-suggestions branch. Original 64-commit history
was lost to git-object corruption in a Nextcloud-synced checkout; this single
commit captures the equivalent diff.

Includes:
- pgvector-backed tag suggestion infra (WD14 + SigLIP centroids, ml-worker
  container, Celery tasks, suggestion service, accept/reject endpoints + modal
  UI with green/red chip buttons)
- Character/fandom integrity: title-case normalization on every write path,
  fandom-id backfill, maintenance task + settings button, migrations g26041901
  + h26041901 to canonicalize legacy rows with case-only duplicate merging
- Tag-underscores + modal polish: WD14 name canonicalization at emit + accept
  + add/bulk-add paths, migration i26041901 for legacy-row rename-or-merge
  across character/fandom/NULL kinds, suggestion-accept refresh parity via
  awaited loadTags, persistent chip tint
This commit is contained in:
2026-04-19 19:50:58 -04:00
parent 69b3ddcbd0
commit 0f35a0c484
37 changed files with 8642 additions and 30 deletions
@@ -0,0 +1,318 @@
# Clickable Post Tag Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** In the gallery/showcase image modal, move the system-generated `post` tag out of the editable tag list into a dedicated, visually distinct provenance section whose chip links to the tag-filtered gallery view.
**Architecture:** Frontend-only (Jinja template + vanilla JS + CSS). No backend or API changes — the existing `/image/<id>/tags` and `/api/post-metadata/by-tag-name/<name>` endpoints cover everything. Tags returned by the image-tags endpoint are partitioned client-side into editable vs. provenance lists and rendered into separate containers.
**Tech Stack:** Jinja2 templates, vanilla JavaScript, single CSS file (`app/static/style.css`)
**Spec:** `docs/superpowers/specs/2026-04-18-clickable-post-tag-design.md`
**Testing:** This repo has no automated test harness for the frontend. Verification is a manual browser check at the end of the plan. Each earlier task ends with a commit; the browser check is gated to after all code is in place.
---
## File Map
| File | Task(s) | What changes |
|------|---------|--------------|
| `app/templates/_gallery_modal.html` | 1 | New `modal-provenance-section` between Series and Tags sections |
| `app/static/style.css` | 2 | `.modal-provenance-section`, `.provenance-chip`, platform accent selectors |
| `app/static/js/view-modal.js` | 3 | Cache provenance DOM refs; partition tags in `loadTags`; add `renderProvenance`, `getProvenanceDisplayName`, `upgradeProvenanceChip`; clear provenance in `closeModal` |
---
## Task 1: Add provenance section markup to the modal
**Files:**
- Modify: `app/templates/_gallery_modal.html` (insert between line 48 and line 50)
### Step 1.1: Open the modal template
- [ ] Open `app/templates/_gallery_modal.html`.
- [ ] Locate the end of the Series section — the `</div>` on line 48 that closes `modal-series-section`, immediately followed by `<!-- SECTION: Tags -->` on line 50.
### Step 1.2: Insert the provenance section between Series and Tags
- [ ] Insert this block on a new line between the closing `</div>` of the Series section and the `<!-- SECTION: Tags -->` comment:
```html
<!-- SECTION: Provenance (system-generated tags: post; future: source, archive) -->
<div class="modal-sidebar-section modal-provenance-section" id="modalProvenanceSection" style="display: none;">
<div id="modalProvenanceList" class="provenance-list"></div>
</div>
```
The `style="display: none;"` matches the inline-style pattern already used by `modalSeriesInfo` and `modalAddToSeries` in this same file (per the project's "inline style over CSS class toggling" convention from commit `e37ce27`).
### Step 1.3: Verify the insertion
- [ ] Run: `grep -n "modalProvenanceSection\|modal-tags-section" app/templates/_gallery_modal.html`
- [ ] Expected: `modalProvenanceSection` appears on an earlier line than `modal-tags-section`.
### Step 1.4: Commit
```bash
git add app/templates/_gallery_modal.html
git commit -m "add empty provenance section to gallery modal sidebar"
```
---
## Task 2: Add CSS for provenance section and chip
**Files:**
- Modify: `app/static/style.css` (append new block)
### Step 2.1: Find the end of the modal sidebar / tag chip section in the stylesheet
- [ ] Open `app/static/style.css`.
- [ ] Run: `grep -n "Tag chips in modal - with remove button" app/static/style.css`
- [ ] Navigate to that block (around line 816). Scroll to the end of the rule block that styles `.tag-editor .tag-chip` — the last closing `}` of that rule group.
### Step 2.2: Append the provenance CSS
- [ ] Immediately after that closing `}`, append:
```css
/* Modal provenance section — system-generated origin tags (post; future: source/archive) */
.modal-provenance-section .provenance-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.provenance-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 2px 8px 2px 10px;
border-radius: 999px;
background: transparent;
color: var(--text-dim);
text-decoration: none;
border: 1px solid rgba(255, 255, 255, 0.15);
border-left-width: 2px;
border-left-color: rgba(255, 255, 255, 0.4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font: 12px/1.6 system-ui, sans-serif;
max-width: clamp(8rem, 22vw, 14rem);
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.provenance-chip:hover {
border-color: rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.provenance-chip[data-platform="patreon"] {
border-left-color: #f96854;
}
.provenance-chip[data-platform="subscribestar"] {
border-left-color: #009cde;
}
.provenance-chip[data-platform="hentaifoundry"] {
border-left-color: #c8232c;
}
.provenance-chip .provenance-arrow {
opacity: 0.6;
font-size: 0.85em;
}
```
### Step 2.3: Commit
```bash
git add app/static/style.css
git commit -m "style provenance chip with platform-tinted ghost treatment"
```
---
## Task 3: Partition tags and render the provenance chip in view-modal.js
**Files:**
- Modify: `app/static/js/view-modal.js`
This task has several edits inside one file. Make them in order; commit once at the end.
### Step 3.1: Cache the new DOM references
- [ ] Open `app/static/js/view-modal.js`.
- [ ] Find the block that caches tag editor elements (around lines 11-16), beginning with `const tagEditor = document.getElementById('modalTagEditor');`.
- [ ] Immediately after the line `const tagActionFeedback = document.getElementById('tagActionFeedback');`, insert:
```javascript
const provenanceSection = document.getElementById('modalProvenanceSection');
const provenanceList = document.getElementById('modalProvenanceList');
```
### Step 3.2: Add the provenance display-name helper
- [ ] Find the `renderTags` function (starts around line 85).
- [ ] Immediately **above** `function renderTags(tags) {`, insert this helper:
```javascript
function getProvenanceDisplayName(name) {
// Post tag names are "post:platform:artist:id". Strip the "post:" prefix only.
if (name.startsWith('post:')) return name.slice(5);
return name;
}
```
### Step 3.3: Add renderProvenance and upgradeProvenanceChip
- [ ] Directly **below** the existing `renderTags` function (after its closing `}` around line 96), insert:
```javascript
function renderProvenance(tags) {
if (!provenanceSection || !provenanceList) return;
provenanceList.innerHTML = '';
if (!tags || tags.length === 0) {
provenanceSection.style.display = 'none';
return;
}
tags.forEach(t => {
const chip = document.createElement('a');
chip.className = 'provenance-chip';
chip.href = `/gallery?tag=${encodeURIComponent(t.name)}`;
chip.title = t.name;
chip.dataset.tagName = t.name;
const placeholder = escapeHtml(getProvenanceDisplayName(t.name));
chip.innerHTML = `📌 <span class="provenance-label">${placeholder}</span> <span class="provenance-arrow">↗</span>`;
provenanceList.appendChild(chip);
upgradeProvenanceChip(chip, t.name);
});
provenanceSection.style.display = '';
}
async function upgradeProvenanceChip(chip, tagName) {
try {
const r = await fetch(`/api/post-metadata/by-tag-name/${encodeURIComponent(tagName)}`);
const j = await r.json();
if (!j.ok || !j.metadata) return;
const pm = j.metadata;
if (pm.platform) chip.dataset.platform = pm.platform;
const label = pm.artist || pm.title;
if (label) {
const labelEl = chip.querySelector('.provenance-label');
if (labelEl) labelEl.textContent = label;
}
} catch {
// Fetch failed — chip keeps its placeholder label and neutral accent.
}
}
```
### Step 3.4: Partition tags in loadTags
- [ ] Find the current `loadTags` function (around lines 97-106):
```javascript
async function loadTags(imageId) {
if (!imageId) { renderTags([]); return; }
try {
const r = await fetch(`/image/${imageId}/tags`);
const j = await r.json();
if (j.ok) renderTags(j.tags);
} catch {
// ignore
}
}
```
- [ ] Replace it with:
```javascript
async function loadTags(imageId) {
if (!imageId) { renderTags([]); renderProvenance([]); return; }
try {
const r = await fetch(`/image/${imageId}/tags`);
const j = await r.json();
if (j.ok) {
const all = j.tags || [];
const postTags = all.filter(t => t.kind === 'post');
const editableTags = all.filter(t => t.kind !== 'post');
renderTags(editableTags);
renderProvenance(postTags);
}
} catch {
// ignore
}
}
```
### Step 3.5: Clear provenance on modal close
- [ ] Find the `closeModal` function (around lines 615-634).
- [ ] Locate the line `renderTags([]);` inside it (around line 625).
- [ ] Immediately **after** that line, add:
```javascript
renderProvenance([]);
```
### Step 3.6: Commit
```bash
git add app/static/js/view-modal.js
git commit -m "split post tags into provenance section in image modal"
```
---
## Task 4: Manual browser verification
**No code changes. This is a gate before declaring the feature done.**
### Step 4.1: Start the app
- [ ] Follow whatever start procedure this project uses locally (e.g., `docker compose up` or `python run.py` — consult `README.md` / `entrypoint.sh` if unsure).
- [ ] Open the showcase in a browser.
### Step 4.2: Verify with an image that has a post tag
- [ ] In the Tags nav, pick any post-backed image — or open the showcase and click any image known to come from an imported post (Patreon / SubscribeStar / HentaiFoundry).
- [ ] Confirm:
- A new chip-only section appears in the modal sidebar, **above** the editable tag list and **below** the Series section.
- The chip displays 📌, a text label, and a trailing ↗.
- The chip's **left border** is tinted with the platform color (orange / teal / red).
- The label starts as the raw `platform:artist:id` placeholder, then updates to the artist (or title, if no artist) once the platform metadata request resolves.
- The post tag is **not** present in the editable tag list below.
- Hovering the chip brightens the border and background.
- Clicking the chip navigates to `/gallery?tag=<post tag name>` and the gallery page shows the post's title/description/source URL block.
### Step 4.3: Verify with an image that has NO post tag
- [ ] Open an image with no post tag (e.g., a manually uploaded image without import metadata).
- [ ] Confirm:
- The provenance section is **not visible** — no empty heading, no empty chip row, no stray divider.
- The editable tag list still renders unchanged.
### Step 4.4: Verify the metadata-fetch failure path (optional smoke test)
- [ ] In DevTools, block requests to `/api/post-metadata/by-tag-name/*` and reopen the modal on a post-backed image.
- [ ] Confirm the chip renders with the neutral-grey left border (no platform tint) and the raw `platform:artist:id` label, and clicking it still navigates to the filtered gallery.
### Step 4.5: Navigate between images with arrow keys
- [ ] From a post-backed image modal, press the right-arrow key to advance through several images of mixed types (with and without post tags).
- [ ] Confirm the provenance section shows and hides correctly per image, with no stale chip left over from the previous image.
---
## Self-Review Notes
- Spec coverage: placement between Series and Tags ✓ (Task 1), hidden when empty ✓ (Task 3.3, 3.4, 3.5), platform-tinted ghost chip ✓ (Task 2), click → filtered gallery ✓ (Task 3.3), partition so post tag is removed from editable list ✓ (Task 3.4), placeholder + metadata upgrade ✓ (Task 3.3), fallback accent on fetch failure ✓ (Task 4.4 verifies).
- No placeholders in the plan: every code step shows the full snippet to insert; no "similar to" references.
- Type consistency: `renderProvenance`, `upgradeProvenanceChip`, `getProvenanceDisplayName` are named consistently across steps; DOM ids (`modalProvenanceSection`, `modalProvenanceList`) match between template and JS.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff