novela/containers/novela/templates/grabber.html
Ivo Oskamp e4d2e2c636 DB-stored books, full-text search, backup restore, and AO3 scraper
- DB-stored books (Fase 1–6): chapters and images stored in PostgreSQL; grabber writes to DB, EPUB→DB conversion, DB→EPUB export, FTS search page (/search)
- Chapter editor: Monaco editor supports DB-stored books; inline title editing
- Grabber: DB/EPUB storage toggle on Convert page
- Backup: restore from Dropbox snapshot (browse snapshots, restore individual or selected files)
- AO3 scraper: initial implementation
- Changelog: v0.1.2 and v0.1.3 entries added to changelog.py and changelog.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:13:08 +02:00

530 lines
20 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</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 content ── */
.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: 620px; margin-bottom: 1.5rem;
}
.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-dim); margin-bottom: 0.4rem; letter-spacing: 0.04em;
}
input[type="url"] {
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.65rem 0.85rem; outline: none;
transition: border-color 0.15s; margin-bottom: 1rem;
}
input[type="url"]:focus { border-color: var(--accent); }
button {
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
width: 100%; padding: 0.85rem; 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.05em;
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; }
.cred-status {
font-family: var(--mono); font-size: 0.75rem;
margin-top: -0.6rem; margin-bottom: 1rem;
padding: 0.4rem 0.7rem; border-radius: var(--radius); display: none;
}
.cred-status.found {
display: block; color: var(--success);
background: rgba(107,170,107,0.08); border: 1px solid rgba(107,170,107,0.2);
}
.cred-status.missing {
display: block; color: var(--text-faint);
background: var(--surface2); border: 1px solid var(--border);
}
.spinner {
display: none; width: 14px; height: 14px;
border: 2px solid rgba(15,14,12,0.3); border-top-color: #0f0e0c;
border-radius: 50%; animation: spin 0.7s linear infinite; flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Metadata preview card */
#meta-card { display: none; }
#meta-card.visible { display: block; }
.meta-row {
display: flex; gap: 0.75rem; margin-bottom: 0.5rem;
font-family: var(--mono); font-size: 0.82rem;
}
.meta-label { color: var(--text-faint); min-width: 7rem; flex-shrink: 0; }
.meta-value { color: var(--text); }
.description-text {
font-size: 0.85rem; color: var(--text-dim); line-height: 1.7;
max-height: 160px; overflow-y: auto; margin-bottom: 1rem;
padding-right: 0.25rem;
}
.description-text::-webkit-scrollbar { width: 4px; }
.description-text::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.divider {
border: none; border-top: 1px solid var(--border);
margin: 1.25rem 0;
}
/* Cover upload */
.cover-upload-area {
border: 1px dashed var(--border); border-radius: var(--radius);
padding: 1.25rem; text-align: center; margin-bottom: 1.25rem;
cursor: pointer; transition: border-color 0.15s;
position: relative;
}
.cover-upload-area:hover { border-color: var(--accent); }
.cover-upload-area input[type="file"] {
position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%;
}
.cover-upload-label {
font-family: var(--mono); font-size: 0.78rem; color: var(--text-dim);
pointer-events: none;
}
.cover-upload-label span { color: var(--accent); }
.cover-preview {
display: none; max-height: 180px; max-width: 120px;
border-radius: var(--radius); margin: 0 auto 0.6rem;
object-fit: contain;
}
.cover-preview.visible { display: block; }
.cover-filename {
font-family: var(--mono); font-size: 0.72rem; color: var(--success);
margin-top: 0.4rem; display: none;
}
.cover-filename.visible { display: block; }
/* Progress */
#progress-card { display: none; }
#progress-card.visible { display: block; }
.status-line {
font-family: var(--mono); font-size: 0.8rem;
color: var(--text-dim); margin-bottom: 1rem; min-height: 1.2em;
}
.progress-bar-wrap {
background: var(--bg); border: 1px solid var(--border);
border-radius: 100px; height: 6px; margin-bottom: 1.5rem; overflow: hidden;
}
.progress-bar {
height: 100%; background: var(--accent); border-radius: 100px;
width: 0%; transition: width 0.3s ease;
}
.chapter-list {
list-style: none; max-height: 260px; overflow-y: auto;
border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg);
}
.chapter-list::-webkit-scrollbar { width: 4px; }
.chapter-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.chapter-item {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.5rem 0.75rem; font-family: var(--mono); font-size: 0.75rem;
color: var(--text-faint); border-bottom: 1px solid var(--border); transition: color 0.2s;
}
.chapter-item:last-child { border-bottom: none; }
.chapter-item.done { color: var(--success); }
.chapter-item.active { color: var(--accent2); }
.chapter-item .dot {
width: 6px; height: 6px; border-radius: 50%;
background: currentColor; flex-shrink: 0;
}
.chapter-item.active .dot { animation: pulse 1s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.log-lines {
margin-top: 1rem; font-family: var(--mono); font-size: 0.72rem;
color: var(--text-faint); line-height: 1.8;
max-height: 120px; overflow-y: auto;
}
.log-lines .warn { color: var(--warning); }
.log-lines .err { color: var(--error); }
/* Result */
#result-card { display: none; }
#result-card.visible { display: block; }
.result-meta {
font-size: 0.85rem; color: var(--text-dim);
margin-bottom: 1.25rem; line-height: 1.8;
}
.result-meta strong { color: var(--text); font-weight: 400; }
.download-btn { background: var(--success); }
.download-btn:hover { background: #82c082; }
.result-actions { display: flex; gap: 0.75rem; }
.result-actions button { width: auto; flex: 1; }
.btn-outline {
background: var(--surface2); color: var(--text-dim);
border: 1px solid var(--border);
}
.btn-outline:hover { background: var(--surface); color: var(--text); border-color: var(--text-faint); }
.storage-toggle {
display: flex; align-items: center; gap: 0.5rem;
margin-bottom: 1rem;
}
.storage-label {
font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim);
}
.storage-opt {
width: auto; padding: 0.3rem 0.75rem;
background: var(--surface2); color: var(--text-dim);
border: 1px solid var(--border); font-size: 0.75rem;
}
.storage-opt:first-of-type { border-radius: var(--radius) 0 0 var(--radius); }
.storage-opt:last-of-type { border-radius: 0 var(--radius) var(--radius) 0; }
.storage-opt.active { background: var(--accent); color: #0f0e0c; border-color: var(--accent); }
.storage-opt:hover:not(.active) { background: var(--surface); color: var(--text); }
.dup-warning {
display: none; width: 100%; max-width: 620px; margin-bottom: 1.5rem;
background: rgba(200,160,58,0.08); border: 1px solid rgba(200,160,58,0.35);
border-radius: var(--radius); padding: 0.85rem 1rem;
font-family: var(--mono); font-size: 0.78rem; color: var(--warning);
line-height: 1.6;
}
.dup-warning.visible { display: block; }
.dup-warning a { color: var(--accent2); text-decoration: none; }
.dup-warning a:hover { text-decoration: underline; }
</style>
</head>
<body>
{% include "_sidebar.html" %}
<main class="main">
<!-- Step 1: URL input -->
<div class="card">
<div class="card-title">Book URL</div>
<label for="url">Story overview page</label>
<input type="url" id="url" placeholder="https://..." oninput="checkUrlCredentials(); clearDupWarning()"/>
<div class="cred-status" id="cred-status"></div>
<button id="load-btn" onclick="loadMeta()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
</svg>
<span id="load-label">Load metadata</span>
<div class="spinner" id="load-spinner"></div>
</button>
</div>
<!-- Duplicate warning -->
<div class="dup-warning" id="dup-warning"></div>
<!-- Step 2: Metadata preview + cover upload + Convert -->
<div class="card" id="meta-card">
<div class="card-title">Book info</div>
<div id="meta-rows"></div>
<hr class="divider"/>
<label>Cover image</label>
<div class="cover-upload-area" id="cover-upload-area">
<input type="file" id="cover-file" accept="image/*" onchange="onCoverSelected()"/>
<img class="cover-preview" id="cover-preview" src="" alt="cover preview"/>
<div class="cover-upload-label">
<span id="cover-upload-text">Click to select a cover image</span>
</div>
<div class="cover-filename" id="cover-filename"></div>
</div>
<div class="storage-toggle">
<span class="storage-label">Save as</span>
<button type="button" class="storage-opt active" id="opt-db" onclick="setStorage('db')">DB</button>
<button type="button" class="storage-opt" id="opt-epub" onclick="setStorage('epub')">EPUB file</button>
</div>
<button id="convert-btn" onclick="startConvert()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
<span id="convert-label">Convert</span>
<div class="spinner" id="convert-spinner"></div>
</button>
</div>
<!-- Progress -->
<div class="card" id="progress-card">
<div class="card-title">Progress</div>
<div class="status-line" id="status-line">Connecting...</div>
<div class="progress-bar-wrap">
<div class="progress-bar" id="progress-bar"></div>
</div>
<ul class="chapter-list" id="chapter-list"></ul>
<div class="log-lines" id="log-lines"></div>
</div>
<!-- Result -->
<div class="card" id="result-card">
<div class="card-title">Done</div>
<div class="result-meta" id="result-meta"></div>
<div class="result-actions">
<button class="download-btn" id="download-btn">
<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-4M7 10l5 5 5-5M12 15V3"/>
</svg>
<span>Download EPUB</span>
</button>
<button class="btn-outline" id="book-detail-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/>
</svg>
Go to book
</button>
</div>
</div>
</main>
<script src="/static/books.js"></script>
<script src="/static/conversion.js"></script>
<script>
let currentUrl = '';
let coverB64 = null;
let storageMode = 'db';
function setStorage(mode) {
storageMode = mode;
document.getElementById('opt-db').classList.toggle('active', mode === 'db');
document.getElementById('opt-epub').classList.toggle('active', mode === 'epub');
}
// --- Credential status ---
async function checkUrlCredentials() {
const url = document.getElementById('url').value.trim();
const el = document.getElementById('cred-status');
if (!url) { el.className = 'cred-status'; return; }
try {
const domain = new URL(url).hostname.replace(/^www\./, '');
const r = await fetch('/credentials');
const creds = (await r.json())[domain];
if (creds !== undefined) {
el.className = 'cred-status found';
el.textContent = `✓ Credentials available for ${domain}${creds.username ? ' (' + creds.username + ')' : ''}`;
} else {
el.className = 'cred-status missing';
el.textContent = `No credentials configured for ${domain}`;
}
} catch (e) { el.className = 'cred-status'; }
}
checkUrlCredentials();
// --- Step 1: Load metadata ---
async function loadMeta() {
const url = document.getElementById('url').value.trim();
if (!url) { alert('Please enter a URL.'); return; }
currentUrl = url;
coverB64 = null;
setLoading(true);
document.getElementById('meta-card').classList.remove('visible');
document.getElementById('progress-card').classList.remove('visible');
document.getElementById('result-card').classList.remove('visible');
try {
const resp = await fetch('/preload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const d = await resp.json();
if (d.error) {
alert('Error loading metadata:\n' + d.error);
setLoading(false);
return;
}
renderMeta(d);
showDupWarning(d.already_exists ? d.existing_books : []);
document.getElementById('meta-card').classList.add('visible');
// Reset cover upload
document.getElementById('cover-file').value = '';
document.getElementById('cover-preview').classList.remove('visible');
document.getElementById('cover-filename').classList.remove('visible');
document.getElementById('cover-upload-text').textContent = 'Click to select a cover image';
} catch (e) {
alert('Failed to load metadata: ' + e);
}
setLoading(false);
}
function setLoading(on) {
document.getElementById('load-btn').disabled = on;
document.getElementById('load-label').textContent = on ? 'Loading...' : 'Load metadata';
document.getElementById('load-spinner').style.display = on ? 'block' : 'none';
}
function metaRow(label, value) {
return `<div class="meta-row">
<span class="meta-label">${label}</span>
<span class="meta-value">${esc(value)}</span>
</div>`;
}
function renderMeta(d) {
let html = '';
html += metaRow('Title', d.title);
html += metaRow('Author', d.author);
if (d.publisher) html += metaRow('Publisher', d.publisher);
if (d.series) html += metaRow('Series', d.series);
if (d.series) {
html += `<div class="meta-row">
<span class="meta-label">Series index</span>
<span class="meta-value"><input type="number" id="series-index-input" min="1" value="${d.series_index_next}" style="width:5rem;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-family:var(--mono);font-size:0.82rem;padding:0.25rem 0.5rem;"/></span>
</div>`;
}
if (d.genres && d.genres.length) html += metaRow('Genres', d.genres.join(', '));
if (d.subgenres && d.subgenres.length) html += metaRow('Sub-genres', d.subgenres.join(', '));
if (d.tags && d.tags.length) html += metaRow('Tags', d.tags.join(', '));
html += `<div class="meta-row">
<span class="meta-label">Updated</span>
<span class="meta-value"><input type="date" id="updated-date-input" value="${d.updated_date || ''}"
style="background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);
color:var(--text);font-family:var(--mono);font-size:0.82rem;
padding:0.25rem 0.5rem;color-scheme:dark;"/></span>
</div>`;
if (d.publication_status) html += metaRow('Status', d.publication_status);
if (d.description) {
const paras = d.description.split('\n\n')
.map(p => `<p style="margin:.4rem 0">${esc(p.trim())}</p>`).join('');
html += `<div class="meta-row"><span class="meta-label">Description</span>
<div class="description-text">${paras}</div></div>`;
}
html += metaRow('Filename', d.filename);
document.getElementById('meta-rows').innerHTML = html;
}
// --- Cover upload ---
function onCoverSelected() {
const file = document.getElementById('cover-file').files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
const dataUrl = e.target.result;
coverB64 = dataUrl.split(',')[1]; // strip "data:image/...;base64,"
const preview = document.getElementById('cover-preview');
preview.src = dataUrl;
preview.classList.add('visible');
document.getElementById('cover-upload-text').textContent = 'Click to replace cover';
document.getElementById('cover-filename').textContent = file.name;
document.getElementById('cover-filename').classList.add('visible');
};
reader.readAsDataURL(file);
}
// --- Step 2: Convert ---
async function startConvert() {
if (!currentUrl) return;
document.getElementById('convert-btn').disabled = true;
document.getElementById('convert-label').textContent = 'Starting...';
document.getElementById('convert-spinner').style.display = 'block';
document.getElementById('progress-card').classList.add('visible');
document.getElementById('result-card').classList.remove('visible');
document.getElementById('chapter-list').innerHTML = '';
document.getElementById('log-lines').innerHTML = '';
document.getElementById('progress-bar').style.width = '0%';
const body = { url: currentUrl, storage_mode: storageMode };
if (coverB64) body.cover_b64 = coverB64;
const seriesInput = document.getElementById('series-index-input');
if (seriesInput) body.series_index = parseInt(seriesInput.value) || 1;
const dateInput = document.getElementById('updated-date-input');
if (dateInput) body.updated_date = dateInput.value || '';
const resp = await fetch('/convert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const { job_id } = await resp.json();
document.getElementById('convert-label').textContent = 'Convert';
document.getElementById('convert-spinner').style.display = 'none';
connectConversionStream(job_id);
}
function clearDupWarning() {
const el = document.getElementById('dup-warning');
el.classList.remove('visible');
el.innerHTML = '';
}
function showDupWarning(books) {
const el = document.getElementById('dup-warning');
if (!books || !books.length) { clearDupWarning(); return; }
const links = books.map(b =>
`<a href="/library/book/${encodeURIComponent(b.filename)}" target="_blank">${esc(b.title)}</a>`
).join(', ');
el.innerHTML = `⚠ This title already exists in your library: ${links}. You can still proceed with the conversion.`;
el.classList.add('visible');
}
</script>
</body>
</html>