- 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>
482 lines
16 KiB
JavaScript
482 lines
16 KiB
JavaScript
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
|
|
|
|
const { filename, is_db } = EDITOR;
|
|
|
|
let editor = null;
|
|
let chapters = [];
|
|
let currentIndex = -1;
|
|
let dirty = new Set(); // indices with unsaved changes
|
|
let pendingContent = new Map(); // index -> modified content not yet saved
|
|
let pendingTitles = new Map(); // index -> modified title not yet saved (DB only)
|
|
let loadingChapter = false; // suppress dirty events during setValue
|
|
let saving = false;
|
|
|
|
// ── Init Monaco ───────────────────────────────────────────────────────────────
|
|
|
|
require(['vs/editor/editor.main'], function () {
|
|
if (is_db) {
|
|
document.getElementById('header-chapter').style.display = 'none';
|
|
document.getElementById('chapter-title-input').style.display = '';
|
|
}
|
|
|
|
editor = monaco.editor.create(document.getElementById('editor-pane'), {
|
|
language: is_db ? 'html' : 'xml',
|
|
theme: 'vs-dark',
|
|
wordWrap: 'on',
|
|
minimap: { enabled: true },
|
|
fontSize: 13,
|
|
fontFamily: "'DM Mono', monospace",
|
|
lineNumbers: 'on',
|
|
scrollBeyondLastLine: false,
|
|
automaticLayout: true,
|
|
});
|
|
|
|
editor.onDidChangeModelContent(() => {
|
|
if (loadingChapter) return;
|
|
if (currentIndex >= 0) {
|
|
dirty.add(currentIndex);
|
|
renderChapterList();
|
|
setStatus('dirty', 'Unsaved changes');
|
|
document.getElementById('btn-save').disabled = false;
|
|
updateSaveAll();
|
|
}
|
|
});
|
|
|
|
// Ctrl+S / Cmd+S
|
|
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveChapter);
|
|
|
|
if (is_db) {
|
|
document.getElementById('chapter-title-input').addEventListener('input', () => {
|
|
if (currentIndex >= 0) {
|
|
pendingTitles.set(currentIndex, document.getElementById('chapter-title-input').value);
|
|
dirty.add(currentIndex);
|
|
renderChapterList();
|
|
setStatus('dirty', 'Unsaved changes');
|
|
document.getElementById('btn-save').disabled = false;
|
|
updateSaveAll();
|
|
}
|
|
});
|
|
}
|
|
|
|
loadChapterList();
|
|
});
|
|
|
|
// ── Chapter list ──────────────────────────────────────────────────────────────
|
|
|
|
async function loadChapterList(targetIndex = 0) {
|
|
const resp = await fetch(`/library/chapters/${encodeURIComponent(filename)}`);
|
|
if (!resp.ok) {
|
|
setStatus('error', 'Failed to load chapters');
|
|
return;
|
|
}
|
|
chapters = await resp.json();
|
|
if (!Array.isArray(chapters)) chapters = [];
|
|
|
|
if (chapters.length === 0) {
|
|
currentIndex = -1;
|
|
dirty.clear();
|
|
pendingContent.clear();
|
|
pendingTitles.clear();
|
|
renderChapterList();
|
|
document.getElementById('header-chapter').textContent = 'No chapters';
|
|
document.getElementById('btn-save').disabled = true;
|
|
document.getElementById('btn-break').disabled = true;
|
|
document.getElementById('btn-del-page').disabled = true;
|
|
if (editor) { loadingChapter = true; editor.setValue(''); loadingChapter = false; }
|
|
updateSaveAll();
|
|
return;
|
|
}
|
|
|
|
const next = Math.min(Math.max(targetIndex, 0), chapters.length - 1);
|
|
renderChapterList();
|
|
await loadChapter(next);
|
|
}
|
|
|
|
function renderChapterList() {
|
|
const el = document.getElementById('chapter-list');
|
|
el.innerHTML = '';
|
|
chapters.forEach((ch, i) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'chapter-item' + (i === currentIndex ? ' active' : '');
|
|
item.innerHTML =
|
|
(dirty.has(i) ? '<span class="dirty-dot"></span>' : '') +
|
|
`<span class="chapter-item-title">${esc(ch.title)}</span>`;
|
|
item.onclick = () => switchChapter(i);
|
|
el.appendChild(item);
|
|
});
|
|
}
|
|
|
|
// ── Load / switch ─────────────────────────────────────────────────────────────
|
|
|
|
async function switchChapter(index) {
|
|
if (index === currentIndex) return;
|
|
// Preserve current editor content in pending cache before switching (never lose changes)
|
|
if (dirty.has(currentIndex) && editor) {
|
|
pendingContent.set(currentIndex, editor.getValue());
|
|
}
|
|
// Preserve title input for DB books
|
|
if (is_db && currentIndex >= 0) {
|
|
const inp = document.getElementById('chapter-title-input');
|
|
if (inp) pendingTitles.set(currentIndex, inp.value);
|
|
}
|
|
loadChapter(index);
|
|
}
|
|
|
|
async function loadChapter(index) {
|
|
setStatus('', '');
|
|
document.getElementById('btn-save').disabled = true;
|
|
document.getElementById('btn-break').disabled = true;
|
|
document.getElementById('btn-del-page').disabled = true;
|
|
if (!is_db) document.getElementById('header-chapter').textContent = 'Loading…';
|
|
|
|
let content, title;
|
|
|
|
if (pendingContent.has(index)) {
|
|
content = pendingContent.get(index);
|
|
title = pendingTitles.has(index) ? pendingTitles.get(index) : (chapters[index]?.title ?? '');
|
|
} else {
|
|
const resp = await fetch(`/api/edit/chapter/${index}/${encodeURIComponent(filename)}`);
|
|
if (!resp.ok) { setStatus('error', 'Load failed'); return; }
|
|
const data = await resp.json();
|
|
content = data.content;
|
|
title = pendingTitles.has(index) ? pendingTitles.get(index) : data.title;
|
|
}
|
|
|
|
currentIndex = index;
|
|
|
|
loadingChapter = true;
|
|
editor.setValue(content);
|
|
editor.setScrollTop(0);
|
|
loadingChapter = false;
|
|
editor.focus();
|
|
|
|
// Restore dirty state based on whether we loaded from pending cache
|
|
if (dirty.has(index)) {
|
|
document.getElementById('btn-save').disabled = false;
|
|
setStatus('dirty', 'Unsaved changes');
|
|
} else {
|
|
document.getElementById('btn-save').disabled = true;
|
|
setStatus('', '');
|
|
}
|
|
|
|
renderChapterList();
|
|
if (is_db) {
|
|
document.getElementById('chapter-title-input').value = title;
|
|
} else {
|
|
document.getElementById('header-chapter').textContent = title;
|
|
}
|
|
document.getElementById('btn-break').disabled = false;
|
|
document.getElementById('btn-del-page').disabled = chapters.length <= 1;
|
|
updateSaveAll();
|
|
}
|
|
|
|
// ── Save (current chapter) ────────────────────────────────────────────────────
|
|
|
|
async function saveChapter() {
|
|
if (currentIndex < 0 || saving) return;
|
|
saving = true;
|
|
document.getElementById('btn-save').disabled = true;
|
|
setStatus('saving', 'Saving…');
|
|
|
|
try {
|
|
const saveBody = { content: editor.getValue() };
|
|
if (is_db) {
|
|
const inp = document.getElementById('chapter-title-input');
|
|
saveBody.title = inp ? inp.value.trim() : (pendingTitles.get(currentIndex) || '');
|
|
}
|
|
const resp = await fetch(
|
|
`/api/edit/chapter/${currentIndex}/${encodeURIComponent(filename)}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(saveBody),
|
|
}
|
|
);
|
|
const data = await resp.json();
|
|
if (data.ok) {
|
|
dirty.delete(currentIndex);
|
|
pendingContent.delete(currentIndex);
|
|
if (is_db && chapters[currentIndex]) {
|
|
const saved = pendingTitles.get(currentIndex) || chapters[currentIndex].title;
|
|
chapters[currentIndex].title = saved || chapters[currentIndex].title;
|
|
pendingTitles.delete(currentIndex);
|
|
}
|
|
renderChapterList();
|
|
setStatus('saved', 'Saved');
|
|
setTimeout(() => setStatus('', ''), 2000);
|
|
updateSaveAll();
|
|
} else {
|
|
setStatus('error', data.error || 'Save failed');
|
|
document.getElementById('btn-save').disabled = false;
|
|
}
|
|
} catch {
|
|
setStatus('error', 'Save failed');
|
|
document.getElementById('btn-save').disabled = false;
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
// ── Save all pending ──────────────────────────────────────────────────────────
|
|
|
|
async function saveAllChapters() {
|
|
if (saving) return;
|
|
saving = true;
|
|
const btn = document.getElementById('btn-save-all');
|
|
if (btn) btn.disabled = true;
|
|
setStatus('saving', 'Saving all…');
|
|
|
|
// Flush current editor content and title into pending caches first
|
|
if (currentIndex >= 0 && dirty.has(currentIndex)) {
|
|
pendingContent.set(currentIndex, editor.getValue());
|
|
if (is_db) {
|
|
const inp = document.getElementById('chapter-title-input');
|
|
if (inp) pendingTitles.set(currentIndex, inp.value);
|
|
}
|
|
}
|
|
|
|
const indices = [...dirty];
|
|
for (const i of indices) {
|
|
const content = pendingContent.has(i)
|
|
? pendingContent.get(i)
|
|
: (i === currentIndex ? editor.getValue() : null);
|
|
// For DB books, a title-only change has no pendingContent — still need to save
|
|
const hasTitleChange = is_db && pendingTitles.has(i);
|
|
if (!content && !hasTitleChange) continue;
|
|
|
|
try {
|
|
const saveBody = { content: content || '' };
|
|
if (is_db) saveBody.title = pendingTitles.has(i) ? pendingTitles.get(i) : (chapters[i]?.title || '');
|
|
const resp = await fetch(
|
|
`/api/edit/chapter/${i}/${encodeURIComponent(filename)}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(saveBody),
|
|
}
|
|
);
|
|
const data = await resp.json();
|
|
if (data.ok) {
|
|
dirty.delete(i);
|
|
pendingContent.delete(i);
|
|
if (is_db && chapters[i]) {
|
|
chapters[i].title = pendingTitles.get(i) || chapters[i].title;
|
|
pendingTitles.delete(i);
|
|
}
|
|
}
|
|
} catch {
|
|
setStatus('error', `Save failed on chapter ${i + 1}`);
|
|
saving = false;
|
|
updateSaveAll();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Reload current chapter display to reflect saved state
|
|
if (currentIndex >= 0) {
|
|
loadingChapter = true;
|
|
editor.setValue(editor.getValue()); // no-op, just clears dirty for display
|
|
loadingChapter = false;
|
|
document.getElementById('btn-save').disabled = true;
|
|
}
|
|
|
|
renderChapterList();
|
|
setStatus('saved', 'All saved');
|
|
setTimeout(() => setStatus('', ''), 2000);
|
|
saving = false;
|
|
updateSaveAll();
|
|
}
|
|
|
|
function updateSaveAll() {
|
|
const btn = document.getElementById('btn-save-all');
|
|
if (!btn) return;
|
|
const count = dirty.size;
|
|
if (count > 1) {
|
|
btn.style.display = 'flex';
|
|
btn.textContent = `Save all (${count})`;
|
|
} else {
|
|
btn.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// ── Insert break ──────────────────────────────────────────────────────────────
|
|
|
|
function insertBreak() {
|
|
if (!editor || currentIndex < 0) return;
|
|
const breakSrc = is_db ? '/static/break.png' : '../Images/break.png';
|
|
const pos = editor.getPosition();
|
|
editor.executeEdits('insert-break', [{
|
|
range: new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column),
|
|
text: `\n<center><img src="${breakSrc}" style="height:15px;"/></center>\n`,
|
|
forceMoveMarkers: true,
|
|
}]);
|
|
editor.focus();
|
|
}
|
|
|
|
|
|
|
|
// ── Add / delete chapter ─────────────────────────────────────────────────────
|
|
|
|
async function addChapter() {
|
|
if (saving) return;
|
|
if (dirty.size > 0) {
|
|
alert('Save pending changes before adding a page.');
|
|
return;
|
|
}
|
|
const title = prompt('Title for new page:', `New chapter ${Math.max(chapters.length + 1, 1)}`);
|
|
if (title === null) return;
|
|
|
|
const resp = await fetch(`/api/edit/chapter/add/${encodeURIComponent(filename)}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title, after_index: currentIndex }),
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.ok) {
|
|
setStatus('error', data.error || 'Add page failed');
|
|
return;
|
|
}
|
|
|
|
dirty.clear();
|
|
pendingContent.clear();
|
|
pendingTitles.clear();
|
|
await loadChapterList(data.index ?? Math.max(currentIndex + 1, 0));
|
|
setStatus('saved', 'Page added');
|
|
setTimeout(() => setStatus('', ''), 1500);
|
|
}
|
|
|
|
async function deleteChapter() {
|
|
if (saving || currentIndex < 0) return;
|
|
if (chapters.length <= 1) {
|
|
alert('Cannot delete the last page.');
|
|
return;
|
|
}
|
|
if (dirty.size > 0) {
|
|
alert('Save pending changes before deleting a page.');
|
|
return;
|
|
}
|
|
const chTitle = chapters[currentIndex]?.title || `chapter ${currentIndex + 1}`;
|
|
if (!confirm(`Delete page "${chTitle}"?`)) return;
|
|
|
|
const resp = await fetch(`/api/edit/chapter/${currentIndex}/${encodeURIComponent(filename)}`, {
|
|
method: 'DELETE',
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.ok) {
|
|
setStatus('error', data.error || 'Delete page failed');
|
|
return;
|
|
}
|
|
|
|
dirty.clear();
|
|
pendingContent.clear();
|
|
pendingTitles.clear();
|
|
await loadChapterList(data.index ?? Math.max(currentIndex - 1, 0));
|
|
setStatus('saved', 'Page deleted');
|
|
setTimeout(() => setStatus('', ''), 1500);
|
|
}
|
|
|
|
// ── Find & Replace all chapters ───────────────────────────────────────────────
|
|
|
|
function openReplaceModal() {
|
|
document.getElementById('replace-modal').classList.add('open');
|
|
document.getElementById('rp-search').focus();
|
|
document.getElementById('rp-progress').textContent = '';
|
|
document.getElementById('rp-progress').className = 'modal-progress';
|
|
document.getElementById('rp-run').disabled = false;
|
|
}
|
|
|
|
function closeReplaceModal() {
|
|
document.getElementById('replace-modal').classList.remove('open');
|
|
}
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') closeReplaceModal();
|
|
});
|
|
|
|
async function replaceInAllChapters() {
|
|
const searchVal = document.getElementById('rp-search').value;
|
|
if (!searchVal) return;
|
|
const replaceVal = document.getElementById('rp-replace').value;
|
|
const useRegex = document.getElementById('rp-regex').checked;
|
|
const caseSens = document.getElementById('rp-case').checked;
|
|
|
|
const runBtn = document.getElementById('rp-run');
|
|
const prog = document.getElementById('rp-progress');
|
|
runBtn.disabled = true;
|
|
|
|
let pattern;
|
|
try {
|
|
pattern = useRegex
|
|
? new RegExp(searchVal, caseSens ? 'g' : 'gi')
|
|
: new RegExp(searchVal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), caseSens ? 'g' : 'gi');
|
|
} catch (e) {
|
|
prog.className = 'modal-progress error';
|
|
prog.textContent = 'Invalid regex: ' + e.message;
|
|
runBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
let totalOccurrences = 0;
|
|
let chaptersChanged = 0;
|
|
|
|
// Flush current editor content into pending before we start
|
|
if (currentIndex >= 0) {
|
|
pendingContent.set(currentIndex, editor.getValue());
|
|
}
|
|
|
|
for (let i = 0; i < chapters.length; i++) {
|
|
prog.className = 'modal-progress';
|
|
prog.textContent = `Checking chapter ${i + 1} / ${chapters.length}…`;
|
|
|
|
let original;
|
|
if (pendingContent.has(i)) {
|
|
original = pendingContent.get(i);
|
|
} else {
|
|
try {
|
|
const resp = await fetch(`/api/edit/chapter/${i}/${encodeURIComponent(filename)}`);
|
|
if (!resp.ok) continue;
|
|
const data = await resp.json();
|
|
original = data.content;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Count occurrences
|
|
let count = 0;
|
|
const updated = original.replace(pattern, m => { count++; return replaceVal; });
|
|
if (count === 0) continue;
|
|
|
|
totalOccurrences += count;
|
|
chaptersChanged++;
|
|
pendingContent.set(i, updated);
|
|
dirty.add(i);
|
|
}
|
|
|
|
// Reload current chapter from pending cache if it was changed
|
|
if (dirty.has(currentIndex) && pendingContent.has(currentIndex)) {
|
|
loadingChapter = true;
|
|
editor.setValue(pendingContent.get(currentIndex));
|
|
loadingChapter = false;
|
|
document.getElementById('btn-save').disabled = false;
|
|
setStatus('dirty', 'Unsaved changes');
|
|
}
|
|
|
|
renderChapterList();
|
|
updateSaveAll();
|
|
|
|
prog.className = totalOccurrences > 0 ? 'modal-progress ok' : 'modal-progress';
|
|
prog.textContent = totalOccurrences > 0
|
|
? `${totalOccurrences} replacement${totalOccurrences !== 1 ? 's' : ''} in ${chaptersChanged} chapter${chaptersChanged !== 1 ? 's' : ''} — not saved yet.`
|
|
: 'No matches found.';
|
|
runBtn.disabled = false;
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function setStatus(cls, text) {
|
|
const el = document.getElementById('save-status');
|
|
el.className = 'save-status' + (cls ? ' ' + cls : '');
|
|
el.textContent = text;
|
|
}
|