From edf0735098171344dcaf7f26f8cd6b6a57b0d05e Mon Sep 17 00:00:00 2001 From: mischa Date: Sat, 27 Jun 2026 17:35:58 +0200 Subject: [PATCH] feat(trip-cluster): master/detail review routes + templates + clusterReview JS --- apps/trip-cluster/app/routes/nav.py | 15 +- apps/trip-cluster/app/routes/review.py | 128 ++++++++++++- apps/trip-cluster/app/static/app.js | 182 +++++++++++++++++++ apps/trip-cluster/app/templates/_detail.html | 41 +++++ apps/trip-cluster/app/templates/review.html | 71 ++++++++ apps/trip-cluster/tests/test_routes.py | 74 ++++++++ 6 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 apps/trip-cluster/app/static/app.js create mode 100644 apps/trip-cluster/app/templates/_detail.html create mode 100644 apps/trip-cluster/app/templates/review.html diff --git a/apps/trip-cluster/app/routes/nav.py b/apps/trip-cluster/app/routes/nav.py index 2245db8..fe0a50a 100644 --- a/apps/trip-cluster/app/routes/nav.py +++ b/apps/trip-cluster/app/routes/nav.py @@ -1,8 +1,14 @@ -from flask import Blueprint +from flask import Blueprint, current_app, render_template +from photoflow.core import Store bp = Blueprint("nav", __name__) +def _store(): + cfg = current_app.config["APP_CONFIG"] + return Store(cfg.db_path).connect() + + @bp.route("/health") def health(): return "ok" @@ -10,4 +16,9 @@ def health(): @bp.route("/") def index(): - return "trip-cluster" + s = _store() + clusters = s.clusters_by_attention() + stats = {"assets": len(s.all_assets()), "clusters": len(clusters), + "pending": sum(1 for c in clusters if c.status == "pending")} + s.close() + return render_template("review.html", clusters=clusters, stats=stats) diff --git a/apps/trip-cluster/app/routes/review.py b/apps/trip-cluster/app/routes/review.py index de13b75..0d26f18 100644 --- a/apps/trip-cluster/app/routes/review.py +++ b/apps/trip-cluster/app/routes/review.py @@ -1,3 +1,129 @@ -from flask import Blueprint +from flask import Blueprint, current_app, render_template, request, jsonify, abort +from photoflow.core import Store +from app import review bp = Blueprint("review", __name__) + + +def _store(): + cfg = current_app.config["APP_CONFIG"] + return Store(cfg.db_path).connect() + + +def _client(): + factory = current_app.config.get("IMMICH_FACTORY") + if factory: + return factory() + from photoflow.immich import ImmichClient + cfg = current_app.config["APP_CONFIG"] + return ImmichClient(cfg.immich_url, cfg.immich_api_key) + + +def _body(): + return request.get_json(silent=True) or {} + + +@bp.route("/cluster/") +def detail(cid): + s = _store() + c = s.get_cluster(cid) + if c is None: + s.close() + abort(404) + members = s.cluster_members(cid) + prev_id, next_id = s.chronological_neighbors(cid) + s.close() + return render_template("_detail.html", c=c, members=members, + prev_id=prev_id, next_id=next_id) + + +@bp.route("/cluster//approve", methods=["POST"]) +def approve(cid): + s = _store() + review.approve(s, cid, _body().get("name")) + c = s.get_cluster(cid) + s.close() + return jsonify({"ok": True, "status": c.status, "name": c.decided_name}) + + +@bp.route("/cluster//non-trip", methods=["POST"]) +def non_trip(cid): + s = _store() + review.mark_non_trip(s, cid) + s.close() + return jsonify({"ok": True, "status": "non_trip"}) + + +@bp.route("/cluster//skip", methods=["POST"]) +def skip(cid): + s = _store() + review.skip(s, cid) + s.close() + return jsonify({"ok": True, "status": "skipped"}) + + +@bp.route("/cluster//split", methods=["POST"]) +def split(cid): + s = _store() + boundary = _body().get("boundary_asset_id") + if not boundary: + s.close() + return jsonify({"error": "boundary_asset_id required"}), 400 + try: + id1, id2 = review.split(s, cid, boundary) + except ValueError as e: + s.close() + return jsonify({"error": str(e)}), 400 + s.close() + return jsonify({"ok": True, "ids": [id1, id2]}) + + +@bp.route("/cluster//merge", methods=["POST"]) +def merge(cid): + s = _store() + other = _body().get("other_id") + if other is None: + s.close() + return jsonify({"error": "other_id required"}), 400 + new_id = review.merge(s, cid, int(other)) + s.close() + return jsonify({"ok": True, "id": new_id}) + + +@bp.route("/cluster//member", methods=["POST"]) +def member(cid): + s = _store() + body = _body() + review.set_member(s, cid, body["asset_id"], bool(body.get("included"))) + s.close() + return jsonify({"ok": True}) + + +@bp.route("/approve-high-confidence", methods=["POST"]) +def approve_high_confidence(): + s = _store() + threshold = float(_body().get("threshold", 0.75)) + n = review.approve_high_confidence(s, threshold) + s.close() + return jsonify({"approved": n}) + + +@bp.route("/cluster//apply", methods=["POST"]) +def apply_one(cid): + # Lazy import: app.writeback is owned by a later task and may be absent + # at app-startup; importing here keeps the blueprint importable regardless. + from app.writeback import apply_cluster + s = _store() + res = apply_cluster(_client(), s, cid) + s.close() + return jsonify(res) + + +@bp.route("/apply-all", methods=["POST"]) +def apply_everything(): + # Lazy import: see apply_one above. + from app.writeback import apply_all + s = _store() + results = apply_all(_client(), s) + s.close() + return jsonify({"results": results}) diff --git a/apps/trip-cluster/app/static/app.js b/apps/trip-cluster/app/static/app.js new file mode 100644 index 0000000..7745dc0 --- /dev/null +++ b/apps/trip-cluster/app/static/app.js @@ -0,0 +1,182 @@ +function clusterReview() { + return { + ...photoGrid(), + selected: null, + clusterIds: [], + failures: [], + + init() { + this.clusterIds = [...document.querySelectorAll('.cluster-row')] + .map(el => parseInt(el.dataset.clusterId, 10)); + this.selected = this.clusterIds.length ? this.clusterIds[0] : null; + document.body.addEventListener('htmx:afterSwap', (e) => { + if (e.target.id === 'detail') { + this.hideIndicator(); + this.selectFirst(); + } + }); + }, + + // --- F16: loading indicator for programmatic swaps ----------------- + showIndicator() { + const el = document.getElementById('detail-indicator'); + if (el) el.classList.add('htmx-request'); + }, + hideIndicator() { + const el = document.getElementById('detail-indicator'); + if (el) el.classList.remove('htmx-request'); + }, + + selectCluster(id) { + this.selected = id; + this.showIndicator(); + htmx.ajax('GET', `/cluster/${id}`, { target: '#detail' }); + }, + + moveCluster(dir) { + const n = this.clusterIds.length; + if (!n) return; + const i = this.clusterIds.indexOf(this.selected); + const j = ((i + dir) % n + n) % n; // wrap around (mirrors nextPendingId) + if (this.clusterIds[j] != null) this.selectCluster(this.clusterIds[j]); + }, + + // --- F3: in-place rail status + advance to next pending ------------ + statusClass(status) { + return ({ + pending: 'badge-ghost', approved: 'badge-success', non_trip: 'badge-neutral', + skipped: 'badge-warning', merged: 'badge-info', split: 'badge-info', + })[status] || 'badge-ghost'; + }, + railStatusEl(id) { + return document.querySelector(`.cluster-status[data-cluster-id="${id}"]`); + }, + railStatusText(id) { + const el = this.railStatusEl(id); + return el ? el.textContent.trim() : ''; + }, + updateRailStatus(id, status) { + const el = this.railStatusEl(id); + if (el) el.innerHTML = + `${status}`; + }, + nextPendingId(fromId) { + const n = this.clusterIds.length; + const start = Math.max(0, this.clusterIds.indexOf(fromId)); + for (let k = 1; k <= n; k++) { + const id = this.clusterIds[(start + k) % n]; + if (id !== fromId && this.railStatusText(id) === 'pending') return id; + } + return null; + }, + // Update the acted cluster's rail badge in place (keeping the new status + // text visible) and advance the detail pane to the next pending cluster + // instead of bouncing back to the attention-queue head. + afterDecision(id, status) { + this.updateRailStatus(id, status); + const next = this.nextPendingId(id); + if (next != null) this.selectCluster(next); + else this.selectCluster(id); // no pending left: refresh current detail + }, + + nameValue() { + const el = document.getElementById('cluster-name'); + return el ? el.value : ''; + }, + + async post(path, body) { + const res = await fetch(path, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}), + }); + return res.json(); + }, + + async approve() { + const acted = this.selected; + const r = await this.post(`/cluster/${acted}/approve`, { name: this.nameValue() }); + this.afterDecision(acted, r.status || 'approved'); + }, + async nonTrip() { + const acted = this.selected; + const r = await this.post(`/cluster/${acted}/non-trip`); + this.afterDecision(acted, r.status || 'non_trip'); + }, + async skip() { + const acted = this.selected; + const r = await this.post(`/cluster/${acted}/skip`); + this.afterDecision(acted, r.status || 'skipped'); + }, + async split() { + // F7: require a focused boundary photo before splitting. + if (!this.focused) { alert('Focus the first photo of the second trip, then Split.'); return; } + await this.post(`/cluster/${this.selected}/split`, { boundary_asset_id: this.focused.dataset.assetId }); + location.reload(); // split restructures the rail: full refresh is correct + }, + async merge(otherId) { + // F17: merge terminally marks both clusters merged with no UI un-merge. + if (!confirm('Merge these two clusters? This cannot be undone in the UI.')) return; + await this.post(`/cluster/${this.selected}/merge`, { other_id: otherId }); + location.reload(); // merge restructures the rail: full refresh is correct + }, + async setMember(assetId, included) { + await this.post(`/cluster/${this.selected}/member`, { asset_id: assetId, included }); + this.selectCluster(this.selected); + }, + async approveHighConfidence() { + // F19: show a pre-action count and confirm. + const threshold = 0.75; + const n = this.clusterIds.filter(id => { + const row = document.querySelector(`.cluster-row[data-cluster-id="${id}"]`); + return row && row.querySelector('.badge-success[title]'); // confidence "high" + }).length; + if (!confirm(`Approve ${n} high-confidence cluster(s) (>= ${threshold})?`)) return; + const r = await this.post('/approve-high-confidence', { threshold }); + alert(`Approved ${r.approved} cluster(s).`); + location.reload(); + }, + async apply() { + const r = await this.post(`/cluster/${this.selected}/apply`, {}); + alert(`Applied ${r.succeeded.length}, failed ${r.failed.length}.`); + }, + async applyAll() { + if (!confirm('Apply all approved decisions to Immich?')) return; + const r = await this.post('/apply-all', {}); + const results = r.results || []; + let ok = 0, bad = 0; + this.failures = []; + for (const x of results) { + const succeeded = x.succeeded || []; + const failed = x.failed || []; + ok += succeeded.length; + bad += failed.length; + if (failed.length) { + const id = x.cluster_id != null ? x.cluster_id : x.id; + const row = document.querySelector(`.cluster-row[data-cluster-id="${id}"]`); + if (row) row.classList.add('badge-error', 'ring-1', 'ring-error'); + this.failures.push({ + id, + name: row ? row.dataset.clusterName : `cluster ${id}`, + failed: failed.length, + }); + } + } + alert(`Applied ${ok} write(s), ${bad} failure(s).`); + }, + + onKey(e) { + if (e.target.tagName === 'INPUT') return; + const k = e.key.toLowerCase(); + if (e.key === '[') { e.preventDefault(); this.moveCluster(-1); } + else if (e.key === ']') { e.preventDefault(); this.moveCluster(1); } + else if (e.key === 'ArrowLeft') { e.preventDefault(); this.navigate(-1); } + else if (e.key === 'ArrowRight') { e.preventDefault(); this.navigate(1); } + else if (e.key === 'Enter') { if (this.focused) this.openLightbox(this.focused); } + else if (e.key === 'Escape') { this.closeLightbox(); } + else if (k === 'a') { this.approve(); } + else if (k === 'n') { this.nonTrip(); } + else if (k === 's') { if (this.focused) this.split(); } // F7: suppress until focused + else if (k === 'x') { this.skip(); } + }, + }; +} diff --git a/apps/trip-cluster/app/templates/_detail.html b/apps/trip-cluster/app/templates/_detail.html new file mode 100644 index 0000000..f371e95 --- /dev/null +++ b/apps/trip-cluster/app/templates/_detail.html @@ -0,0 +1,41 @@ +{% from "macros.html" import lightbox %} +
+
+ + + + + + + + {% if prev_id %}{% endif %} + {% if next_id %}{% endif %} +
+ +
+ Focus the first photo of the second trip (click it or use ← →), then Split. +
+
+ {{ c.start_at[:10] }} → {{ c.end_at[:10] }} · {{ members | length }} assets · status {{ c.status }} +
+
+ {% for a, m in members %} +
+ + {% if m.flagged_coverage %} + + {% elif m.is_outlier %} + + {% endif %} + {% if not m.included %}
{% endif %} +
+ {% endfor %} +
+ {{ lightbox() }} +
diff --git a/apps/trip-cluster/app/templates/review.html b/apps/trip-cluster/app/templates/review.html new file mode 100644 index 0000000..0a8a1ee --- /dev/null +++ b/apps/trip-cluster/app/templates/review.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% from "macros.html" import confidence_badge, status_badge %} +{% block title %}trip-cluster{% endblock %} +{% block navbar_title %}trip-cluster{% endblock %} +{% block content %} +{% if stats.clusters == 0 %} +
+

