feat: M1 foundation packages + trip-cluster app #1
@@ -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__)
|
bp = Blueprint("nav", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _store():
|
||||||
|
cfg = current_app.config["APP_CONFIG"]
|
||||||
|
return Store(cfg.db_path).connect()
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/health")
|
@bp.route("/health")
|
||||||
def health():
|
def health():
|
||||||
return "ok"
|
return "ok"
|
||||||
@@ -10,4 +16,9 @@ def health():
|
|||||||
|
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
def index():
|
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)
|
||||||
|
|||||||
@@ -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__)
|
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/<int:cid>")
|
||||||
|
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/<int:cid>/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/<int:cid>/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/<int:cid>/skip", methods=["POST"])
|
||||||
|
def skip(cid):
|
||||||
|
s = _store()
|
||||||
|
review.skip(s, cid)
|
||||||
|
s.close()
|
||||||
|
return jsonify({"ok": True, "status": "skipped"})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/cluster/<int:cid>/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/<int:cid>/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/<int:cid>/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/<int:cid>/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})
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
`<span class="badge badge-sm ${this.statusClass(status)}">${status}</span>`;
|
||||||
|
},
|
||||||
|
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(); }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{% from "macros.html" import lightbox %}
|
||||||
|
<div data-cluster-id="{{ c.id }}">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
<input id="cluster-name" class="input input-bordered input-sm"
|
||||||
|
value="{{ c.decided_name or c.suggested_name }}">
|
||||||
|
<button class="btn btn-sm btn-success" @click="approve()">Approve (A)</button>
|
||||||
|
<button class="btn btn-sm" @click="nonTrip()">Non-trip (N)</button>
|
||||||
|
<!-- F7: Split disabled until a boundary photo is focused -->
|
||||||
|
<button class="btn btn-sm" :disabled="!focused" @click="split()">Split (S)</button>
|
||||||
|
<button class="btn btn-sm" @click="skip()">Skip (X)</button>
|
||||||
|
<button class="btn btn-sm btn-ghost" @click="apply()">Apply</button>
|
||||||
|
{% if prev_id %}<button class="btn btn-xs" @click="merge({{ prev_id }})">⤺ merge prev</button>{% endif %}
|
||||||
|
{% if next_id %}<button class="btn btn-xs" @click="merge({{ next_id }})">merge next ⤻</button>{% endif %}
|
||||||
|
</div>
|
||||||
|
<!-- F7: visible instruction for Split -->
|
||||||
|
<div class="text-xs opacity-60 mb-2" x-show="!focused">
|
||||||
|
Focus the first photo of the second trip (click it or use ← →), then Split.
|
||||||
|
</div>
|
||||||
|
<div class="text-sm opacity-60 mb-2">
|
||||||
|
{{ c.start_at[:10] }} → {{ c.end_at[:10] }} · {{ members | length }} assets · status {{ c.status }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
||||||
|
{% for a, m in members %}
|
||||||
|
<div class="photo-card relative cursor-pointer rounded-lg overflow-hidden border-2
|
||||||
|
{% if m.flagged_coverage %}border-info{% elif m.is_outlier %}border-warning{% else %}border-transparent{% endif %}"
|
||||||
|
data-asset-id="{{ a.immich_id }}" data-included="{{ '1' if m.included else '' }}"
|
||||||
|
tabindex="0" @click="openLightbox($el)" @focus="select($el)">
|
||||||
|
<img src="/thumb/{{ a.immich_id }}" class="w-full aspect-square object-cover" loading="lazy" alt="">
|
||||||
|
{% if m.flagged_coverage %}
|
||||||
|
<button class="absolute bottom-1 left-1 badge badge-xs badge-info"
|
||||||
|
@click.stop="setMember('{{ a.immich_id }}', true)">+ include</button>
|
||||||
|
{% elif m.is_outlier %}
|
||||||
|
<button class="absolute bottom-1 left-1 badge badge-xs badge-warning"
|
||||||
|
@click.stop="setMember('{{ a.immich_id }}', false)">– exclude</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if not m.included %}<div class="absolute inset-0 bg-black/50 pointer-events-none"></div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{{ lightbox() }}
|
||||||
|
</div>
|
||||||
@@ -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 %}
|
||||||
|
<div class="hero py-20"><div class="hero-content text-center"><div>
|
||||||
|
<h1 class="text-2xl font-bold">No clusters yet</h1>
|
||||||
|
{% if stats.assets == 0 %}
|
||||||
|
<p class="opacity-70 mt-2">Nothing ingested. Run
|
||||||
|
<code>categorize ingest</code> then <code>categorize cluster</code>.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="opacity-70 mt-2">{{ stats.assets }} assets ingested, but this scope
|
||||||
|
produced no clusters. Try a wider <code>categorize ingest</code> scope.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div></div></div>
|
||||||
|
{% else %}
|
||||||
|
<div x-data="clusterReview()" @keydown.window="onKey($event)" class="flex gap-4">
|
||||||
|
<div class="w-72 shrink-0 max-h-[85vh] overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="font-bold">Clusters</span>
|
||||||
|
<button class="btn btn-xs btn-primary" @click="approveHighConfidence()">Approve high-conf</button>
|
||||||
|
</div>
|
||||||
|
<!-- F8: explain the attention sort -->
|
||||||
|
<p class="text-xs opacity-60 mb-2">Needs attention first — lowest-confidence pending.</p>
|
||||||
|
{% if stats.pending == 0 %}
|
||||||
|
<div class="alert alert-success text-xs mb-2">All reviewed — “Apply all” or <code>categorize apply</code>.</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- F4: write-failed clusters surfaced here after Apply all -->
|
||||||
|
<div x-show="failures.length" class="alert alert-error text-xs mb-2 flex-col items-start">
|
||||||
|
<span class="font-semibold">Write failures:</span>
|
||||||
|
<ul class="list-disc ml-4">
|
||||||
|
<template x-for="f in failures" :key="f.id">
|
||||||
|
<li><span x-text="f.name"></span> — <span x-text="f.failed"></span> failed</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% for c in clusters %}
|
||||||
|
<div class="cluster-row block p-2 rounded cursor-pointer hover:bg-base-300"
|
||||||
|
:class="selected == {{ c.id }} && 'bg-base-300'"
|
||||||
|
data-cluster-id="{{ c.id }}" data-cluster-name="{{ c.decided_name or c.suggested_name }}"
|
||||||
|
@click="selectCluster({{ c.id }})">
|
||||||
|
<div class="flex items-center justify-between gap-1">
|
||||||
|
<span class="truncate text-sm font-medium">{{ c.decided_name or c.suggested_name }}</span>
|
||||||
|
{{ confidence_badge(c.confidence) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 mt-1">
|
||||||
|
<span class="cluster-status" data-cluster-id="{{ c.id }}">{{ status_badge(c.status) }}</span>
|
||||||
|
<span class="badge badge-xs">{{ c.count }}</span>
|
||||||
|
<span class="text-xs opacity-50">{{ c.start_at[:10] }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<button class="btn btn-xs btn-block mt-3" @click="applyAll()">Apply all approved</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<!-- F16: loading indicator for declarative + programmatic swaps -->
|
||||||
|
<div id="detail-indicator" class="htmx-indicator absolute top-2 right-2 z-10">
|
||||||
|
<span class="loading loading-spinner loading-md"></span>
|
||||||
|
</div>
|
||||||
|
<div id="detail" class="flex-1"
|
||||||
|
hx-get="/cluster/{{ clusters[0].id }}" hx-trigger="load" hx-target="#detail"
|
||||||
|
hx-indicator="#detail-indicator"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fixed bottom-2 right-2 text-xs opacity-50">
|
||||||
|
[ ] cluster · ← → grid · Enter open · A approve · N non-trip · S split (focus a photo) · X skip · Esc close
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block extra_scripts %}<script src="/static/app.js"></script>{% endblock %}
|
||||||
@@ -1,6 +1,25 @@
|
|||||||
import os
|
import os
|
||||||
from app import create_app
|
from app import create_app
|
||||||
from app.config import Config
|
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):
|
def _app(tmp_path):
|
||||||
@@ -24,3 +43,58 @@ def test_thumb_served(tmp_path):
|
|||||||
r = client.get("/thumb/a")
|
r = client.get("/thumb/a")
|
||||||
assert r.status_code == 200 and r.mimetype == "image/jpeg"
|
assert r.status_code == 200 and r.mimetype == "image/jpeg"
|
||||||
assert _app(tmp_path).test_client().get("/thumb/missing").status_code == 404
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user