- log_thread.py: thread-safe ContextVar bridge so executor threads can log
individual LLM calls and archive searches back to the event loop
- ai_log.py: init_thread_logging(), notify_entity_update(); WS now pushes
entity_update messages when book data changes after any plugin or batch run
- batch.py: replace batch_pending.json with batch_queue SQLite table;
run_batch_consumer() reads queue dynamically so new books can be added
while batch is running; add_to_queue() deduplicates
- migrate.py: fix _migrate_v1 (clear-on-startup bug); add _migrate_v2 for
batch_queue table
- _client.py / archive.py / identification.py: wrap each LLM API call and
archive search with log_thread start/finish entries
- api.py: POST /api/batch returns {already_running, added}; notify_entity_update
after identify pipeline
- models.default.yaml: strengthen ai_identify confidence-scoring instructions;
warn against placeholder data
- detail-render.js: book log entries show clickable ID + spine thumbnail;
book spine/title images open full-screen popup
- events.js: batch-start handles already_running+added; open-img-popup action
- init.js: entity_update WS handler; image popup close listeners
- overlays.css / index.html: full-screen image popup overlay
- eslint.config.js: add new globals; fix no-redeclare/no-unused-vars for
multi-file global architecture; all lint errors resolved
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
163 lines
6.1 KiB
JavaScript
163 lines
6.1 KiB
JavaScript
/*
|
|
* photo.js
|
|
* Photo upload for all entity types and the mobile Photo Queue feature.
|
|
*
|
|
* Photo upload:
|
|
* triggerPhoto(type, id) — opens the hidden file input, sets _photoTarget.
|
|
* The 'change' handler uploads via multipart POST, updates the tree node,
|
|
* and on mobile automatically runs the full AI pipeline for books
|
|
* (POST /api/books/{id}/process).
|
|
*
|
|
* Photo Queue (mobile-only UI):
|
|
* collectQueueBooks(node, type) — collects all non-approved books in tree
|
|
* order (top-to-bottom within each shelf, left-to-right across shelves).
|
|
* renderPhotoQueue() — updates the #photo-queue-overlay DOM in-place.
|
|
* Queue flow: show spine → tap camera → upload + process → auto-advance.
|
|
* Queue is stored in _photoQueue (state.js) so events.js can control it.
|
|
*
|
|
* Depends on: S, _photoQueue (state.js); req, toast (api.js / helpers.js);
|
|
* walkTree, findNode, esc (tree-render.js / helpers.js);
|
|
* isDesktop, render (helpers.js / init.js)
|
|
* Provides: collectQueueBooks(), renderPhotoQueue(), triggerPhoto()
|
|
*/
|
|
|
|
/* exported collectQueueBooks, renderPhotoQueue, triggerPhoto */
|
|
|
|
// ── Photo Queue ──────────────────────────────────────────────────────────────
|
|
function collectQueueBooks(node, type) {
|
|
const books = [];
|
|
function collect(n, t) {
|
|
if (t === 'book') {
|
|
if (n.identification_status !== 'user_approved') books.push(n);
|
|
return;
|
|
}
|
|
if (t === 'room') n.cabinets.forEach((c) => collect(c, 'cabinet'));
|
|
if (t === 'cabinet') n.shelves.forEach((s) => collect(s, 'shelf'));
|
|
if (t === 'shelf') n.books.forEach((b) => collect(b, 'book'));
|
|
}
|
|
collect(node, type);
|
|
return books;
|
|
}
|
|
|
|
function renderPhotoQueue() {
|
|
const el = document.getElementById('photo-queue-overlay');
|
|
if (!el) return;
|
|
if (!_photoQueue) {
|
|
el.style.display = 'none';
|
|
el.innerHTML = '';
|
|
return;
|
|
}
|
|
const { books, index, processing } = _photoQueue;
|
|
el.style.display = 'flex';
|
|
if (index >= books.length) {
|
|
el.innerHTML = `<div class="pq-hdr">
|
|
<button class="hbtn" data-a="photo-queue-close">✕</button>
|
|
<span class="pq-hdr-title">Photo Queue</span>
|
|
<span style="min-width:34px"></span>
|
|
</div>
|
|
<div class="pq-spine-wrap" style="text-align:center">
|
|
<div style="font-size:3rem">✓</div>
|
|
<div style="font-size:1.1rem;color:#86efac;font-weight:600">All done!</div>
|
|
<div style="font-size:.82rem;color:#94a3b8;margin-top:4px">All ${books.length} book${books.length !== 1 ? 's' : ''} photographed</div>
|
|
<button class="btn btn-p" style="margin-top:20px" data-a="photo-queue-close">Close</button>
|
|
</div>`;
|
|
return;
|
|
}
|
|
const book = books[index];
|
|
el.innerHTML = `<div class="pq-hdr">
|
|
<button class="hbtn" data-a="photo-queue-close">✕</button>
|
|
<span class="pq-hdr-title">${index + 1} / ${books.length}</span>
|
|
<span style="min-width:34px"></span>
|
|
</div>
|
|
<div class="pq-spine-wrap">
|
|
<img class="pq-spine-img" src="/api/books/${book.id}/spine?t=${Date.now()}" alt="Spine"
|
|
onerror="this.style.display='none'">
|
|
<div class="pq-book-name">${esc(book.title || '—')}</div>
|
|
</div>
|
|
<div class="pq-actions">
|
|
<button class="pq-skip-btn" data-a="photo-queue-skip">Skip</button>
|
|
<button class="pq-camera-btn" data-a="photo-queue-take">📷</button>
|
|
</div>
|
|
${processing ? '<div class="pq-processing"><div class="spinner"></div><span>Processing…</span></div>' : ''}`;
|
|
}
|
|
|
|
// ── Photo upload ─────────────────────────────────────────────────────────────
|
|
const gphoto = document.getElementById('gphoto');
|
|
|
|
function triggerPhoto(type, id) {
|
|
S._photoTarget = { type, id };
|
|
if (/Android|iPhone|iPad/i.test(navigator.userAgent)) gphoto.setAttribute('capture', 'environment');
|
|
else gphoto.removeAttribute('capture');
|
|
gphoto.value = '';
|
|
gphoto.click();
|
|
}
|
|
|
|
gphoto.addEventListener('change', async () => {
|
|
const file = gphoto.files[0];
|
|
if (!file || !S._photoTarget) return;
|
|
const { type, id } = S._photoTarget;
|
|
S._photoTarget = null;
|
|
const fd = new FormData();
|
|
fd.append('image', file, file.name); // HD — no client-side compression
|
|
const urls = {
|
|
room: `/api/rooms/${id}/photo`,
|
|
cabinet: `/api/cabinets/${id}/photo`,
|
|
shelf: `/api/shelves/${id}/photo`,
|
|
book: `/api/books/${id}/photo`,
|
|
};
|
|
try {
|
|
const res = await req('POST', urls[type], fd, true);
|
|
const key = type === 'book' ? 'image_filename' : 'photo_filename';
|
|
walkTree((n) => {
|
|
if (n.id === id) n[key] = res[key];
|
|
});
|
|
// Photo queue mode: process and advance without full re-render
|
|
if (_photoQueue && type === 'book') {
|
|
_photoQueue.processing = true;
|
|
renderPhotoQueue();
|
|
const book = findNode(id);
|
|
if (book && book.identification_status !== 'user_approved') {
|
|
try {
|
|
const br = await req('POST', `/api/books/${id}/process`);
|
|
walkTree((n) => {
|
|
if (n.id === id) Object.assign(n, br);
|
|
});
|
|
} catch {
|
|
/* continue queue on process error */
|
|
}
|
|
}
|
|
_photoQueue.processing = false;
|
|
_photoQueue.index++;
|
|
renderPhotoQueue();
|
|
return;
|
|
}
|
|
render();
|
|
// Mobile: auto-queue AI after photo upload (books only)
|
|
if (!isDesktop()) {
|
|
if (type === 'book') {
|
|
const book = findNode(id);
|
|
if (book && book.identification_status !== 'user_approved') {
|
|
try {
|
|
const br = await req('POST', `/api/books/${id}/process`);
|
|
walkTree((n) => {
|
|
if (n.id === id) Object.assign(n, br);
|
|
});
|
|
toast(`Photo saved · Identified (${br.identification_status})`);
|
|
render();
|
|
} catch {
|
|
toast('Photo saved');
|
|
}
|
|
} else {
|
|
toast('Photo saved');
|
|
}
|
|
} else {
|
|
toast('Photo saved');
|
|
}
|
|
} else {
|
|
toast('Photo saved');
|
|
}
|
|
} catch (err) {
|
|
toast('Upload failed: ' + err.message);
|
|
}
|
|
});
|