No clusters yet

+ {% if stats.assets == 0 %} +

Nothing ingested. Run + categorize ingest then categorize cluster.

+ {% else %} +

{{ stats.assets }} assets ingested, but this scope + produced no clusters. Try a wider categorize ingest scope.

+ {% endif %} +
+{% else %} +
+
+
+ Clusters + +
+ +

Needs attention first — lowest-confidence pending.

+ {% if stats.pending == 0 %} +
All reviewed — “Apply all” or categorize apply.
+ {% endif %} + +
+ Write failures: +
    + +
+
+ {% for c in clusters %} +
+
+ {{ c.decided_name or c.suggested_name }} + {{ confidence_badge(c.confidence) }} +
+
+ {{ status_badge(c.status) }} + {{ c.count }} + {{ c.start_at[:10] }} +
+
+ {% endfor %} + +
+
+ +
+ +
+
+
+
+
+ [ ] cluster · ← → grid · Enter open · A approve · N non-trip · S split (focus a photo) · X skip · Esc close +
+{% endif %} +{% endblock %} +{% block extra_scripts %}{% endblock %} diff --git a/apps/trip-cluster/tests/test_routes.py b/apps/trip-cluster/tests/test_routes.py index 18de583..e2a6036 100644 --- a/apps/trip-cluster/tests/test_routes.py +++ b/apps/trip-cluster/tests/test_routes.py @@ -1,6 +1,25 @@ import os from app import create_app from app.config import Config +from photoflow.core import Store +from photoflow.core.models import Asset, Cluster, ClusterMember + + +def _seed_store(cfg): + s = Store(cfg.db_path).connect() + for i, t in [("a", "2019-06-01"), ("b", "2019-06-02"), ("c", "2019-07-10")]: + s.upsert_asset(Asset(immich_id=i, taken_at=t)) + cid = s.insert_cluster( + Cluster(start_at="2019-06-01", end_at="2019-06-02", suggested_name="Venice", + confidence=0.9, kind_guess="trip", status="pending"), + [ClusterMember(cluster_id=0, immich_id="a"), + ClusterMember(cluster_id=0, immich_id="b")]) + other = s.insert_cluster( + Cluster(start_at="2019-07-10", end_at="2019-07-10", suggested_name="Rome", + confidence=0.3, kind_guess="trip", status="pending"), + [ClusterMember(cluster_id=0, immich_id="c")]) + s.close() + return cid, other def _app(tmp_path): @@ -24,3 +43,58 @@ def test_thumb_served(tmp_path): r = client.get("/thumb/a") assert r.status_code == 200 and r.mimetype == "image/jpeg" assert _app(tmp_path).test_client().get("/thumb/missing").status_code == 404 + + +def test_index_lists_clusters(tmp_path): + app = _app(tmp_path) + _seed_store(app.config["APP_CONFIG"]) + r = app.test_client().get("/") + assert r.status_code == 200 and b"Venice" in r.data and b"Rome" in r.data + + +def test_index_empty_state(tmp_path): + r = _app(tmp_path).test_client().get("/") + assert r.status_code == 200 and b"No clusters yet" in r.data + + +def test_detail_and_approve(tmp_path): + app = _app(tmp_path) + cid, _ = _seed_store(app.config["APP_CONFIG"]) + client = app.test_client() + d = client.get(f"/cluster/{cid}") + assert d.status_code == 200 and b"/thumb/a" in d.data + r = client.post(f"/cluster/{cid}/approve", json={"name": "Venezia"}) + assert r.get_json()["status"] == "approved" + s = Store(app.config["APP_CONFIG"].db_path).connect() + assert s.get_cluster(cid).decided_name == "Venezia" + s.close() + + +def test_member_toggle_and_high_confidence(tmp_path): + app = _app(tmp_path) + cid, _ = _seed_store(app.config["APP_CONFIG"]) + client = app.test_client() + client.post(f"/cluster/{cid}/member", json={"asset_id": "b", "included": False}) + s = Store(app.config["APP_CONFIG"].db_path).connect() + assert {m.immich_id: m.included for _, m in s.cluster_members(cid)}["b"] is False + s.close() + r = client.post("/approve-high-confidence", json={"threshold": 0.75}) + assert r.get_json()["approved"] == 1 # only the 0.9 cluster + + +def test_apply_all_with_injected_client(tmp_path): + app = _app(tmp_path) + cid, _ = _seed_store(app.config["APP_CONFIG"]) + + class FakeImmich: + def __init__(self): self.tagged = [] + def upsert_tag(self, name): return f"id:{name}" + def tag_assets(self, tid, ids): self.tagged.append((tid, list(ids))) + + fake = FakeImmich() + app.config["IMMICH_FACTORY"] = lambda: fake + client = app.test_client() + client.post(f"/cluster/{cid}/approve", json={"name": "Venice"}) + r = client.post("/apply-all", json={}) + assert r.status_code == 200 + assert any(tid == "id:Venice" for tid, _ in fake.tagged)