novela/containers/novela/templates/bulk_import.html
2026-04-15 21:39:20 +02:00

962 lines
39 KiB
HTML
Raw 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.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Bulk Import</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body { background: var(--bg); color: var(--text); font-family: var(--serif); }
.main {
margin-left: var(--sidebar);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1rem 5rem;
}
@media (max-width: 768px) { .main { margin-left: 0; padding: 4rem 1rem 4rem; } }
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 2rem;
width: 100%; max-width: 680px; margin-bottom: 1.5rem;
}
.card-wide { max-width: 1100px; }
.card-title {
font-size: 0.7rem; font-family: var(--mono);
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--accent); margin-bottom: 1.25rem;
}
label {
display: block; font-size: 0.78rem; font-family: var(--mono);
color: var(--text); margin-bottom: 0.4rem; letter-spacing: 0.04em;
}
input[type="text"], input[type="date"], select {
width: 100%; background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text);
font-family: var(--mono); font-size: 0.85rem;
padding: 0.6rem 0.85rem; outline: none;
transition: border-color 0.15s; margin-bottom: 1rem;
appearance: none; -webkit-appearance: none;
}
input[type="text"]:focus, input[type="date"]:focus, select:focus { border-color: var(--accent); }
select { cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238a8278' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.75rem center; padding-right: 2.2rem; }
select option { background: var(--surface2); }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.row-2 input, .row-2 select { margin-bottom: 0; }
.row-2 > div { display: flex; flex-direction: column; margin-bottom: 1rem; }
.row-2 > div label { margin-bottom: 0.4rem; }
button {
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
padding: 0.75rem 1.25rem; background: var(--accent); color: #0f0e0c;
border: none; border-radius: var(--radius); font-family: var(--mono);
font-size: 0.85rem; font-weight: 500; letter-spacing: 0.04em;
cursor: pointer; transition: background 0.15s, transform 0.1s;
}
button:hover { background: var(--accent2); }
button:active { transform: scale(0.99); }
button:disabled { background: var(--text-faint); cursor: not-allowed; color: var(--bg); }
.btn-outline {
background: var(--surface2); color: var(--text-dim);
border: 1px solid var(--border);
}
.btn-outline:hover { background: var(--surface); color: var(--text); }
.btn-sm {
padding: 0.35rem 0.75rem; font-size: 0.75rem;
}
.btn-danger { background: rgba(200,90,58,0.15); color: var(--error); border: 1px solid rgba(200,90,58,0.3); }
.btn-danger:hover { background: rgba(200,90,58,0.25); color: var(--error); }
/* Placeholder chips */
.ph-chips {
display: flex; flex-wrap: wrap; gap: 0.4rem;
margin-bottom: 1rem;
}
.ph-chip {
display: inline-flex; align-items: center;
padding: 0.25rem 0.65rem;
border-radius: var(--radius);
font-family: var(--mono); font-size: 0.78rem;
cursor: grab; user-select: none;
background: var(--surface2); border: 1px solid var(--border);
color: var(--chip-color, var(--text-dim));
transition: border-color 0.15s, color 0.15s;
}
.ph-chip:hover { border-color: var(--chip-color, var(--accent)); color: var(--chip-color, var(--text)); }
.ph-chip:active { cursor: grabbing; }
.pattern-preview {
font-family: var(--mono); font-size: 0.78rem;
color: var(--text-dim); margin-bottom: 1rem;
padding: 0.5rem 0.75rem;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
letter-spacing: 0.02em; word-break: break-all;
}
.pattern-preview .tok { font-weight: 500; }
.pattern-preview .delim { color: var(--text); }
.test-parse-result {
font-family: var(--mono); font-size: 0.78rem;
color: var(--text-dim); line-height: 1.9;
padding: 0.6rem 0.75rem; background: var(--bg);
border: 1px solid var(--border); border-radius: var(--radius);
display: none;
}
.test-parse-result.visible { display: block; }
.tpr-field { color: var(--text-faint); }
.tpr-val { color: var(--text); }
/* File drop */
.file-drop {
border: 1px dashed var(--border); border-radius: var(--radius);
padding: 2rem; text-align: center; cursor: pointer;
transition: border-color 0.15s; position: relative; margin-bottom: 0;
}
.file-drop:hover, .file-drop.drag-over { border-color: var(--accent); }
.file-drop input[type="file"] {
position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%;
}
.file-drop-label {
font-family: var(--mono); font-size: 0.82rem; color: var(--text-dim);
pointer-events: none;
}
.file-drop-label span { color: var(--accent); }
.file-count {
font-family: var(--mono); font-size: 0.8rem;
color: var(--text-dim); margin-top: 0.75rem;
display: none;
}
.file-count.visible { display: block; }
/* Preview table */
.table-scroll {
overflow-x: auto; overflow-y: auto; max-height: 65vh;
border: 1px solid var(--border); border-radius: var(--radius);
margin-bottom: 1rem;
}
.table-scroll::-webkit-scrollbar { width: 6px; height: 6px; }
.table-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
table {
width: 100%; border-collapse: collapse;
font-family: var(--mono); font-size: 0.78rem;
}
thead th {
background: var(--surface2); color: var(--text-dim);
padding: 0.6rem 0.75rem; text-align: left;
font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase;
font-size: 0.68rem; position: sticky; top: 0; z-index: 2;
border-bottom: 1px solid var(--border); white-space: nowrap;
}
tbody tr { border-bottom: 1px solid var(--border); }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: rgba(255,162,14,0.04); }
tbody tr.row-warn { background: rgba(200,160,58,0.06); }
tbody tr.row-warn:hover { background: rgba(200,160,58,0.10); }
td {
padding: 0.45rem 0.6rem; color: var(--text); vertical-align: middle;
}
td.td-num { color: var(--text-faint); font-size: 0.7rem; width: 2.5rem; text-align: right; padding-right: 0.75rem; }
td.td-filename { color: var(--text-dim); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
td[contenteditable="true"] { outline: none; cursor: text; min-width: 60px; }
td[contenteditable="true"]:focus {
background: rgba(255,162,14,0.08);
box-shadow: inset 0 0 0 1px var(--accent);
}
td[contenteditable="true"]:empty::before {
content: attr(data-placeholder); color: var(--text-faint); font-style: italic;
}
td.td-warn { color: var(--warning); font-size: 0.72rem; width: 1.5rem; text-align: center; }
td.td-skip { width: 2.5rem; text-align: center; }
td.td-skip input[type="checkbox"] { cursor: pointer; accent-color: var(--error); }
tbody tr.row-dup { background: rgba(200,90,58,0.06); }
tbody tr.row-dup:hover { background: rgba(200,90,58,0.10); }
tbody tr.row-skipped { opacity: 0.38; }
.cnt-dup { color: var(--error); }
.dup-actions {
display: flex; gap: 0.5rem; align-items: center;
font-family: var(--mono); font-size: 0.73rem;
}
.dup-actions button {
padding: 0.2rem 0.55rem; font-size: 0.7rem;
background: var(--surface2); color: var(--text-dim);
border: 1px solid var(--border); border-radius: var(--radius);
}
.dup-actions button:hover { color: var(--text); background: var(--surface); }
.preview-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;
}
.preview-header .card-title { margin-bottom: 0; }
.preview-stats {
font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim);
}
.preview-stats .cnt-ok { color: var(--success); }
.preview-stats .cnt-warn { color: var(--warning); }
/* Progress */
.progress-wrap { margin-top: 1.25rem; display: none; }
.progress-wrap.visible { display: block; }
.progress-bar-outer {
background: var(--bg); border: 1px solid var(--border);
border-radius: 100px; height: 6px; margin-bottom: 0.75rem; overflow: hidden;
}
.progress-bar-inner {
height: 100%; background: var(--accent); border-radius: 100px;
width: 0%; transition: width 0.2s ease;
}
.progress-status {
font-family: var(--mono); font-size: 0.78rem;
color: var(--text-dim); min-height: 1.2em;
}
/* Result */
.result-box { display: none; }
.result-box.visible { display: block; }
.result-ok {
font-family: var(--mono); font-size: 0.82rem; color: var(--success);
margin-bottom: 0.75rem;
}
.skipped-list {
margin-top: 0.75rem; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg);
max-height: 200px; overflow-y: auto;
}
.skipped-list::-webkit-scrollbar { width: 4px; }
.skipped-list::-webkit-scrollbar-thumb { background: var(--border); }
.skipped-item {
padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border);
font-family: var(--mono); font-size: 0.72rem; color: var(--error);
display: flex; gap: 0.75rem;
}
.skipped-item:last-child { border-bottom: none; }
.skipped-item .sk-file { color: var(--text-dim); flex-shrink: 0; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.divider { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; }
.suggest-wrap { position: relative; margin-bottom: 1rem; }
.suggest-wrap input { margin-bottom: 0; }
.suggest-dropdown {
position: absolute; background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); max-height: 180px; overflow-y: auto;
z-index: 300; width: 100%; margin-top: 2px; box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.suggest-option { padding: 0.45rem 0.75rem; font-family: var(--mono); font-size: 0.8rem; color: var(--text-dim); cursor: pointer; }
.suggest-option:hover, .suggest-option.active { background: var(--surface2); color: var(--text); }
.hint {
font-family: var(--mono); font-size: 0.73rem; color: var(--text-dim);
margin-top: -0.6rem; margin-bottom: 1rem; line-height: 1.6;
}
</style>
</head>
<body>
{% include "_sidebar.html" %}
<main class="main">
<!-- Card 1: Pattern -->
<div class="card">
<div class="card-title">Filename Pattern</div>
<label>Pattern</label>
<input type="text" id="pattern-input" value="%series% - %volume% - %title% - %year%" oninput="onPatternChange()" style="font-family:var(--mono)"/>
<label>Available placeholders <span style="color:var(--text-dim)">(click or drag to cursor position)</span></label>
<div class="ph-chips" id="ph-chips"></div>
<div class="pattern-preview" id="pattern-preview"></div>
<label>Test filename (optional)</label>
<input type="text" id="test-input" placeholder="" oninput="updateTestParse()"/>
<div class="test-parse-result" id="test-parse-result"></div>
</div>
<!-- Card 2: Shared metadata -->
<div class="card">
<div class="card-title">Shared Metadata</div>
<p class="hint">Applies to all files. Filled-in fields override values parsed from the pattern.</p>
<div class="row-2">
<div>
<label>Author</label>
<div class="suggest-wrap">
<input type="text" id="shared-author" autocomplete="off" oninput="updatePreview()"/>
<div class="suggest-dropdown" id="author-dropdown" style="display:none"></div>
</div>
</div>
<div>
<label>Publisher</label>
<div class="suggest-wrap">
<input type="text" id="shared-publisher" autocomplete="off" oninput="updatePreview()"/>
<div class="suggest-dropdown" id="publisher-dropdown" style="display:none"></div>
</div>
</div>
</div>
<div class="row-2">
<div>
<label>Series</label>
<div class="suggest-wrap">
<input type="text" id="shared-series" autocomplete="off" oninput="updatePreview()"/>
<div class="suggest-dropdown" id="series-dropdown" style="display:none"></div>
</div>
</div>
<div>
<label>Year/Vol. <span style="color:var(--text-dim)">(series volume)</span></label>
<input type="text" id="shared-series-volume" autocomplete="off" oninput="updatePreview()" placeholder="e.g. 2024"/>
</div>
<div>
<label>Status</label>
<select id="shared-status" oninput="updatePreview()">
<option value="">(none)</option>
<option value="Complete">Complete</option>
<option value="Ongoing">Ongoing</option>
<option value="Temporary Hold">Temporary Hold</option>
<option value="Long-Term Hold">Long-Term Hold</option>
</select>
</div>
<div>
<label>Genres <span style="color:var(--text-dim)">(comma-separated)</span></label>
<input type="text" id="shared-genres" oninput="updatePreview()"/>
</div>
</div>
<label>Tags <span style="color:var(--text-dim)">(comma-separated)</span></label>
<input type="text" id="shared-tags"/>
<label style="display:flex;align-items:center;gap:0.5rem;margin-top:0.5rem;cursor:pointer">
<input type="checkbox" id="auto-title" oninput="updatePreview()"/>
Auto-generate titles from series info &mdash; <span style="color:var(--text-dim)">for comics without individual titles</span>
</label>
<p class="hint" id="auto-title-hint" style="display:none">
Format: <code>Series (Year/Vol) #Number</code>. Only fills in rows where title is empty or matches the filename stem.
</p>
</div>
<!-- Card 3: Files -->
<div class="card">
<div class="card-title">Select Files</div>
<div class="file-drop" id="file-drop">
<input type="file" id="file-input" multiple accept=".cbr,.cbz,.epub,.pdf"
onchange="onFilesSelected(this.files)"/>
<div class="file-drop-label">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="margin-bottom:0.5rem;display:block;margin:0 auto 0.5rem">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Click or drop files here &mdash; <span>CBR, CBZ, EPUB, PDF</span>
</div>
</div>
<div class="file-count" id="file-count"></div>
</div>
<!-- Card 4: Preview table -->
<div class="card card-wide" id="preview-card" style="display:none">
<div class="preview-header">
<div class="card-title">Preview</div>
<div class="preview-stats" id="preview-stats"></div>
</div>
<div class="table-scroll">
<table>
<thead>
<tr>
<th style="width:2.5rem">#</th>
<th>Filename</th>
<th>Series</th>
<th>Yr/Vol</th>
<th>Vol</th>
<th>Title</th>
<th>Author</th>
<th>Publisher</th>
<th>Year</th>
<th style="width:2.5rem" title="Skip this file during import">Skip</th>
<th style="width:1.5rem"></th>
</tr>
</thead>
<tbody id="preview-body"></tbody>
</table>
</div>
<p class="hint" style="margin-bottom:0">
Cells are editable — click to adjust.
<span style="color:var(--warning)">Yellow rows</span> did not match the pattern.
<span style="color:var(--error)">Red rows</span> already exist in the library — check the Skip box to exclude them.
</p>
</div>
<!-- Card 5: Import -->
<div class="card" id="import-card" style="display:none">
<div class="card-title">Import</div>
<button id="import-btn" onclick="startImport()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<span id="import-btn-label">Import files</span>
</button>
<div class="progress-wrap" id="progress-wrap">
<div class="progress-bar-outer">
<div class="progress-bar-inner" id="progress-bar"></div>
</div>
<div class="progress-status" id="progress-status"></div>
</div>
<div class="result-box" id="result-box">
<hr class="divider"/>
<div class="result-ok" id="result-ok"></div>
<div id="result-skipped"></div>
<button class="btn-outline" onclick="goToLibrary()" style="margin-top:1rem;width:auto">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
Go to library
</button>
</div>
</div>
</main>
<script src="/static/books.js"></script>
<script>
// ── State ──────────────────────────────────────────────────────────────────
const PLACEHOLDER_META = [
{ key: 'series', label: '%series%', color: 'var(--accent)' },
{ key: 'series_volume', label: '%series_volume%', color: '#d07840' },
{ key: 'volume', label: '%volume%', color: '#4a90b8' },
{ key: 'title', label: '%title%', color: 'var(--success)' },
{ key: 'year', label: '%year%', color: 'var(--warning)' },
{ key: 'month', label: '%month%', color: '#c8a03a' },
{ key: 'day', label: '%day%', color: '#c8a03a' },
{ key: 'author', label: '%author%', color: '#9878c8' },
{ key: 'publisher', label: '%publisher%', color: '#4ab8a0' },
{ key: 'ignore', label: '%ignore%', color: 'var(--text-faint)' },
];
let selectedFiles = [];
let parsedRows = []; // [{original_filename, series, series_volume, volume, title, year, author, publisher, status, genres, tags, _warn}]
const BATCH_SIZE = 5;
// ── Placeholder chips ──────────────────────────────────────────────────────
function initChips() {
const container = document.getElementById('ph-chips');
container.innerHTML = '';
PLACEHOLDER_META.forEach(ph => {
const chip = document.createElement('span');
chip.className = 'ph-chip';
chip.style.setProperty('--chip-color', ph.color);
chip.textContent = ph.label;
chip.draggable = true;
chip.addEventListener('click', () => {
const input = document.getElementById('pattern-input');
const pos = input.selectionStart ?? input.value.length;
input.value = input.value.slice(0, pos) + ph.label + input.value.slice(pos);
const newPos = pos + ph.label.length;
input.setSelectionRange(newPos, newPos);
input.focus();
onPatternChange();
});
chip.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', ph.label);
e.dataTransfer.effectAllowed = 'copy';
});
container.appendChild(chip);
});
// Allow dropping chips onto pattern input
const patInput = document.getElementById('pattern-input');
patInput.addEventListener('drop', () => { setTimeout(onPatternChange, 0); });
}
function renderPatternPreview() {
const pattern = document.getElementById('pattern-input').value;
const parts = pattern.split(/(%\w+%)/);
let html = '';
parts.forEach(part => {
if (/^%\w+%$/.test(part)) {
const key = part.slice(1, -1);
const meta = PLACEHOLDER_META.find(p => p.key === key);
const color = meta ? meta.color : 'var(--text-dim)';
html += `<span class="tok" style="color:${color}">${esc(part)}</span>`;
} else if (part) {
html += `<span class="delim">${esc(part)}</span>`;
}
});
document.getElementById('pattern-preview').innerHTML = html || '<span class="delim">(empty)</span>';
}
function onPatternChange() {
renderPatternPreview();
updateTestParse();
updatePreview();
}
// ── Filename parser ────────────────────────────────────────────────────────
function patternToRegex(pattern) {
const parts = pattern.split(/(%\w+%)/);
const fields = [];
let regexStr = '^';
parts.forEach(part => {
if (/^%\w+%$/.test(part)) {
fields.push(part.slice(1, -1));
regexStr += '(.*?)';
} else {
regexStr += part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
});
regexStr += '$';
return { regex: new RegExp(regexStr), fields };
}
function parseFilename(stem, pattern) {
const { regex, fields } = patternToRegex(pattern);
const match = regex.exec(stem);
const result = {};
if (match) {
fields.forEach((field, i) => {
if (field !== 'ignore') result[field] = match[i + 1].trim();
});
result._warn = false;
} else {
result.title = stem;
result._warn = true;
}
return result;
}
function updateTestParse() {
const raw = document.getElementById('test-input').value.trim();
const box = document.getElementById('test-parse-result');
if (!raw) { box.classList.remove('visible'); return; }
const stem = raw.replace(/\.[^.]+$/, '');
const pattern = document.getElementById('pattern-input').value;
const parsed = parseFilename(stem, pattern);
const fields = PLACEHOLDER_META.filter(p => p.key !== 'ignore' && parsed[p.key] !== undefined);
let html = '';
fields.forEach(p => {
html += `<span class="tpr-field">${esc(p.key)}:</span> <span class="tpr-val">${esc(parsed[p.key])}</span> `;
});
if (!html) html = `<span class="tpr-field">titel:</span> <span class="tpr-val">${esc(parsed.title || stem)}</span>`;
box.innerHTML = html.trim();
box.classList.add('visible');
}
// ── File selection ─────────────────────────────────────────────────────────
function onFilesSelected(files) {
selectedFiles = Array.from(files).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
const cnt = document.getElementById('file-count');
cnt.textContent = selectedFiles.length + ' file' + (selectedFiles.length !== 1 ? 's' : '') + ' selected';
cnt.classList.add('visible');
updatePreview();
document.getElementById('preview-card').style.display = '';
document.getElementById('import-card').style.display = '';
}
// Drag & drop
const dropEl = document.getElementById('file-drop');
dropEl.addEventListener('dragover', e => { e.preventDefault(); dropEl.classList.add('drag-over'); });
dropEl.addEventListener('dragleave', () => dropEl.classList.remove('drag-over'));
dropEl.addEventListener('drop', e => {
e.preventDefault();
dropEl.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files.length) {
document.getElementById('file-input').files = files;
onFilesSelected(files);
}
});
// ── Preview table ──────────────────────────────────────────────────────────
function updatePreview() {
if (!selectedFiles.length) return;
const pattern = document.getElementById('pattern-input').value;
const sharedSeries = document.getElementById('shared-series').value.trim();
const sharedSeriesVolume = document.getElementById('shared-series-volume').value.trim();
const autoTitle = document.getElementById('auto-title').checked;
document.getElementById('auto-title-hint').style.display = autoTitle ? '' : 'none';
parsedRows = selectedFiles.map(f => {
const stem = f.name.replace(/\.[^.]+$/, '');
const parsed = parseFilename(stem, pattern);
const series = sharedSeries || parsed.series || '';
const series_volume = sharedSeriesVolume || parsed.series_volume || '';
const volume = parsed.volume || '';
let title = parsed.title || '';
if (autoTitle && series && !title) {
title = series;
if (series_volume) title += ` (${series_volume})`;
if (volume) title += ` #${volume}`;
}
return {
original_filename: f.name,
series,
series_volume,
volume,
title: title || stem,
year: parsed.year || '',
author: parsed.author || '',
publisher: parsed.publisher || '',
status: '',
genres: '',
tags: '',
_warn: parsed._warn || false,
_duplicate: false,
_skip: false,
};
});
renderPreviewTable();
checkDuplicates();
}
async function checkDuplicates() {
if (!parsedRows.length) return;
const sharedAuthor = document.getElementById('shared-author').value.trim();
const items = parsedRows.map(r => ({
title: r.title,
author: r.author || sharedAuthor,
series: r.series,
volume: r.volume,
}));
try {
const resp = await fetch('/api/bulk-check-duplicates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
});
if (!resp.ok) return;
const data = await resp.json();
if (!Array.isArray(data.duplicates)) return;
data.duplicates.forEach((isDup, i) => {
if (!parsedRows[i]) return;
parsedRows[i]._duplicate = isDup;
if (isDup) parsedRows[i]._skip = true;
});
renderPreviewTable();
} catch {}
}
function renderPreviewTable() {
const sharedAuthor = document.getElementById('shared-author').value.trim();
const sharedPublisher = document.getElementById('shared-publisher').value.trim();
const tbody = document.getElementById('preview-body');
tbody.innerHTML = '';
const hasDups = parsedRows.some(r => r._duplicate);
let warnCount = 0;
let dupCount = 0;
let skipCount = 0;
parsedRows.forEach((row, i) => {
if (row._warn) warnCount++;
if (row._duplicate) dupCount++;
if (row._skip) skipCount++;
const tr = document.createElement('tr');
const classes = [];
if (row._warn) classes.push('row-warn');
if (row._duplicate) classes.push('row-dup');
if (row._skip) classes.push('row-skipped');
if (classes.length) tr.className = classes.join(' ');
// #
const tdNum = document.createElement('td');
tdNum.className = 'td-num';
tdNum.textContent = i + 1;
tr.appendChild(tdNum);
// Filename (read-only)
const tdFn = document.createElement('td');
tdFn.className = 'td-filename';
tdFn.title = row.original_filename;
tdFn.textContent = row.original_filename;
tr.appendChild(tdFn);
// Editable fields
const sharedSeriesVolume = document.getElementById('shared-series-volume').value.trim();
const fields = [
{ key: 'series', placeholder: '—' },
{ key: 'series_volume', placeholder: sharedSeriesVolume || '—' },
{ key: 'volume', placeholder: '—' },
{ key: 'title', placeholder: 'Title' },
{ key: 'author', placeholder: sharedAuthor || '—' },
{ key: 'publisher', placeholder: sharedPublisher || '—' },
{ key: 'year', placeholder: '—' },
];
fields.forEach(({ key, placeholder }) => {
const td = document.createElement('td');
td.contentEditable = 'true';
td.dataset.row = i;
td.dataset.field = key;
td.dataset.placeholder = placeholder;
td.textContent = row[key] || '';
td.addEventListener('input', () => {
parsedRows[i][key] = td.textContent.trim();
});
td.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); td.blur(); }
});
td.addEventListener('paste', e => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
document.execCommand('insertText', false, text);
});
tr.appendChild(td);
});
// Skip checkbox (always visible)
const tdSkip = document.createElement('td');
tdSkip.className = 'td-skip';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = row._skip;
cb.title = 'Skip this file during import';
cb.addEventListener('change', () => {
parsedRows[i]._skip = cb.checked;
tr.classList.toggle('row-skipped', cb.checked);
renderPreviewStats();
});
tdSkip.appendChild(cb);
tr.appendChild(tdSkip);
// Warning indicator
const tdW = document.createElement('td');
tdW.className = 'td-warn';
if (row._warn) tdW.title = 'Pattern did not match — check the values';
if (row._duplicate && !row._warn) tdW.title = 'Already exists in the library';
tdW.textContent = row._warn ? '⚠' : (row._duplicate ? '⊘' : '');
if (row._duplicate && !row._warn) tdW.style.color = 'var(--error)';
tr.appendChild(tdW);
tbody.appendChild(tr);
});
renderPreviewStats();
}
function renderPreviewStats() {
const dupCount = parsedRows.filter(r => r._duplicate).length;
const skipCount = parsedRows.filter(r => r._skip).length;
const warnCount = parsedRows.filter(r => r._warn).length;
const importCount = parsedRows.length - skipCount;
const statsEl = document.getElementById('preview-stats');
let stats = `<span class="cnt-ok">${importCount} to import</span>`;
if (skipCount) stats += ` &nbsp; <span class="cnt-warn">${skipCount} skipped</span>`;
if (warnCount) stats += ` &nbsp; <span class="cnt-warn">${warnCount} to check</span>`;
if (dupCount) stats += ` &nbsp; <span class="cnt-dup">${dupCount} duplicate${dupCount !== 1 ? 's' : ''}</span>`;
statsEl.innerHTML = stats;
document.getElementById('import-btn-label').textContent =
`Import ${importCount} file${importCount !== 1 ? 's' : ''}`;
}
function setAllDuplicatesSkip(skip) {
parsedRows.forEach(r => { if (r._duplicate) r._skip = skip; });
renderPreviewTable();
}
// ── Import ─────────────────────────────────────────────────────────────────
async function startImport() {
if (!selectedFiles.length) return;
const shared = {
author: document.getElementById('shared-author').value.trim(),
publisher: document.getElementById('shared-publisher').value.trim(),
series: document.getElementById('shared-series').value.trim(),
series_volume: document.getElementById('shared-series-volume').value.trim(),
status: document.getElementById('shared-status').value,
genres: document.getElementById('shared-genres').value.trim(),
tags: document.getElementById('shared-tags').value.trim(),
};
const btn = document.getElementById('import-btn');
btn.disabled = true;
document.getElementById('progress-wrap').classList.add('visible');
document.getElementById('result-box').classList.remove('visible');
// Filter out rows the user chose to skip (duplicates)
const activeFiles = selectedFiles.filter((_, i) => !parsedRows[i]?._skip);
const activeRows = parsedRows.filter(r => !r._skip);
const skippedAsDup = selectedFiles
.filter((f, i) => parsedRows[i]?._skip)
.map(f => ({ file: f.name, reason: 'Duplicate skipped' }));
const total = activeFiles.length;
let done = 0;
const allImported = [];
const allSkipped = [...skippedAsDup];
if (total === 0) {
document.getElementById('progress-status').textContent = 'Done.';
showResult(0, allSkipped);
btn.disabled = false;
return;
}
for (let i = 0; i < activeFiles.length; i += BATCH_SIZE) {
const batchFiles = activeFiles.slice(i, i + BATCH_SIZE);
const batchRows = activeRows.slice(i, i + BATCH_SIZE);
const fd = new FormData();
batchFiles.forEach(f => fd.append('files', f));
fd.append('rows', JSON.stringify(batchRows));
fd.append('shared', JSON.stringify(shared));
try {
const resp = await fetch('/library/bulk-import', { method: 'POST', body: fd });
const data = await resp.json();
if (data.imported) allImported.push(...data.imported);
if (data.skipped) allSkipped.push(...data.skipped);
} catch (e) {
batchFiles.forEach(f => allSkipped.push({ file: f.name, reason: 'Network error' }));
}
done = Math.min(i + BATCH_SIZE, total);
const pct = Math.round((done / total) * 100);
document.getElementById('progress-bar').style.width = pct + '%';
document.getElementById('progress-status').textContent =
`${done} / ${total} files processed…`;
}
// Done
document.getElementById('progress-status').textContent = 'Done.';
showResult(allImported.length, allSkipped);
btn.disabled = false;
}
function showResult(imported, skipped) {
const box = document.getElementById('result-box');
document.getElementById('result-ok').textContent =
`${imported} file${imported !== 1 ? 's' : ''} imported.`;
const skippedEl = document.getElementById('result-skipped');
if (skipped.length) {
let html = `<div style="font-family:var(--mono);font-size:0.75rem;color:var(--text-dim);margin-bottom:0.4rem;">${skipped.length} skipped:</div>`;
html += '<div class="skipped-list">';
skipped.forEach(s => {
html += `<div class="skipped-item"><span class="sk-file" title="${esc(s.file)}">${esc(s.file)}</span><span>${esc(s.reason)}</span></div>`;
});
html += '</div>';
skippedEl.innerHTML = html;
} else {
skippedEl.innerHTML = '';
}
box.classList.add('visible');
}
function goToLibrary() {
window.location.href = '/library#new';
}
// ── TextSuggest ────────────────────────────────────────────────────────────
class TextSuggest {
constructor(inputId, dropdownId, onSelect) {
this.input = document.getElementById(inputId);
this.dropdown = document.getElementById(dropdownId);
this.all = [];
this.ddIndex = -1;
this.onSelect = onSelect || (() => {});
this.input.addEventListener('input', () => this._onInput());
this.input.addEventListener('keydown', (e) => this._onKeydown(e));
this.input.addEventListener('blur', () => setTimeout(() => this._hide(), 150));
}
setSuggestions(all) { this.all = all; }
_show(items) {
if (!items.length) { this._hide(); return; }
this.dropdown.innerHTML = items.map(v =>
`<div class="suggest-option" data-val="${v.replace(/"/g,'&quot;')}">${v}</div>`
).join('');
this.dropdown.querySelectorAll('.suggest-option').forEach(el => {
el.onmousedown = (e) => { e.preventDefault(); this.input.value = el.dataset.val; this._hide(); this.onSelect(); };
});
this.dropdown.style.display = 'block';
this.ddIndex = -1;
}
_hide() { this.dropdown.style.display = 'none'; this.ddIndex = -1; }
_onInput() {
const q = this.input.value.trim().toLowerCase();
if (!q) { this._hide(); return; }
this._show(this.all.filter(v => v.toLowerCase().includes(q)).slice(0, 40));
}
_onKeydown(e) {
const opts = this.dropdown.querySelectorAll('.suggest-option');
if (e.key === 'ArrowDown') {
e.preventDefault();
this.ddIndex = Math.min(this.ddIndex + 1, opts.length - 1);
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.ddIndex = Math.max(this.ddIndex - 1, -1);
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
} else if (e.key === 'Enter' && this.ddIndex >= 0 && opts[this.ddIndex]) {
e.preventDefault();
this.input.value = opts[this.ddIndex].dataset.val;
this._hide();
this.onSelect();
} else if (e.key === 'Escape') {
this._hide();
}
}
}
const authorSuggest = new TextSuggest('shared-author', 'author-dropdown', updatePreview);
const publisherSuggest = new TextSuggest('shared-publisher', 'publisher-dropdown', updatePreview);
const seriesSuggest = new TextSuggest('shared-series', 'series-dropdown', updatePreview);
async function loadSuggestions() {
const [authors, publishers, series] = await Promise.all([
fetch('/api/suggestions?type=author').then(r => r.json()),
fetch('/api/suggestions?type=publisher').then(r => r.json()),
fetch('/api/suggestions?type=series').then(r => r.json()),
]);
authorSuggest.setSuggestions(authors);
publisherSuggest.setSuggestions(publishers);
seriesSuggest.setSuggestions(series);
}
// ── Init ───────────────────────────────────────────────────────────────────
initChips();
renderPatternPreview();
loadSuggestions();
</script>
</body>
</html>