ui: tab Cataloga con streaming table + modal conferma move

This commit is contained in:
luciano committed 2026-08-27 10:04:57 +02:00
1 parent 9b711fd545
commit 401bed967a
3 files changed
+698

No files matched your search

+133
View File
@@ -687,6 +687,7 @@ input[type="number"]::-webkit-inner-spin-button {
.percent.green { color: var(--green-text); }
.percent.blue { color: var(--blue-text); }
.percent.pink { color: var(--pink-hover); }
.percent.purple { color: var(--purple-text); }
.progress {
width: 100%;
@@ -715,6 +716,10 @@ input[type="number"]::-webkit-inner-spin-button {
background: linear-gradient(90deg, var(--pink), var(--pink-hover));
}
.progress-fill.purple {
background: linear-gradient(90deg, var(--purple), var(--purple-hover));
}
/* ===============================
LOG BOX
=============================== */
@@ -1970,3 +1975,131 @@ input[type="number"]::-webkit-inner-spin-button {
padding-right: 6px; /* spazio per la scrollbar sul bordo */
}
/* ===============================
CATALOGA tab
=============================== */
.catalog-summary {
font-size: 14px;
color: var(--text-2);
font-weight: 600;
}
/* Scroll container per la tabella (analogo a dedup-groups-scroll) */
.catalog-scroll {
max-height: 55vh;
overflow-y: auto;
overflow-x: auto;
padding-right: 6px;
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--bg-card);
}
.catalog-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
color: var(--text-2);
}
.catalog-table thead th {
position: sticky;
top: 0;
z-index: 2;
background: var(--bg-card-2);
color: var(--text-1);
font-weight: 700;
padding: 10px 8px;
text-align: left;
font-size: 11px;
letter-spacing: 0.3px;
text-transform: uppercase;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.catalog-table tbody td {
padding: 8px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 260px;
}
.catalog-table tbody tr:hover {
background: var(--bg-elev);
}
/* Righe senza match AcoustID: sfondo rosa tenue */
.catalog-table tbody tr.catalog-row-unmatched {
background: rgba(236, 72, 153, 0.06);
}
.catalog-table tbody tr.catalog-row-unmatched:hover {
background: rgba(236, 72, 153, 0.12);
}
.catalog-col-check { width: 30px; text-align: center; }
.catalog-col-year { width: 60px; text-align: center; font-variant-numeric: tabular-nums; }
.catalog-col-status { width: 110px; }
.catalog-file-cell {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-1);
font-weight: 500;
}
.catalog-check {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--purple);
}
.catalog-status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.3px;
text-transform: uppercase;
}
.catalog-status-badge.ok {
background: var(--purple-dim);
color: var(--purple-text);
}
.catalog-status-badge.warn {
background: var(--amber-dim, rgba(245, 158, 11, 0.15));
color: var(--amber-text);
}
.catalog-status-badge.err {
background: rgba(220, 38, 38, 0.15);
color: var(--red);
}
.catalog-status-badge.busy {
background: var(--bg-input);
color: var(--text-3);
}
.catalog-footer-bar {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin-top: 12px;
border-radius: var(--r-md);
background: var(--bg-card);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid var(--border);
}
+127
View File
@@ -53,6 +53,10 @@
<span class="nav-icon">🗑</span>
<span>Dedup</span>
</button>
<button class="nav-item" data-view="catalog" data-feature="audio">
<span class="nav-icon">📅</span>
<span>Cataloga</span>
</button>
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span>
<span>Upgrade</span>
@@ -1304,6 +1308,106 @@
</div>
</section>
<!-- ===== VIEW: CATALOGA (AcoustID -> <Anno>/<Genere>/) ===== -->
<section class="view" id="view-catalog">
<header class="hero hero-purple">
<div class="hero-content">
<div class="hero-eyebrow purple">CATALOGA</div>
<h1 class="hero-title">Organizza per anno e genere</h1>
<p class="hero-subtitle">Identifica ogni brano via audio fingerprint (Chromaprint + AcoustID) e sposta i file in <code>&lt;Anno&gt;/&lt;Genere&gt;/</code>.</p>
</div>
<div class="hero-deco">📅</div>
</header>
<h2 class="section-label">Sorgente</h2>
<div class="card">
<div class="beatport-header">
<button id="catalog-pick-source" class="btn btn-primary pill">📁 Cartella input</button>
<button id="catalog-start-btn" class="btn btn-primary pill" disabled>
<span class="ico">▶</span> Scansiona
</button>
<button id="catalog-stop-btn" class="btn btn-danger pill" hidden>
<span class="ico">◼</span> Interrompi
</button>
</div>
<div class="beatport-header" style="margin-top:12px;gap:16px;">
<span id="catalog-source-display" class="beatport-output-info" style="margin:0;">Nessuna cartella selezionata</span>
</div>
<div class="beatport-header" style="margin-top:12px;gap:16px;">
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="checkbox" id="catalog-recursive" checked />
<span>Ricerca ricorsiva (include sottocartelle)</span>
</label>
</div>
</div>
<h2 class="section-label">Destinazione</h2>
<div class="card">
<div class="beatport-header">
<button id="catalog-pick-target" class="btn btn-ghost pill">📁 Cartella destinazione</button>
<button id="catalog-clear-target" class="btn btn-ghost pill btn-sm">Usa la stessa della sorgente</button>
</div>
<div class="beatport-header" style="margin-top:12px;gap:16px;">
<span id="catalog-target-display" class="beatport-output-info" style="margin:0;">Uguale alla sorgente (in-place)</span>
</div>
<div class="hint" style="margin-top:10px;">
<span class="hint-ico">ⓘ</span>
<div>
Se lasci vuota la destinazione i file vengono organizzati <strong>in-place</strong> nella cartella sorgente.
Le sottocartelle <code>&lt;Anno&gt;/&lt;Genere&gt;/</code> vengono create automaticamente.
File senza match AcoustID finiscono in <code>Unknown Year/Unknown Genre/</code>.
</div>
</div>
<div id="catalog-status" class="beatport-status"></div>
</div>
<h2 class="section-label">Progresso</h2>
<div class="card">
<div class="progress">
<div class="progress-bar"><div id="catalogProgressFill" class="progress-fill purple"></div></div>
<div class="progress-meta">
<span id="catalogPercent" class="percent purple">0%</span>
<span id="catalogCounter">In attesa</span>
</div>
</div>
</div>
<h2 class="section-label">Risultati</h2>
<div class="card" id="catalog-summary-card" hidden>
<div class="catalog-summary" id="catalog-summary-line"></div>
</div>
<div id="catalog-table-wrap" class="catalog-scroll" hidden>
<table class="catalog-table">
<thead>
<tr>
<th class="catalog-col-check">
<input type="checkbox" id="catalog-select-all" title="Seleziona/deseleziona tutti"/>
</th>
<th class="catalog-col-file">File</th>
<th class="catalog-col-artist">Artista</th>
<th class="catalog-col-title">Titolo</th>
<th class="catalog-col-year">Anno</th>
<th class="catalog-col-genre">Genere</th>
<th class="catalog-col-status">Stato</th>
</tr>
</thead>
<tbody id="catalog-table-body"></tbody>
</table>
</div>
<div class="catalog-footer-bar" id="catalog-footer-bar" hidden>
<span class="counter" id="catalog-selected-count">0 file selezionati</span>
<button id="catalog-move-btn" class="btn btn-danger pill" disabled>
<span class="ico">📦</span> Sposta selezionati
</button>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="catalog-log"></div>
</div>
</section>
</main>
</div>
@@ -1397,6 +1501,29 @@
</div>
</div>
<!-- ============== MODAL: Cataloga — conferma spostamento ============== -->
<div class="modal-backdrop" id="catalogConfirmModal" hidden>
<div class="modal">
<div class="modal-head">
<h2>Conferma spostamento</h2>
<button class="modal-close" id="catalogConfirmCloseBtn">×</button>
</div>
<div class="modal-body">
<p id="catalogConfirmMsg">Stai per spostare N file. Procedi?</p>
<p class="modal-hint">
L'operazione riorganizza i file in sottocartelle
<code>&lt;Anno&gt;/&lt;Genere&gt;/</code>. E' reversibile
manualmente dal log ma puo' richiedere tempo su cartelle
grandi.
</p>
</div>
<div class="modal-foot">
<button class="btn btn-ghost pill" id="catalogConfirmCancelBtn">Annulla</button>
<button class="btn btn-danger pill" id="catalogConfirmProceedBtn">Sposta</button>
</div>
</div>
</div>
<!-- ============== TOAST ============== -->
<div class="toast" id="toast"></div>
+438
View File
@@ -409,6 +409,7 @@ const logEls = {
convert: () => $("#convert-log"),
traxsource: () => $("#traxsource-log"),
dedup: () => $("#dedup-log"),
catalog: () => $("#catalog-log"),
};
function classifyLog(msg) {
@@ -530,6 +531,9 @@ async function init() {
// Dedup tab (audio duplicati via Chromaprint)
await DedupUI.init();
// Cataloga tab (AcoustID -> <Anno>/<Genere>/)
await CatalogUI.init();
}
async function refreshRecDevices() {
@@ -3205,6 +3209,440 @@ const DedupUI = (() => {
return { init };
})();
// ============================================================
// CatalogUI — AcoustID -> <Anno>/<Genere>/
// ============================================================
const CatalogUI = (() => {
const cstate = {
source: "",
target: "",
recursive: true,
scanning: false,
// Map path -> entry (mantiene ordine di inserimento = streaming order)
entries: new Map(),
// Set di path selezionati per il move
toMove: new Set(),
};
function setStatus(text, kind) {
const el = $("#catalog-status");
if (!el) return;
el.textContent = text || "";
el.className = "beatport-status" + (kind ? " " + kind : "");
}
function _effectiveTarget() {
return cstate.target || cstate.source || "";
}
function updateTargetDisplay() {
const el = $("#catalog-target-display");
if (!el) return;
if (cstate.target) {
el.textContent = cstate.target;
} else if (cstate.source) {
el.textContent = "Uguale alla sorgente: " + cstate.source;
} else {
el.textContent = "Uguale alla sorgente (in-place)";
}
}
function updateSelectedCount() {
const n = cstate.toMove.size;
const cnt = $("#catalog-selected-count");
if (cnt) cnt.textContent = n === 1 ? "1 file selezionato" : `${n} file selezionati`;
const btn = $("#catalog-move-btn");
if (btn) btn.disabled = n === 0 || cstate.scanning;
const bar = $("#catalog-footer-bar");
if (bar) bar.hidden = cstate.entries.size === 0;
}
function _statusBadge(entry) {
if (entry.error) {
return `<span class="catalog-status-badge err" title="${_escapeHtml(entry.error)}">errore</span>`;
}
if (entry.matched && entry.year && entry.genre) {
return `<span class="catalog-status-badge ok">match</span>`;
}
if (entry.matched) {
// Match parziale (mancano anno o genere)
return `<span class="catalog-status-badge warn">parziale</span>`;
}
return `<span class="catalog-status-badge warn">sconosciuto</span>`;
}
function _rowShouldBePreselected(entry) {
// Preselezione: solo match validi (year AND genre presenti).
return !!(entry.matched && entry.year && entry.genre);
}
function _renderRow(entry) {
const path = entry.path;
const tr = document.createElement("tr");
tr.dataset.path = path;
const unmatched = !entry.matched || !entry.year || !entry.genre;
if (unmatched) tr.classList.add("catalog-row-unmatched");
// Checkbox
const tdCheck = document.createElement("td");
tdCheck.className = "catalog-col-check";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.className = "catalog-check";
const pre = _rowShouldBePreselected(entry);
cb.checked = pre;
if (pre) cstate.toMove.add(path);
cb.addEventListener("change", () => {
if (cb.checked) cstate.toMove.add(path);
else cstate.toMove.delete(path);
updateSelectedCount();
});
tdCheck.appendChild(cb);
tr.appendChild(tdCheck);
// File (basename)
const tdFile = document.createElement("td");
tdFile.className = "catalog-col-file";
tdFile.innerHTML = `<span class="catalog-file-cell" title="${_escapeHtml(path)}">${_escapeHtml(_basename(path))}</span>`;
tr.appendChild(tdFile);
// Artista
const tdArtist = document.createElement("td");
tdArtist.className = "catalog-col-artist";
tdArtist.textContent = entry.artist || "—";
tdArtist.title = entry.artist || "";
tr.appendChild(tdArtist);
// Titolo
const tdTitle = document.createElement("td");
tdTitle.className = "catalog-col-title";
tdTitle.textContent = entry.title || "—";
tdTitle.title = entry.title || "";
tr.appendChild(tdTitle);
// Anno
const tdYear = document.createElement("td");
tdYear.className = "catalog-col-year";
tdYear.textContent = entry.year ? String(entry.year) : "—";
tr.appendChild(tdYear);
// Genere
const tdGenre = document.createElement("td");
tdGenre.className = "catalog-col-genre";
tdGenre.textContent = entry.genre || "—";
tdGenre.title = entry.genre || "";
tr.appendChild(tdGenre);
// Status
const tdStatus = document.createElement("td");
tdStatus.className = "catalog-col-status";
tdStatus.innerHTML = _statusBadge(entry);
tr.appendChild(tdStatus);
return tr;
}
function _upsertEntry(entry) {
if (!entry || !entry.path) return;
const wrap = $("#catalog-table-wrap");
const body = $("#catalog-table-body");
if (!body) return;
const existed = cstate.entries.has(entry.path);
cstate.entries.set(entry.path, entry);
if (existed) {
// Aggiorna in place: rimuovi vecchia riga e append nuova nella stessa posizione
const oldRow = body.querySelector(`tr[data-path="${CSS.escape(entry.path)}"]`);
const newRow = _renderRow(entry);
if (oldRow) body.replaceChild(newRow, oldRow);
else body.appendChild(newRow);
} else {
body.appendChild(_renderRow(entry));
}
if (wrap) wrap.hidden = false;
_updateSummary();
updateSelectedCount();
}
function _updateSummary() {
const line = $("#catalog-summary-line");
const card = $("#catalog-summary-card");
if (!line || !card) return;
const n = cstate.entries.size;
if (n === 0) { card.hidden = true; return; }
let matched = 0, unknown = 0, errors = 0;
cstate.entries.forEach((e) => {
if (e.error) errors++;
else if (e.matched && e.year && e.genre) matched++;
else unknown++;
});
line.textContent =
`${n} file · ${matched} identificati · ${unknown} parziali/sconosciuti · ${errors} errori`;
card.hidden = false;
}
function _clearTable() {
const body = $("#catalog-table-body");
if (body) body.innerHTML = "";
const wrap = $("#catalog-table-wrap");
if (wrap) wrap.hidden = true;
const card = $("#catalog-summary-card");
if (card) card.hidden = true;
cstate.entries.clear();
cstate.toMove.clear();
updateSelectedCount();
}
async function pickSource() {
const p = await window.pywebview.api.catalog_pick_source_folder();
if (p && typeof p === "string") {
cstate.source = p;
$("#catalog-source-display").textContent = p;
$("#catalog-start-btn").disabled = false;
updateTargetDisplay();
setStatus("", "");
}
}
async function pickTarget() {
const p = await window.pywebview.api.catalog_pick_target_folder();
if (p && typeof p === "string") {
cstate.target = p;
updateTargetDisplay();
}
}
function clearTarget() {
cstate.target = "";
updateTargetDisplay();
}
async function startScan() {
if (!cstate.source) return;
cstate.recursive = $("#catalog-recursive").checked;
cstate.scanning = true;
_clearTable();
$("#catalog-start-btn").disabled = true;
$("#catalog-pick-source").disabled = true;
$("#catalog-stop-btn").hidden = false;
$("#catalog-log").innerHTML = "";
$("#catalogProgressFill").style.width = "0%";
$("#catalogPercent").textContent = "0%";
$("#catalogCounter").textContent = "Scansione in corso…";
setStatus("Scansione in corso...", "loading");
let res;
try {
res = await window.pywebview.api.catalog_start_scan({
source: cstate.source,
target: cstate.target,
recursive: cstate.recursive,
});
} catch (e) {
setStatus("Errore avvio: " + ((e && e.message) || e), "error");
finishScan();
return;
}
if (!res || !res.ok) {
const msg = (res && res.error) || "Impossibile avviare la scansione";
setStatus(msg, "error");
if (!handleGateBlock(res || {})) toast(msg, "error");
finishScan();
}
}
async function stopScan() {
try { await window.pywebview.api.catalog_stop_scan(); } catch (e) { console.error(e); }
}
function finishScan() {
cstate.scanning = false;
$("#catalog-start-btn").disabled = !cstate.source;
$("#catalog-pick-source").disabled = false;
$("#catalog-stop-btn").hidden = true;
updateSelectedCount();
}
function _openConfirmModal(nFiles, target) {
const msg = $("#catalogConfirmMsg");
if (msg) {
const word = nFiles === 1 ? "1 file" : `${nFiles} file`;
msg.textContent = `Stai per spostare ${word} in ${target}. Procedi?`;
}
$("#catalogConfirmModal").hidden = false;
}
function _closeConfirmModal() {
$("#catalogConfirmModal").hidden = true;
}
async function moveSelected() {
const paths = [...cstate.toMove];
if (!paths.length) return;
const target = _effectiveTarget();
if (!target) { toast("Cartella destinazione mancante", "error"); return; }
// Costruisci lista entries in ordine di selezione
const selectedEntries = paths
.map((p) => cstate.entries.get(p))
.filter(Boolean);
if (!selectedEntries.length) return;
_openConfirmModal(selectedEntries.length, target);
// Attendi conferma o annulla via bottoni
const proceed = await new Promise((resolve) => {
const yes = $("#catalogConfirmProceedBtn");
const no = $("#catalogConfirmCancelBtn");
const close1 = $("#catalogConfirmCloseBtn");
const cleanup = () => {
yes.removeEventListener("click", onYes);
no.removeEventListener("click", onNo);
close1.removeEventListener("click", onNo);
};
const onYes = () => { cleanup(); _closeConfirmModal(); resolve(true); };
const onNo = () => { cleanup(); _closeConfirmModal(); resolve(false); };
yes.addEventListener("click", onYes);
no.addEventListener("click", onNo);
close1.addEventListener("click", onNo);
});
if (!proceed) return;
const btn = $("#catalog-move-btn");
btn.disabled = true;
let res;
try {
res = await window.pywebview.api.catalog_move_files(selectedEntries, target);
} catch (e) {
toast("Errore: " + ((e && e.message) || e), "error");
btn.disabled = false;
return;
}
if (!res || !res.ok) {
toast((res && res.error) || "Errore spostamento", "error");
btn.disabled = false;
return;
}
// Rimuovi dalla tabella le entry spostate (usando operations.src)
const moved = new Set((res.operations || []).map((o) => o.src));
const body = $("#catalog-table-body");
moved.forEach((p) => {
cstate.entries.delete(p);
cstate.toMove.delete(p);
if (body) {
const row = body.querySelector(`tr[data-path="${CSS.escape(p)}"]`);
if (row) row.remove();
}
});
_updateSummary();
updateSelectedCount();
const wrap = $("#catalog-table-wrap");
if (wrap && cstate.entries.size === 0) wrap.hidden = true;
const okN = res.moved || 0;
const failN = res.failed_count || 0;
if (failN > 0) {
toast(`${okN} spostati, ${failN} falliti`, "error");
} else {
toast(`${okN} file organizzati`, "success");
}
}
async function init() {
// Bind eventi
$("#catalog-pick-source").addEventListener("click", pickSource);
$("#catalog-pick-target").addEventListener("click", pickTarget);
$("#catalog-clear-target").addEventListener("click", clearTarget);
$("#catalog-start-btn").addEventListener("click", startScan);
$("#catalog-stop-btn").addEventListener("click", stopScan);
$("#catalog-move-btn").addEventListener("click", moveSelected);
$("#catalog-recursive").addEventListener("change", (e) => {
cstate.recursive = e.target.checked;
});
$("#catalog-select-all").addEventListener("change", (e) => {
const on = e.target.checked;
const checks = $$("#catalog-table-body .catalog-check");
cstate.toMove.clear();
checks.forEach((cb) => {
cb.checked = on;
if (on) {
const row = cb.closest("tr");
if (row && row.dataset.path) cstate.toMove.add(row.dataset.path);
}
});
updateSelectedCount();
});
// Ripristina ultimo state da config
const cfg = state.config || {};
if (cfg.catalog_last_source) {
cstate.source = cfg.catalog_last_source;
$("#catalog-source-display").textContent = cfg.catalog_last_source;
$("#catalog-start-btn").disabled = false;
}
if (cfg.catalog_last_target) {
cstate.target = cfg.catalog_last_target;
}
if (typeof cfg.catalog_recursive === "boolean") {
$("#catalog-recursive").checked = cfg.catalog_recursive;
cstate.recursive = cfg.catalog_recursive;
}
updateTargetDisplay();
// Bridge handlers
bridgeHandlers["catalog:progress"] = (p) => {
if (!p) return;
if (typeof p.overall === "number") {
const pct = Math.round(p.overall * 100);
$("#catalogProgressFill").style.width = pct + "%";
$("#catalogPercent").textContent = pct + "%";
}
if (typeof p.idx === "number" && typeof p.total === "number" && p.total > 0) {
const status = p.status || "";
let label;
if (status === "computing") label = `File ${p.idx}/${p.total} · fingerprint`;
else if (status === "lookup") label = `File ${p.idx}/${p.total} · AcoustID`;
else if (status === "completed") label = `Completato: ${p.total} file`;
else if (status === "stopped") label = "Interrotta";
else if (status === "error") label = `File ${p.idx}/${p.total} · errore`;
else label = `File ${p.idx}/${p.total}`;
$("#catalogCounter").textContent = label;
}
};
bridgeHandlers["catalog:entry"] = (entry) => {
_upsertEntry(entry);
setStatus(`Elaborati ${cstate.entries.size} file...`, "loading");
};
bridgeHandlers["catalog:log"] = (_op) => {
// Log dettagliato gia' appeso via il canale "log" dal backend.
// Handler dedicato riservato per estensioni future (es. undo).
};
bridgeHandlers["catalog:done"] = (p) => {
// Se lo streaming non ha popolato nulla (raro, es. errore), usa
// p.entries come fallback.
if (cstate.entries.size === 0 && p && Array.isArray(p.entries)) {
p.entries.forEach(_upsertEntry);
}
if (p && p.ok === false) {
setStatus(p.error || "Errore scansione", "error");
} else if (cstate.entries.size === 0) {
setStatus("Nessun file audio trovato", "ok");
} else {
const matched = (p && p.n_matched) || 0;
const total = (p && p.n_total) || cstate.entries.size;
setStatus(`Scansione completata · ${matched}/${total} identificati`, "ok");
}
finishScan();
};
}
return { init };
})();
// ============================================================
// Boot
// ============================================================