- 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>
530 lines
20 KiB
HTML
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>
|