962 lines
39 KiB
HTML
962 lines
39 KiB
HTML
<!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 — <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 — <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 += ` <span class="cnt-warn">${skipCount} skipped</span>`;
|
||
if (warnCount) stats += ` <span class="cnt-warn">${warnCount} to check</span>`;
|
||
if (dupCount) stats += ` <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,'"')}">${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>
|