Files
m038 ae093881fa fix(review): harden write-back, confirmation gate, and edge cases
- write-back: call upsert_tag inside _apply_tag's try so a tag-create failure
  is caught per-asset and apply_all no longer aborts mid-batch (was P1)
- write-back: surface _pipeline/processed write failures in the result instead
  of discarding them (was reported as success)
- ui: add title to the high confidence badge so approve-high-confidence's
  pre-action count is non-zero; confirm() before single-cluster apply
- core: Store context manager; close DB connection even if a route raises;
  guard split_cluster against a first-member boundary (empty cluster); add
  writeback_log lookup index
- ingest: split thumbnail download/write error handling and clean up the
  .tmp file on a write failure
- tests: upsert/processed write-failure regression tests + split-guard test
2026-06-27 17:54:38 +02:00

185 lines
7.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() {
// Write-back needs explicit confirmation (mirrors applyAll/merge).
if (!confirm('Apply this clusters decision to Immich?')) return;
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(); }
},
};
}