- ${candidateSugRows(b, 'publisher', 'd-pub')}
-
+
diff --git a/static/js/editing.js b/static/js/editing.js
index 43b53dc..1d46cbd 100644
--- a/static/js/editing.js
+++ b/static/js/editing.js
@@ -12,54 +12,84 @@
* Provides: attachEditables(), initSortables()
*/
+/* exported attachEditables, initSortables */
+
// ── SortableJS instances (destroyed and recreated on each render) ─────────────
let _sortables = [];
// ── Inline name editing ──────────────────────────────────────────────────────
function attachEditables() {
- document.querySelectorAll('[contenteditable=true]').forEach(el => {
+ document.querySelectorAll('[contenteditable=true]').forEach((el) => {
el.dataset.orig = el.textContent.trim();
- el.addEventListener('keydown', e => {
- if (e.key==='Enter') { e.preventDefault(); el.blur(); }
- if (e.key==='Escape') { el.textContent=el.dataset.orig; el.blur(); }
+ el.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ el.blur();
+ }
+ if (e.key === 'Escape') {
+ el.textContent = el.dataset.orig;
+ el.blur();
+ }
e.stopPropagation();
});
el.addEventListener('blur', async () => {
const val = el.textContent.trim();
- if (!val||val===el.dataset.orig) { if(!val) el.textContent=el.dataset.orig; return; }
- const newName = val.replace(/^[🏠📚]\s*/u,'').trim();
- const {type, id} = el.dataset;
- const url = {room:`/api/rooms/${id}`,cabinet:`/api/cabinets/${id}`,shelf:`/api/shelves/${id}`}[type];
+ if (!val || val === el.dataset.orig) {
+ if (!val) el.textContent = el.dataset.orig;
+ return;
+ }
+ const newName = val.replace(/^[🏠📚]\s*/u, '').trim();
+ const { type, id } = el.dataset;
+ const url = { room: `/api/rooms/${id}`, cabinet: `/api/cabinets/${id}`, shelf: `/api/shelves/${id}` }[type];
if (!url) return;
try {
- await req('PUT', url, {name: newName});
+ await req('PUT', url, { name: newName });
el.dataset.orig = el.textContent.trim();
- walkTree(n=>{ if(n.id===id) n.name=newName; });
+ walkTree((n) => {
+ if (n.id === id) n.name = newName;
+ });
// Update sidebar label if editing from header (sidebar has non-editable nname spans)
const sideLabel = document.querySelector(`.node[data-id="${id}"] .nname`);
if (sideLabel && sideLabel !== el) {
- const prefix = type==='room' ? '🏠 ' : type==='cabinet' ? '📚 ' : '';
+ const prefix = type === 'room' ? '🏠 ' : type === 'cabinet' ? '📚 ' : '';
sideLabel.textContent = prefix + newName;
}
- } catch(err) { el.textContent=el.dataset.orig; toast('Rename failed: '+err.message); }
+ } catch (err) {
+ el.textContent = el.dataset.orig;
+ toast('Rename failed: ' + err.message);
+ }
});
- el.addEventListener('click', e=>e.stopPropagation());
+ el.addEventListener('click', (e) => e.stopPropagation());
});
}
// ── SortableJS drag-and-drop ─────────────────────────────────────────────────
function initSortables() {
- _sortables.forEach(s=>{ try{s.destroy();}catch(_){} });
+ _sortables.forEach((s) => {
+ try {
+ s.destroy();
+ } catch (_) {
+ // ignore destroy errors on stale instances
+ }
+ });
_sortables = [];
- document.querySelectorAll('.sortable-list').forEach(el => {
+ document.querySelectorAll('.sortable-list').forEach((el) => {
const type = el.dataset.type;
- _sortables.push(Sortable.create(el, {
- handle:'.drag-h', animation:120, ghostClass:'drag-ghost',
- onEnd: async () => {
- const ids = [...el.querySelectorAll(':scope > .node')].map(n=>n.dataset.id);
- try { await req('PATCH',`/api/${type}/reorder`,{ids}); }
- catch(err) { toast('Reorder failed'); await loadTree(); }
- },
- }));
+ _sortables.push(
+ Sortable.create(el, {
+ handle: '.drag-h',
+ animation: 120,
+ ghostClass: 'drag-ghost',
+ onEnd: async () => {
+ const ids = [...el.querySelectorAll(':scope > .node')].map((n) => n.dataset.id);
+ try {
+ await req('PATCH', `/api/${type}/reorder`, { ids });
+ } catch (_err) {
+ toast('Reorder failed');
+ await loadTree();
+ }
+ },
+ }),
+ );
});
}
diff --git a/static/js/events.js b/static/js/events.js
index 811d96e..fbcff58 100644
--- a/static/js/events.js
+++ b/static/js/events.js
@@ -12,7 +12,7 @@
* Depends on: S, _bnd, _batchState, _photoQueue (state.js);
* req (api.js); toast, isDesktop (helpers.js);
* walkTree, removeNode, findNode, parseBounds (tree-render.js /
- * canvas-boundary.js); render, renderDetail, startBatchPolling
+ * canvas-boundary.js); render, renderDetail, connectBatchWs
* (init.js); startCropMode (canvas-crop.js);
* triggerPhoto, collectQueueBooks, renderPhotoQueue (photo.js);
* drawBnd (canvas-boundary.js)
@@ -22,53 +22,61 @@
// ── Accordion helpers ────────────────────────────────────────────────────────
function getSiblingIds(id, type) {
if (!S.tree) return [];
- if (type === 'room') return S.tree.filter(r => r.id !== id).map(r => r.id);
+ if (type === 'room') return S.tree.filter((r) => r.id !== id).map((r) => r.id);
for (const r of S.tree) {
- if (type === 'cabinet' && r.cabinets.some(c => c.id === id))
- return r.cabinets.filter(c => c.id !== id).map(c => c.id);
+ if (type === 'cabinet' && r.cabinets.some((c) => c.id === id))
+ return r.cabinets.filter((c) => c.id !== id).map((c) => c.id);
for (const c of r.cabinets) {
- if (type === 'shelf' && c.shelves.some(s => s.id === id))
- return c.shelves.filter(s => s.id !== id).map(s => s.id);
+ if (type === 'shelf' && c.shelves.some((s) => s.id === id))
+ return c.shelves.filter((s) => s.id !== id).map((s) => s.id);
}
}
return [];
}
function accordionExpand(id, type) {
- if (!isDesktop()) getSiblingIds(id, type).forEach(sid => S.expanded.delete(sid));
+ if (!isDesktop()) getSiblingIds(id, type).forEach((sid) => S.expanded.delete(sid));
S.expanded.add(id);
}
// ── Event delegation ─────────────────────────────────────────────────────────
-document.getElementById('app').addEventListener('click', async e => {
+document.getElementById('app').addEventListener('click', async (e) => {
const el = e.target.closest('[data-a]');
if (!el) return;
const d = el.dataset;
- try { await handle(d.a, d, e); }
- catch(err) { toast('Error: '+err.message); }
+ try {
+ await handle(d.a, d, e);
+ } catch (err) {
+ toast('Error: ' + err.message);
+ }
});
-document.getElementById('app').addEventListener('change', async e => {
+document.getElementById('app').addEventListener('change', async (e) => {
const el = e.target.closest('[data-a]');
if (!el) return;
const d = el.dataset;
- try { await handle(d.a, d, e); }
- catch(err) { toast('Error: '+err.message); }
+ try {
+ await handle(d.a, d, e);
+ } catch (err) {
+ toast('Error: ' + err.message);
+ }
});
// Photo queue overlay is outside #app so needs its own listener
-document.getElementById('photo-queue-overlay').addEventListener('click', async e => {
+document.getElementById('photo-queue-overlay').addEventListener('click', async (e) => {
const el = e.target.closest('[data-a]');
if (!el) return;
const d = el.dataset;
- try { await handle(d.a, d, e); }
- catch(err) { toast('Error: ' + err.message); }
+ try {
+ await handle(d.a, d, e);
+ } catch (err) {
+ toast('Error: ' + err.message);
+ }
});
// ── Action dispatcher ────────────────────────────────────────────────────────
async function handle(action, d, e) {
switch (action) {
-
case 'select': {
// Ignore if the click hit a button or editable inside the row
if (e?.target?.closest('button,[contenteditable]')) return;
@@ -80,14 +88,16 @@ async function handle(action, d, e) {
}
break;
}
- S.selected = {type: d.type, id: d.id};
+ S.selected = { type: d.type, id: d.id };
S._loading = {};
- render(); break;
+ render();
+ break;
}
case 'deselect': {
S.selected = null;
- render(); break;
+ render();
+ break;
}
case 'toggle': {
@@ -95,168 +105,329 @@ async function handle(action, d, e) {
// Mobile: expand-only (no collapse to avoid accidental mistaps)
accordionExpand(d.id, d.type);
} else {
- if (S.expanded.has(d.id)) { S.expanded.delete(d.id); }
- else { S.expanded.add(d.id); }
+ if (S.expanded.has(d.id)) {
+ S.expanded.delete(d.id);
+ } else {
+ S.expanded.add(d.id);
+ }
}
- render(); break;
+ render();
+ break;
}
// Rooms
case 'add-room': {
- const r = await req('POST','/api/rooms');
- if (!S.tree) S.tree=[];
- S.tree.push({...r, cabinets:[]});
- S.expanded.add(r.id); render(); break;
+ const r = await req('POST', '/api/rooms');
+ if (!S.tree) S.tree = [];
+ S.tree.push({ ...r, cabinets: [] });
+ S.expanded.add(r.id);
+ render();
+ break;
}
case 'del-room': {
if (!confirm('Delete room and all contents?')) break;
- await req('DELETE',`/api/rooms/${d.id}`);
- removeNode('room',d.id);
- if (S.selected?.id===d.id) S.selected=null;
- render(); break;
+ await req('DELETE', `/api/rooms/${d.id}`);
+ removeNode('room', d.id);
+ if (S.selected?.id === d.id) S.selected = null;
+ render();
+ break;
}
// Cabinets
case 'add-cabinet': {
- const c = await req('POST',`/api/rooms/${d.id}/cabinets`);
- S.tree.forEach(r=>{ if(r.id===d.id) r.cabinets.push({...c,shelves:[]}); });
- S.expanded.add(d.id); render(); break; // expand parent room
+ const c = await req('POST', `/api/rooms/${d.id}/cabinets`);
+ S.tree.forEach((r) => {
+ if (r.id === d.id) r.cabinets.push({ ...c, shelves: [] });
+ });
+ S.expanded.add(d.id);
+ render();
+ break; // expand parent room
}
case 'del-cabinet': {
if (!confirm('Delete cabinet and all contents?')) break;
- await req('DELETE',`/api/cabinets/${d.id}`);
- removeNode('cabinet',d.id);
- if (S.selected?.id===d.id) S.selected=null;
- render(); break;
+ await req('DELETE', `/api/cabinets/${d.id}`);
+ removeNode('cabinet', d.id);
+ if (S.selected?.id === d.id) S.selected = null;
+ render();
+ break;
}
// Shelves
case 'add-shelf': {
const cab = findNode(d.id);
const prevCount = cab ? cab.shelves.length : 0;
- const s = await req('POST',`/api/cabinets/${d.id}/shelves`);
- S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===d.id) c.shelves.push({...s,books:[]}); }));
+ const s = await req('POST', `/api/cabinets/${d.id}/shelves`);
+ S.tree.forEach((r) =>
+ r.cabinets.forEach((c) => {
+ if (c.id === d.id) c.shelves.push({ ...s, books: [] });
+ }),
+ );
if (prevCount > 0) {
// Split last segment in half to make room for new shelf
const bounds = parseBounds(cab.shelf_boundaries);
- const lastStart = bounds.length ? bounds[bounds.length-1] : 0.0;
- const newBound = Math.round((lastStart + 1.0) / 2 * 10000) / 10000;
+ const lastStart = bounds.length ? bounds[bounds.length - 1] : 0.0;
+ const newBound = Math.round(((lastStart + 1.0) / 2) * 10000) / 10000;
const newBounds = [...bounds, newBound];
- await req('PATCH', `/api/cabinets/${d.id}/boundaries`, {boundaries: newBounds});
- S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===d.id) c.shelf_boundaries=JSON.stringify(newBounds); }));
+ await req('PATCH', `/api/cabinets/${d.id}/boundaries`, { boundaries: newBounds });
+ S.tree.forEach((r) =>
+ r.cabinets.forEach((c) => {
+ if (c.id === d.id) c.shelf_boundaries = JSON.stringify(newBounds);
+ }),
+ );
}
- S.expanded.add(d.id); render(); break; // expand parent cabinet
+ S.expanded.add(d.id);
+ render();
+ break; // expand parent cabinet
}
case 'del-shelf': {
if (!confirm('Delete shelf and all books?')) break;
- await req('DELETE',`/api/shelves/${d.id}`);
- removeNode('shelf',d.id);
- if (S.selected?.id===d.id) S.selected=null;
- render(); break;
+ await req('DELETE', `/api/shelves/${d.id}`);
+ removeNode('shelf', d.id);
+ if (S.selected?.id === d.id) S.selected = null;
+ render();
+ break;
}
// Books
case 'add-book': {
const shelf = findNode(d.id);
const prevCount = shelf ? shelf.books.length : 0;
- const b = await req('POST',`/api/shelves/${d.id}/books`);
- S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===d.id) s.books.push(b); })));
+ const b = await req('POST', `/api/shelves/${d.id}/books`);
+ S.tree.forEach((r) =>
+ r.cabinets.forEach((c) =>
+ c.shelves.forEach((s) => {
+ if (s.id === d.id) s.books.push(b);
+ }),
+ ),
+ );
if (prevCount > 0) {
// Split last segment in half to make room for new book
const bounds = parseBounds(shelf.book_boundaries);
- const lastStart = bounds.length ? bounds[bounds.length-1] : 0.0;
- const newBound = Math.round((lastStart + 1.0) / 2 * 10000) / 10000;
+ const lastStart = bounds.length ? bounds[bounds.length - 1] : 0.0;
+ const newBound = Math.round(((lastStart + 1.0) / 2) * 10000) / 10000;
const newBounds = [...bounds, newBound];
- await req('PATCH', `/api/shelves/${d.id}/boundaries`, {boundaries: newBounds});
- S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===d.id) s.book_boundaries=JSON.stringify(newBounds); })));
+ await req('PATCH', `/api/shelves/${d.id}/boundaries`, { boundaries: newBounds });
+ S.tree.forEach((r) =>
+ r.cabinets.forEach((c) =>
+ c.shelves.forEach((s) => {
+ if (s.id === d.id) s.book_boundaries = JSON.stringify(newBounds);
+ }),
+ ),
+ );
}
- S.expanded.add(d.id); render(); break; // expand parent shelf
+ S.expanded.add(d.id);
+ render();
+ break; // expand parent shelf
}
case 'del-book': {
if (!confirm('Delete this book?')) break;
- await req('DELETE',`/api/books/${d.id}`);
- removeNode('book',d.id);
- if (S.selected?.id===d.id) S.selected=null;
- render(); break;
+ await req('DELETE', `/api/books/${d.id}`);
+ removeNode('book', d.id);
+ if (S.selected?.id === d.id) S.selected = null;
+ render();
+ break;
}
case 'del-book-confirm': {
if (!confirm('Delete this book?')) break;
- await req('DELETE',`/api/books/${d.id}`);
- removeNode('book',d.id);
- S.selected=null; render(); break;
+ await req('DELETE', `/api/books/${d.id}`);
+ removeNode('book', d.id);
+ S.selected = null;
+ render();
+ break;
}
case 'save-book': {
const data = {
- title: document.getElementById('d-title')?.value || '',
- author: document.getElementById('d-author')?.value || '',
- year: document.getElementById('d-year')?.value || '',
- isbn: document.getElementById('d-isbn')?.value || '',
- publisher: document.getElementById('d-pub')?.value || '',
- notes: document.getElementById('d-notes')?.value || '',
+ title: document.getElementById('d-title')?.value || '',
+ author: document.getElementById('d-author')?.value || '',
+ year: document.getElementById('d-year')?.value || '',
+ isbn: document.getElementById('d-isbn')?.value || '',
+ publisher: document.getElementById('d-pub')?.value || '',
+ notes: document.getElementById('d-notes')?.value || '',
};
- const res = await req('PUT',`/api/books/${d.id}`,data);
- walkTree(n => {
+ const res = await req('PUT', `/api/books/${d.id}`, data);
+ walkTree((n) => {
if (n.id === d.id) {
Object.assign(n, data);
- n.ai_title = data.title; n.ai_author = data.author; n.ai_year = data.year;
- n.ai_isbn = data.isbn; n.ai_publisher = data.publisher;
+ n.ai_title = data.title;
+ n.ai_author = data.author;
+ n.ai_year = data.year;
+ n.ai_isbn = data.isbn;
+ n.ai_publisher = data.publisher;
n.identification_status = res.identification_status ?? n.identification_status;
}
});
- toast('Saved'); render(); break;
+ toast('Saved');
+ render();
+ break;
}
case 'run-plugin': {
const key = `${d.plugin}:${d.id}`;
- S._loading[key] = true; renderDetail();
+ // Capture any unsaved field edits before the first renderDetail() overwrites them.
+ if (d.etype === 'books') {
+ walkTree((n) => {
+ if (n.id === d.id) {
+ n.title = document.getElementById('d-title')?.value ?? n.title;
+ n.author = document.getElementById('d-author')?.value ?? n.author;
+ n.year = document.getElementById('d-year')?.value ?? n.year;
+ n.isbn = document.getElementById('d-isbn')?.value ?? n.isbn;
+ n.publisher = document.getElementById('d-pub')?.value ?? n.publisher;
+ n.notes = document.getElementById('d-notes')?.value ?? n.notes;
+ }
+ });
+ }
+ S._loading[key] = true;
+ renderDetail();
try {
const res = await req('POST', `/api/${d.etype}/${d.id}/plugin/${d.plugin}`);
- walkTree(n => { if (n.id === d.id) Object.assign(n, res); });
- } catch(err) { toast(`${d.plugin} failed: ${err.message}`); }
- delete S._loading[key]; renderDetail();
+ walkTree((n) => {
+ if (n.id !== d.id) return;
+ if (d.etype === 'books') {
+ // Server response must not overwrite user edits captured above.
+ const saved = {
+ title: n.title,
+ author: n.author,
+ year: n.year,
+ isbn: n.isbn,
+ publisher: n.publisher,
+ notes: n.notes,
+ };
+ Object.assign(n, res);
+ Object.assign(n, saved);
+ } else {
+ Object.assign(n, res);
+ }
+ });
+ } catch (err) {
+ toast(`${d.plugin} failed: ${err.message}`);
+ }
+ delete S._loading[key];
+ renderDetail();
break;
}
case 'select-bnd-plugin': {
- if (_bnd) { _bnd.selectedPlugin = e.target.value || null; drawBnd(); }
+ if (_bnd) {
+ _bnd.selectedPlugin = e.target.value || null;
+ drawBnd();
+ }
break;
}
case 'accept-field': {
const inp = document.getElementById(d.input);
if (inp) inp.value = d.value;
- walkTree(n => { if (n.id === d.id) n[d.field] = d.value; });
- renderDetail(); break;
+ walkTree((n) => {
+ if (n.id === d.id) n[d.field] = d.value;
+ });
+ renderDetail();
+ break;
}
case 'dismiss-field': {
- const res = await req('POST', `/api/books/${d.id}/dismiss-field`, {field: d.field, value: d.value || ''});
- walkTree(n => {
+ const res = await req('POST', `/api/books/${d.id}/dismiss-field`, { field: d.field, value: d.value || '' });
+ walkTree((n) => {
if (n.id === d.id) {
n.candidates = JSON.stringify(res.candidates || []);
if (!d.value) n[`ai_${d.field}`] = n[d.field] || '';
n.identification_status = res.identification_status ?? n.identification_status;
}
});
- renderDetail(); break;
+ renderDetail();
+ break;
+ }
+ case 'identify-book': {
+ const key = `identify:${d.id}`;
+ S._loading[key] = true;
+ renderDetail();
+ try {
+ const res = await req('POST', `/api/books/${d.id}/identify`);
+ walkTree((n) => {
+ if (n.id !== d.id) return;
+ const saved = {
+ title: n.title,
+ author: n.author,
+ year: n.year,
+ isbn: n.isbn,
+ publisher: n.publisher,
+ notes: n.notes,
+ };
+ Object.assign(n, res);
+ Object.assign(n, saved);
+ });
+ } catch (err) {
+ toast(`Identify failed: ${err.message}`);
+ }
+ delete S._loading[key];
+ renderDetail();
+ break;
+ }
+ case 'toggle-ai-blocks': {
+ walkTree((n) => {
+ if (n.id === d.id) _aiBlocksVisible[d.id] = !aiBlocksShown(n);
+ });
+ renderDetail();
+ break;
+ }
+ case 'apply-ai-block': {
+ let block;
+ try {
+ block = JSON.parse(d.block);
+ } catch {
+ break;
+ }
+ const fieldMap = { title: 'd-title', author: 'd-author', year: 'd-year', isbn: 'd-isbn', publisher: 'd-pub' };
+ for (const [field, inputId] of Object.entries(fieldMap)) {
+ const v = (block[field] || '').trim();
+ if (!v) continue;
+ const inp = document.getElementById(inputId);
+ if (inp) inp.value = v;
+ walkTree((n) => {
+ if (n.id === d.id) n[field] = v;
+ });
+ }
+ renderDetail();
+ break;
}
case 'batch-start': {
const res = await req('POST', '/api/batch');
- if (res.already_running) { toast('Batch already running'); break; }
- if (!res.started) { toast('No unidentified books'); break; }
- _batchState = {running: true, total: res.total, done: 0, errors: 0, current: ''};
- startBatchPolling(); renderDetail(); break;
+ if (res.already_running) {
+ toast(res.added > 0 ? `Added ${res.added} book(s) to batch` : 'Batch already running');
+ if (!_batchWs) connectBatchWs();
+ break;
+ }
+ if (!res.started) {
+ toast('No unidentified books');
+ break;
+ }
+ connectBatchWs();
+ renderDetail();
+ break;
+ }
+ case 'open-img-popup': {
+ const popup = document.getElementById('img-popup');
+ if (!popup) break;
+ document.getElementById('img-popup-img').src = d.src;
+ popup.classList.add('open');
+ break;
}
// Photo
- case 'photo': triggerPhoto(d.type, d.id); break;
+ case 'photo':
+ triggerPhoto(d.type, d.id);
+ break;
// Crop
- case 'crop-start': startCropMode(d.type, d.id); break;
+ case 'crop-start':
+ startCropMode(d.type, d.id);
+ break;
// Photo queue
case 'photo-queue-start': {
const node = findNode(d.id);
if (!node) break;
const books = collectQueueBooks(node, d.type);
- if (!books.length) { toast('No unidentified books'); break; }
- _photoQueue = {books, index: 0, processing: false};
+ if (!books.length) {
+ toast('No unidentified books');
+ break;
+ }
+ _photoQueue = { books, index: 0, processing: false };
renderPhotoQueue();
break;
}
@@ -278,6 +449,5 @@ async function handle(action, d, e) {
renderPhotoQueue();
break;
}
-
}
}
diff --git a/static/js/helpers.js b/static/js/helpers.js
index f816ba9..a09e7da 100644
--- a/static/js/helpers.js
+++ b/static/js/helpers.js
@@ -6,16 +6,25 @@
* Provides: esc(), toast(), isDesktop()
*/
+/* exported esc, toast, isDesktop */
+
// ── Helpers ─────────────────────────────────────────────────────────────────
function esc(s) {
- return String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
+ return String(s ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
}
function toast(msg, dur = 2800) {
const el = document.getElementById('toast');
- el.textContent = msg; el.classList.add('on');
+ el.textContent = msg;
+ el.classList.add('on');
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove('on'), dur);
}
-function isDesktop() { return window.innerWidth >= 768; }
+function isDesktop() {
+ return window.innerWidth >= 768;
+}
diff --git a/static/js/init.js b/static/js/init.js
index fa0f1a0..83dfbd1 100644
--- a/static/js/init.js
+++ b/static/js/init.js
@@ -10,16 +10,18 @@
* renderDetail() does a cheaper in-place update of the right panel only,
* used during plugin runs and field edits to avoid re-rendering the sidebar.
*
- * Depends on: S, _plugins, _batchState, _batchPollTimer (state.js);
+ * Depends on: S, _plugins, _batchState, _batchWs (state.js);
* req, toast (api.js / helpers.js); isDesktop (helpers.js);
* vApp, vDetailBody, mainTitle, mainHeaderBtns, vBatchBtn
* (tree-render.js / detail-render.js);
* attachEditables, initSortables (editing.js);
* setupDetailCanvas (canvas-boundary.js)
- * Provides: render(), renderDetail(), loadConfig(), startBatchPolling(),
+ * Provides: render(), renderDetail(), loadConfig(), connectBatchWs(),
* loadTree()
*/
+/* exported render, renderDetail, connectBatchWs, connectAiLogWs, loadTree */
+
// ── Full re-render ────────────────────────────────────────────────────────────
function render() {
if (document.activeElement?.contentEditable === 'true') return;
@@ -37,46 +39,121 @@ function renderDetail() {
const body = document.getElementById('main-body');
if (body) body.innerHTML = vDetailBody();
const t = document.getElementById('main-title');
- if (t) t.innerHTML = mainTitle(); // innerHTML: mainTitle() returns an HTML span
+ if (t) t.innerHTML = mainTitle(); // innerHTML: mainTitle() returns an HTML string
const hb = document.getElementById('main-hdr-btns');
if (hb) hb.innerHTML = mainHeaderBtns();
- const bb = document.getElementById('main-hdr-batch');
- if (bb) bb.innerHTML = vBatchBtn();
- attachEditables(); // pick up the new editable span in the header
+ attachEditables(); // pick up the new editable span in the header
requestAnimationFrame(setupDetailCanvas);
}
// ── Data loading ──────────────────────────────────────────────────────────────
async function loadConfig() {
try {
- const cfg = await req('GET','/api/config');
+ const cfg = await req('GET', '/api/config');
window._grabPx = cfg.boundary_grab_px ?? 14;
window._confidenceThreshold = cfg.confidence_threshold ?? 0.8;
+ window._aiLogMax = cfg.ai_log_max_entries ?? 100;
_plugins = cfg.plugins || [];
- } catch { window._grabPx = 14; window._confidenceThreshold = 0.8; }
+ } catch {
+ window._grabPx = 14;
+ window._confidenceThreshold = 0.8;
+ window._aiLogMax = 100;
+ }
}
-function startBatchPolling() {
- if (_batchPollTimer) clearInterval(_batchPollTimer);
- _batchPollTimer = setInterval(async () => {
- try {
- const st = await req('GET', '/api/batch/status');
- _batchState = st;
- const bb = document.getElementById('main-hdr-batch');
- if (bb) bb.innerHTML = vBatchBtn();
- if (!st.running) {
- clearInterval(_batchPollTimer); _batchPollTimer = null;
- toast(`Batch: ${st.done} done, ${st.errors} errors`);
- await loadTree();
+function connectBatchWs() {
+ if (_batchWs) {
+ _batchWs.close();
+ _batchWs = null;
+ }
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const ws = new WebSocket(`${proto}//${location.host}/ws/batch`);
+ _batchWs = ws;
+ ws.onmessage = async (ev) => {
+ const st = JSON.parse(ev.data);
+ _batchState = st;
+ const bb = document.getElementById('main-hdr-batch');
+ if (bb) bb.innerHTML = vBatchBtn();
+ if (!st.running) {
+ ws.close();
+ _batchWs = null;
+ toast(`Batch: ${st.done} done, ${st.errors} errors`);
+ await loadTree();
+ }
+ };
+ ws.onerror = () => {
+ _batchWs = null;
+ };
+ ws.onclose = () => {
+ _batchWs = null;
+ };
+}
+
+function connectAiLogWs() {
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const ws = new WebSocket(`${proto}//${location.host}/ws/ai-log`);
+ _aiLogWs = ws;
+ ws.onmessage = (ev) => {
+ const msg = JSON.parse(ev.data);
+ if (msg.type === 'snapshot') {
+ _aiLog = msg.entries || [];
+ } else if (msg.type === 'update') {
+ const entry = msg.entry;
+ const idx = _aiLog.findIndex((e) => e.id === entry.id);
+ if (idx >= 0) {
+ _aiLog[idx] = entry;
+ } else {
+ _aiLog.push(entry);
+ const max = window._aiLogMax ?? 100;
+ if (_aiLog.length > max) _aiLog.splice(0, _aiLog.length - max);
}
- } catch { /* ignore poll errors */ }
- }, 2000);
+ } else if (msg.type === 'entity_update') {
+ const etype = msg.entity_type.slice(0, -1); // "books" → "book"
+ walkTree((n) => {
+ if (n.id === msg.entity_id) Object.assign(n, msg.data);
+ });
+ if (S.selected && S.selected.type === etype && S.selected.id === msg.entity_id) {
+ renderDetail();
+ } else {
+ render(); // update sidebar badges
+ }
+ return; // skip AI indicator update — not a log entry
+ }
+ // Update header AI indicator
+ const hdr = document.getElementById('hdr-ai-indicator');
+ if (hdr) {
+ const running = _aiLog.filter((e) => e.status === 'running').length;
+ hdr.innerHTML = running > 0 ? vAiIndicator(running) : '';
+ }
+ // Update root detail panel if shown
+ if (!S.selected) renderDetail();
+ };
+ ws.onerror = () => {};
+ ws.onclose = () => {
+ // Reconnect after a short delay
+ setTimeout(connectAiLogWs, 3000);
+ };
}
async function loadTree() {
- S.tree = await req('GET','/api/tree');
+ S.tree = await req('GET', '/api/tree');
render();
}
// ── Init ──────────────────────────────────────────────────────────────────────
-Promise.all([loadConfig(), loadTree()]);
+
+// Image popup: close when clicking the overlay background or the × button.
+(function () {
+ const popup = document.getElementById('img-popup');
+ const closeBtn = document.getElementById('img-popup-close');
+ if (popup) {
+ popup.addEventListener('click', (e) => {
+ if (e.target === popup) popup.classList.remove('open');
+ });
+ }
+ if (closeBtn) {
+ closeBtn.addEventListener('click', () => popup && popup.classList.remove('open'));
+ }
+})();
+
+Promise.all([loadConfig(), loadTree()]).then(() => connectAiLogWs());
diff --git a/static/js/photo.js b/static/js/photo.js
index d03a930..c0c3ef7 100644
--- a/static/js/photo.js
+++ b/static/js/photo.js
@@ -21,6 +21,8 @@
* Provides: collectQueueBooks(), renderPhotoQueue(), triggerPhoto()
*/
+/* exported collectQueueBooks, renderPhotoQueue, triggerPhoto */
+
// ── Photo Queue ──────────────────────────────────────────────────────────────
function collectQueueBooks(node, type) {
const books = [];
@@ -29,9 +31,9 @@ function collectQueueBooks(node, type) {
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'));
+ 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;
@@ -40,8 +42,12 @@ function collectQueueBooks(node, type) {
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;
+ if (!_photoQueue) {
+ el.style.display = 'none';
+ el.innerHTML = '';
+ return;
+ }
+ const { books, index, processing } = _photoQueue;
el.style.display = 'flex';
if (index >= books.length) {
el.innerHTML = `
@@ -79,8 +85,8 @@ function renderPhotoQueue() {
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');
+ S._photoTarget = { type, id };
+ if (/Android|iPhone|iPad/i.test(navigator.userAgent)) gphoto.setAttribute('capture', 'environment');
else gphoto.removeAttribute('capture');
gphoto.value = '';
gphoto.click();
@@ -89,20 +95,22 @@ function triggerPhoto(type, id) {
gphoto.addEventListener('change', async () => {
const file = gphoto.files[0];
if (!file || !S._photoTarget) return;
- const {type, id} = S._photoTarget;
+ const { type, id } = S._photoTarget;
S._photoTarget = null;
const fd = new FormData();
- fd.append('image', file, file.name); // HD — no client-side compression
+ fd.append('image', file, file.name); // HD — no client-side compression
const urls = {
- room: `/api/rooms/${id}/photo`,
+ room: `/api/rooms/${id}/photo`,
cabinet: `/api/cabinets/${id}/photo`,
- shelf: `/api/shelves/${id}/photo`,
- book: `/api/books/${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]; });
+ 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;
@@ -111,8 +119,12 @@ gphoto.addEventListener('change', async () => {
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 */ }
+ walkTree((n) => {
+ if (n.id === id) Object.assign(n, br);
+ });
+ } catch {
+ /* continue queue on process error */
+ }
}
_photoQueue.processing = false;
_photoQueue.index++;
@@ -127,12 +139,24 @@ gphoto.addEventListener('change', async () => {
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); });
+ 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); }
+ } catch {
+ toast('Photo saved');
+ }
+ } else {
+ toast('Photo saved');
+ }
+ } else {
+ toast('Photo saved');
+ }
+ } else {
+ toast('Photo saved');
+ }
+ } catch (err) {
+ toast('Upload failed: ' + err.message);
+ }
});
diff --git a/static/js/state.js b/static/js/state.js
index 6ad1010..7dabc2f 100644
--- a/static/js/state.js
+++ b/static/js/state.js
@@ -7,35 +7,53 @@
* S — main UI state (tree data, selection, loading flags)
* _plugins — plugin manifest populated from GET /api/config
* _batchState — current batch-processing progress
- * _batchPollTimer — setInterval handle for batch polling
+ * _batchWs — active WebSocket for batch push notifications (null when idle)
* _bnd — live boundary-canvas state (written by canvas-boundary.js,
* read by detail-render.js)
* _photoQueue — photo queue session state (written by photo.js,
* read by events.js)
*/
+/* exported S */
+
// ── Main UI state ───────────────────────────────────────────────────────────
-let S = {
+const S = {
tree: null,
expanded: new Set(),
- selected: null, // {type:'cabinet'|'shelf'|'book', id}
- _photoTarget: null, // {type, id}
- _loading: {}, // {`${pluginId}:${entityId}`: true}
- _cropMode: null, // {type, id} while crop UI is active
+ selected: null, // {type:'cabinet'|'shelf'|'book', id}
+ _photoTarget: null, // {type, id}
+ _loading: {}, // {`${pluginId}:${entityId}`: true}
+ _cropMode: null, // {type, id} while crop UI is active
};
// ── Plugin registry ─────────────────────────────────────────────────────────
-let _plugins = []; // populated from GET /api/config
+// eslint-disable-next-line prefer-const
+let _plugins = []; // populated from GET /api/config
// ── Batch processing state ──────────────────────────────────────────────────
-let _batchState = {running: false, total: 0, done: 0, errors: 0, current: ''};
-let _batchPollTimer = null;
+const _batchState = { running: false, total: 0, done: 0, errors: 0, current: '' };
+// eslint-disable-next-line prefer-const
+let _batchWs = null;
// ── Boundary canvas live state ───────────────────────────────────────────────
// Owned by canvas-boundary.js; declared here so detail-render.js can read it
// without a circular load dependency.
+// eslint-disable-next-line prefer-const
let _bnd = null; // {wrap,img,canvas,axis,boundaries[],pluginResults{},selectedPlugin,segments[],nodeId,nodeType}
// ── Photo queue session state ────────────────────────────────────────────────
// Owned by photo.js; declared here so events.js can read/write it.
+// eslint-disable-next-line prefer-const
let _photoQueue = null; // {books:[...], index:0, processing:false}
+
+// ── AI blocks visibility ─────────────────────────────────────────────────────
+// Per-book override map. If bookId is absent the default rule applies:
+// show when not user_approved, hide when user_approved.
+const _aiBlocksVisible = {}; // {bookId: true|false}
+
+// ── AI request log ───────────────────────────────────────────────────────────
+// Populated from /ws/ai-log on page load.
+// eslint-disable-next-line prefer-const
+let _aiLog = []; // AiLogEntry[] — ring buffer, oldest first
+// eslint-disable-next-line prefer-const
+let _aiLogWs = null; // active WebSocket for AI log push (never closed)
diff --git a/static/js/tree-render.js b/static/js/tree-render.js
index c9ef64c..3b12b17 100644
--- a/static/js/tree-render.js
+++ b/static/js/tree-render.js
@@ -13,17 +13,27 @@
* vBook(), getBookStats(), vAiProgressBar()
*/
+/* exported pluginsByCategory, pluginsByTarget, isLoading, vPluginBtn, vBatchBtn, vAiIndicator,
+ candidateSugRows, vApp, mainTitle, mainHeaderBtns, _STATUS_BADGE,
+ getBookStats, vAiProgressBar, walkTree, removeNode, findNode */
+
// ── Plugin helpers ───────────────────────────────────────────────────────────
-function pluginsByCategory(cat) { return _plugins.filter(p => p.category === cat); }
-function pluginsByTarget(cat, target) { return _plugins.filter(p => p.category === cat && p.target === target); }
-function isLoading(pluginId, entityId) { return !!S._loading[`${pluginId}:${entityId}`]; }
+function pluginsByCategory(cat) {
+ return _plugins.filter((p) => p.category === cat);
+}
+function pluginsByTarget(cat, target) {
+ return _plugins.filter((p) => p.category === cat && p.target === target);
+}
+function isLoading(pluginId, entityId) {
+ return !!S._loading[`${pluginId}:${entityId}`];
+}
function vPluginBtn(plugin, entityId, entityType, extraDisabled = false) {
const loading = isLoading(plugin.id, entityId);
const label = loading ? '⏳' : esc(plugin.name);
return `
`;
}
@@ -34,21 +44,36 @@ function vBatchBtn() {
return `
`;
}
+// ── AI active indicator ───────────────────────────────────────────────────────
+function vAiIndicator(count) {
+ return `
${count}`;
+}
+
// ── Candidate suggestion rows ────────────────────────────────────────────────
const SOURCE_LABELS = {
- vlm: 'VLM', ai: 'AI', openlibrary: 'OpenLib',
- rsl: 'РГБ', rusneb: 'НЭБ', alib: 'Alib', nlr: 'НЛР', shpl: 'ШПИЛ',
+ vlm: 'VLM',
+ ai: 'AI',
+ openlibrary: 'OpenLib',
+ rsl: 'РГБ',
+ rusneb: 'НЭБ',
+ alib: 'Alib',
+ nlr: 'НЛР',
+ shpl: 'ШПИЛ',
};
function getSourceLabel(source) {
if (SOURCE_LABELS[source]) return SOURCE_LABELS[source];
- const p = _plugins.find(pl => pl.id === source);
+ const p = _plugins.find((pl) => pl.id === source);
return p ? p.name : source;
}
function parseCandidates(json) {
if (!json) return [];
- try { return JSON.parse(json) || []; } catch { return []; }
+ try {
+ return JSON.parse(json) || [];
+ } catch {
+ return [];
+ }
}
function candidateSugRows(b, field, inputId) {
@@ -61,7 +86,7 @@ function candidateSugRows(b, field, inputId) {
const v = (c[field] || '').trim();
if (!v) continue;
const key = v.toLowerCase();
- if (!byVal.has(key)) byVal.set(key, {display: v, sources: []});
+ if (!byVal.has(key)) byVal.set(key, { display: v, sources: [] });
const entry = byVal.get(key);
if (!entry.sources.includes(c.source)) entry.sources.push(c.source);
}
@@ -69,17 +94,17 @@ function candidateSugRows(b, field, inputId) {
const aiVal = (b[`ai_${field}`] || '').trim();
if (aiVal) {
const key = aiVal.toLowerCase();
- if (!byVal.has(key)) byVal.set(key, {display: aiVal, sources: []});
+ if (!byVal.has(key)) byVal.set(key, { display: aiVal, sources: [] });
const entry = byVal.get(key);
if (!entry.sources.includes('ai')) entry.sources.unshift('ai');
}
return [...byVal.entries()]
.filter(([k]) => k !== userVal.toLowerCase())
- .map(([, {display, sources}]) => {
- const badges = sources.map(s =>
- `
${esc(getSourceLabel(s))}`
- ).join(' ');
+ .map(([, { display, sources }]) => {
+ const badges = sources
+ .map((s) => `
${esc(getSourceLabel(s))}`)
+ .join(' ');
const val = esc(display);
return `
${badges} ${val}
@@ -90,34 +115,41 @@ function candidateSugRows(b, field, inputId) {
data-a="dismiss-field" data-id="${b.id}" data-field="${field}"
data-value="${val}" title="Dismiss">✗
`;
- }).join('');
+ })
+ .join('');
}
// ── App shell ────────────────────────────────────────────────────────────────
function vApp() {
- return `
-