Add a "Current chapter only" checkbox to the Find & Replace modal. When checked, search/replace runs against the open chapter instead of every chapter in the book. Default unchecked, preserving the existing all-chapters behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
700 lines
25 KiB
JavaScript
700 lines
25 KiB
JavaScript
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
|
|
|
|
const { filename, is_db } = EDITOR;
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
|
|
let editor = null;
|
|
let chapters = []; // [{title, href, _id, _new, _serverIndex}, ...]
|
|
let nextLocalId = 0;
|
|
let pendingDeletes = []; // [{_serverIndex, title}, ...] to be deleted on save
|
|
let currentIndex = -1; // index into chapters[]
|
|
let dirty = new Set(); // chapter _ids with unsaved content/title changes
|
|
let pendingContent = new Map(); // _id -> content string
|
|
let pendingTitles = new Map(); // _id -> title string (DB only)
|
|
let structureDirty = false; // pending adds or deletes not yet on server
|
|
let loadingChapter = false;
|
|
let saving = false;
|
|
|
|
function currentCh() { return currentIndex >= 0 ? chapters[currentIndex] : null; }
|
|
|
|
// ── Init Monaco ───────────────────────────────────────────────────────────────
|
|
|
|
require(['vs/editor/editor.main'], function () {
|
|
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;
|
|
const ch = currentCh();
|
|
if (ch) {
|
|
dirty.add(ch._id);
|
|
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);
|
|
|
|
document.getElementById('chapter-title-input').addEventListener('input', () => {
|
|
const ch = currentCh();
|
|
if (ch) {
|
|
pendingTitles.set(ch._id, document.getElementById('chapter-title-input').value);
|
|
dirty.add(ch._id);
|
|
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;
|
|
}
|
|
const raw = await resp.json();
|
|
chapters = Array.isArray(raw)
|
|
? raw.map((ch, i) => ({ ...ch, _id: nextLocalId++, _new: false, _serverIndex: i }))
|
|
: [];
|
|
pendingDeletes = [];
|
|
dirty.clear();
|
|
pendingContent.clear();
|
|
pendingTitles.clear();
|
|
structureDirty = false;
|
|
|
|
if (chapters.length === 0) {
|
|
currentIndex = -1;
|
|
renderChapterList();
|
|
document.getElementById('header-chapter').textContent = 'No chapters';
|
|
document.getElementById('btn-save').disabled = true;
|
|
document.getElementById('btn-break').disabled = true;
|
|
document.getElementById('btn-subheading').disabled = true;
|
|
document.getElementById('btn-chat').disabled = true;
|
|
document.getElementById('btn-indent').disabled = true;
|
|
document.getElementById('btn-comment').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' : '');
|
|
const dot = dirty.has(ch._id) ? '<span class="dirty-dot"></span>' : '';
|
|
item.innerHTML = dot + `<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;
|
|
// Flush current content/title to pending cache before switching
|
|
const ch = currentCh();
|
|
if (ch) {
|
|
if (dirty.has(ch._id) && editor) pendingContent.set(ch._id, editor.getValue());
|
|
const inp = document.getElementById('chapter-title-input');
|
|
if (inp) pendingTitles.set(ch._id, inp.value);
|
|
}
|
|
await loadChapter(index);
|
|
}
|
|
|
|
async function loadChapter(index) {
|
|
setStatus('', '');
|
|
document.getElementById('btn-save').disabled = true;
|
|
document.getElementById('btn-break').disabled = true;
|
|
document.getElementById('btn-subheading').disabled = true;
|
|
document.getElementById('btn-chat').disabled = true;
|
|
document.getElementById('btn-del-page').disabled = true;
|
|
if (!is_db) document.getElementById('header-chapter').textContent = 'Loading…';
|
|
|
|
const ch = chapters[index];
|
|
if (!ch) return;
|
|
|
|
let content, title;
|
|
|
|
if (pendingContent.has(ch._id)) {
|
|
content = pendingContent.get(ch._id);
|
|
title = pendingTitles.has(ch._id) ? pendingTitles.get(ch._id) : ch.title;
|
|
} else if (ch._new) {
|
|
// New chapter not yet on server — starts empty
|
|
content = '';
|
|
title = pendingTitles.has(ch._id) ? pendingTitles.get(ch._id) : ch.title;
|
|
} else {
|
|
const resp = await fetch(`/api/edit/chapter/${ch._serverIndex}/${encodeURIComponent(filename)}`);
|
|
if (!resp.ok) { setStatus('error', 'Load failed'); return; }
|
|
const data = await resp.json();
|
|
content = data.content;
|
|
title = pendingTitles.has(ch._id) ? pendingTitles.get(ch._id) : data.title;
|
|
}
|
|
|
|
currentIndex = index;
|
|
|
|
loadingChapter = true;
|
|
editor.setValue(content);
|
|
editor.setScrollTop(0);
|
|
loadingChapter = false;
|
|
editor.focus();
|
|
|
|
const hasChanges = dirty.has(ch._id) || structureDirty;
|
|
document.getElementById('btn-save').disabled = !hasChanges;
|
|
if (hasChanges) setStatus('dirty', 'Unsaved changes');
|
|
|
|
renderChapterList();
|
|
document.getElementById('chapter-title-input').value = title;
|
|
document.getElementById('btn-break').disabled = false;
|
|
document.getElementById('btn-subheading').disabled = false;
|
|
document.getElementById('btn-chat').disabled = false;
|
|
document.getElementById('btn-indent').disabled = false;
|
|
document.getElementById('btn-comment').disabled = false;
|
|
document.getElementById('btn-del-page').disabled = chapters.length <= 1;
|
|
updateSaveAll();
|
|
}
|
|
|
|
// ── Save (current chapter) ────────────────────────────────────────────────────
|
|
|
|
async function saveChapter() {
|
|
if (saving) return;
|
|
saving = true;
|
|
document.getElementById('btn-save').disabled = true;
|
|
|
|
// Apply structural changes (add/delete) before saving content
|
|
if (structureDirty) {
|
|
setStatus('saving', 'Applying changes…');
|
|
try {
|
|
await applyStructuralChanges();
|
|
} catch (e) {
|
|
setStatus('error', e.message || 'Failed to apply changes');
|
|
document.getElementById('btn-save').disabled = false;
|
|
saving = false;
|
|
return;
|
|
}
|
|
renderChapterList();
|
|
}
|
|
|
|
const ch = currentCh();
|
|
if (!ch || !dirty.has(ch._id)) {
|
|
// Structural changes saved, no content changes for this chapter
|
|
setStatus('saved', 'Saved');
|
|
setTimeout(() => setStatus('', ''), 2000);
|
|
saving = false;
|
|
updateSaveAll();
|
|
return;
|
|
}
|
|
|
|
setStatus('saving', 'Saving…');
|
|
try {
|
|
const saveBody = { content: editor.getValue() };
|
|
const inp = document.getElementById('chapter-title-input');
|
|
saveBody.title = inp ? inp.value.trim() : (pendingTitles.get(ch._id) || '');
|
|
const resp = await fetch(
|
|
`/api/edit/chapter/${ch._serverIndex}/${encodeURIComponent(filename)}`,
|
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(saveBody) }
|
|
);
|
|
const data = await resp.json();
|
|
if (data.ok) {
|
|
dirty.delete(ch._id);
|
|
pendingContent.delete(ch._id);
|
|
ch.title = pendingTitles.get(ch._id) || ch.title;
|
|
pendingTitles.delete(ch._id);
|
|
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
|
|
const ch = currentCh();
|
|
if (ch && dirty.has(ch._id)) {
|
|
pendingContent.set(ch._id, editor.getValue());
|
|
const inp = document.getElementById('chapter-title-input');
|
|
if (inp) pendingTitles.set(ch._id, inp.value);
|
|
}
|
|
|
|
// Apply structural changes first
|
|
if (structureDirty) {
|
|
setStatus('saving', 'Applying changes…');
|
|
try {
|
|
await applyStructuralChanges();
|
|
} catch (e) {
|
|
setStatus('error', e.message || 'Failed to apply changes');
|
|
saving = false;
|
|
updateSaveAll();
|
|
return;
|
|
}
|
|
renderChapterList();
|
|
}
|
|
|
|
const ids = [...dirty];
|
|
for (const id of ids) {
|
|
const chapter = chapters.find(c => c._id === id);
|
|
if (!chapter) { dirty.delete(id); continue; }
|
|
|
|
const content = pendingContent.has(id)
|
|
? pendingContent.get(id)
|
|
: (chapter._id === currentCh()?._id ? editor.getValue() : null);
|
|
const hasTitleChange = pendingTitles.has(id);
|
|
if (!content && !hasTitleChange) continue;
|
|
|
|
try {
|
|
const saveBody = { content: content || '' };
|
|
saveBody.title = pendingTitles.has(id) ? pendingTitles.get(id) : (chapter.title || '');
|
|
const resp = await fetch(
|
|
`/api/edit/chapter/${chapter._serverIndex}/${encodeURIComponent(filename)}`,
|
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(saveBody) }
|
|
);
|
|
const data = await resp.json();
|
|
if (data.ok) {
|
|
dirty.delete(id);
|
|
pendingContent.delete(id);
|
|
chapter.title = pendingTitles.get(id) || chapter.title;
|
|
pendingTitles.delete(id);
|
|
}
|
|
} catch {
|
|
setStatus('error', 'Save failed');
|
|
saving = false;
|
|
updateSaveAll();
|
|
return;
|
|
}
|
|
}
|
|
|
|
loadingChapter = true;
|
|
editor.setValue(editor.getValue()); // refresh 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})`;
|
|
btn.disabled = false;
|
|
} else {
|
|
btn.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// ── Apply structural changes (adds/deletes) ───────────────────────────────────
|
|
|
|
async function applyStructuralChanges() {
|
|
// Step 1: apply deletes in descending server-index order so earlier indices stay valid
|
|
const sorted = [...pendingDeletes].sort((a, b) => b._serverIndex - a._serverIndex);
|
|
for (const del of sorted) {
|
|
const resp = await fetch(
|
|
`/api/edit/chapter/${del._serverIndex}/${encodeURIComponent(filename)}`,
|
|
{ method: 'DELETE' }
|
|
);
|
|
if (!resp.ok) {
|
|
const data = await resp.json().catch(() => ({}));
|
|
throw new Error(data.error || 'Delete failed');
|
|
}
|
|
// Shift server indices for remaining chapters
|
|
chapters.forEach(c => {
|
|
if (c._serverIndex !== null && c._serverIndex > del._serverIndex) c._serverIndex--;
|
|
});
|
|
}
|
|
pendingDeletes = [];
|
|
|
|
// Step 2: apply adds in order of appearance in chapters[]
|
|
for (const ch of chapters.filter(c => c._new)) {
|
|
const localIdx = chapters.indexOf(ch);
|
|
// Find nearest preceding chapter that already has a server index
|
|
let afterServerIndex = -1;
|
|
for (let j = localIdx - 1; j >= 0; j--) {
|
|
if (chapters[j]._serverIndex !== null) {
|
|
afterServerIndex = chapters[j]._serverIndex;
|
|
break;
|
|
}
|
|
}
|
|
const title = pendingTitles.has(ch._id) ? pendingTitles.get(ch._id) : ch.title;
|
|
const resp = await fetch(
|
|
`/api/edit/chapter/add/${encodeURIComponent(filename)}`,
|
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title, after_index: afterServerIndex }) }
|
|
);
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.ok) throw new Error(data.error || 'Add chapter failed');
|
|
|
|
const addedIdx = data.index;
|
|
// Shift server indices for chapters inserted after this position
|
|
chapters.forEach(c => {
|
|
if (c._id !== ch._id && c._serverIndex !== null && c._serverIndex >= addedIdx) c._serverIndex++;
|
|
});
|
|
ch._serverIndex = addedIdx;
|
|
ch._new = false;
|
|
}
|
|
|
|
structureDirty = false;
|
|
}
|
|
|
|
// ── 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();
|
|
}
|
|
|
|
// ── Wrap selection in tag ─────────────────────────────────────────────────────
|
|
|
|
function wrapTag(tag, attrs) {
|
|
if (!editor || currentIndex < 0) return;
|
|
const sel = editor.getSelection();
|
|
const open = attrs ? `<${tag} ${attrs}>` : `<${tag}>`;
|
|
const close = `</${tag}>`;
|
|
const selectedText = editor.getModel().getValueInRange(sel);
|
|
const isEmpty = sel.startLineNumber === sel.endLineNumber && sel.startColumn === sel.endColumn;
|
|
editor.executeEdits('wrap-tag', [{
|
|
range: sel,
|
|
text: open + selectedText + close,
|
|
forceMoveMarkers: true,
|
|
}]);
|
|
if (isEmpty) {
|
|
const pos = editor.getPosition();
|
|
editor.setPosition({ lineNumber: pos.lineNumber, column: pos.column - close.length });
|
|
}
|
|
editor.focus();
|
|
}
|
|
|
|
// Wraps selected text in a span (inline). If the selection contains block
|
|
// elements (<p>, <div>, <h*>) the span is replaced by a <div> so the result
|
|
// stays valid HTML.
|
|
function wrapSpan(cls) {
|
|
if (!editor || currentIndex < 0) return;
|
|
const sel = editor.getSelection();
|
|
const selectedText = editor.getModel().getValueInRange(sel);
|
|
const hasBlock = /<(p|div|h[1-6]|blockquote|ul|ol|li)[\s>]/i.test(selectedText);
|
|
const tag = hasBlock ? 'div' : 'span';
|
|
wrapTag(tag, `class="${cls}"`);
|
|
}
|
|
|
|
function insertIndent() {
|
|
if (!editor || currentIndex < 0) return;
|
|
const sel = editor.getSelection();
|
|
const selectedText = editor.getModel().getValueInRange(sel);
|
|
const hasBlock = /<(p|div|h[1-6]|blockquote|ul|ol|li)[\s>]/i.test(selectedText);
|
|
// If selection contains block elements wrap in a div, else in a p
|
|
const tag = hasBlock ? 'div' : 'p';
|
|
wrapTag(tag, 'style="padding-left: 40px;"');
|
|
}
|
|
|
|
function insertComment() { wrapTag('div', 'class="novela-comment"'); }
|
|
|
|
// ── Add / delete chapter ──────────────────────────────────────────────────────
|
|
|
|
async function addChapter() {
|
|
if (saving) return;
|
|
const title = prompt('Title for new page:', `New chapter ${Math.max(chapters.length + 1, 1)}`);
|
|
if (title === null) return;
|
|
|
|
const insertIdx = currentIndex < 0 ? chapters.length : currentIndex + 1;
|
|
const newCh = {
|
|
title: title.trim() || 'New chapter',
|
|
href: null,
|
|
_id: nextLocalId++,
|
|
_new: true,
|
|
_serverIndex: null,
|
|
};
|
|
chapters.splice(insertIdx, 0, newCh);
|
|
structureDirty = true;
|
|
currentIndex = insertIdx;
|
|
|
|
renderChapterList();
|
|
|
|
loadingChapter = true;
|
|
editor.setValue('');
|
|
editor.setScrollTop(0);
|
|
loadingChapter = false;
|
|
editor.focus();
|
|
|
|
document.getElementById('btn-save').disabled = false;
|
|
document.getElementById('btn-break').disabled = false;
|
|
document.getElementById('btn-subheading').disabled = false;
|
|
document.getElementById('btn-chat').disabled = false;
|
|
document.getElementById('btn-indent').disabled = false;
|
|
document.getElementById('btn-comment').disabled = false;
|
|
document.getElementById('btn-del-page').disabled = chapters.length <= 1;
|
|
setStatus('dirty', 'Unsaved changes');
|
|
document.getElementById('chapter-title-input').value = newCh.title;
|
|
updateSaveAll();
|
|
}
|
|
|
|
async function deleteChapter() {
|
|
if (saving || currentIndex < 0) return;
|
|
saving = true;
|
|
if (chapters.length <= 1) {
|
|
alert('Cannot delete the last page.');
|
|
saving = false;
|
|
return;
|
|
}
|
|
const ch = chapters[currentIndex];
|
|
const chTitle = ch.title || `chapter ${currentIndex + 1}`;
|
|
if (!confirm(`Delete page "${chTitle}"?`)) { saving = false; return; }
|
|
|
|
// Clean up pending state for this chapter
|
|
dirty.delete(ch._id);
|
|
pendingContent.delete(ch._id);
|
|
pendingTitles.delete(ch._id);
|
|
|
|
const removedIndex = currentIndex;
|
|
|
|
if (ch._new) {
|
|
// Never reached the server — just remove locally
|
|
chapters.splice(removedIndex, 1);
|
|
structureDirty = pendingDeletes.length > 0 || chapters.some(c => c._new);
|
|
} else {
|
|
pendingDeletes.push({ _serverIndex: ch._serverIndex, title: chTitle });
|
|
chapters.splice(removedIndex, 1);
|
|
structureDirty = true;
|
|
}
|
|
|
|
const newCount = chapters.length;
|
|
if (newCount === 0) {
|
|
currentIndex = -1;
|
|
renderChapterList();
|
|
if (!is_db) document.getElementById('header-chapter').textContent = 'No chapters';
|
|
document.getElementById('btn-save').disabled = !structureDirty;
|
|
if (structureDirty) setStatus('dirty', 'Unsaved changes');
|
|
document.getElementById('btn-break').disabled = true;
|
|
document.getElementById('btn-subheading').disabled = true;
|
|
document.getElementById('btn-chat').disabled = true;
|
|
document.getElementById('btn-indent').disabled = true;
|
|
document.getElementById('btn-comment').disabled = true;
|
|
document.getElementById('btn-del-page').disabled = true;
|
|
if (editor) { loadingChapter = true; editor.setValue(''); loadingChapter = false; }
|
|
saving = false;
|
|
updateSaveAll();
|
|
return;
|
|
}
|
|
|
|
const newIdx = Math.min(removedIndex, newCount - 1);
|
|
renderChapterList();
|
|
await loadChapter(newIdx);
|
|
setStatus('dirty', 'Unsaved changes');
|
|
saving = false;
|
|
}
|
|
|
|
// ── Generate Book Info page ───────────────────────────────────────────────────
|
|
|
|
async function generateIntroPage() {
|
|
if (saving) return;
|
|
if (structureDirty || dirty.size > 0) {
|
|
alert('Please save pending changes before generating the info page.');
|
|
return;
|
|
}
|
|
saving = true;
|
|
setStatus('saving', 'Generating info page…');
|
|
try {
|
|
const resp = await fetch(
|
|
`/api/edit/intro/${encodeURIComponent(filename)}`,
|
|
{ method: 'POST' }
|
|
);
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok || !data.ok) {
|
|
setStatus('error', data.error || 'Failed to generate info page');
|
|
saving = false;
|
|
return;
|
|
}
|
|
setStatus('saved', 'Info page added');
|
|
setTimeout(() => setStatus('', ''), 2000);
|
|
saving = false;
|
|
await loadChapterList(0);
|
|
} catch {
|
|
setStatus('error', 'Failed to generate info page');
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
// ── 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 currentOnly = document.getElementById('rp-current').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
|
|
const curCh = currentCh();
|
|
if (curCh) pendingContent.set(curCh._id, editor.getValue());
|
|
|
|
// Determine which chapters to process
|
|
let targets;
|
|
if (currentOnly) {
|
|
if (!curCh) {
|
|
prog.className = 'modal-progress error';
|
|
prog.textContent = 'No chapter open.';
|
|
runBtn.disabled = false;
|
|
return;
|
|
}
|
|
targets = [curCh];
|
|
} else {
|
|
targets = chapters;
|
|
}
|
|
|
|
for (let i = 0; i < targets.length; i++) {
|
|
const ch = targets[i];
|
|
prog.className = 'modal-progress';
|
|
prog.textContent = `Checking chapter ${i + 1} / ${targets.length}…`;
|
|
|
|
let original;
|
|
if (pendingContent.has(ch._id)) {
|
|
original = pendingContent.get(ch._id);
|
|
} else if (ch._new) {
|
|
original = '';
|
|
} else {
|
|
try {
|
|
const resp = await fetch(`/api/edit/chapter/${ch._serverIndex}/${encodeURIComponent(filename)}`);
|
|
if (!resp.ok) continue;
|
|
const data = await resp.json();
|
|
original = data.content;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
let count = 0;
|
|
const updated = original.replace(pattern, m => { count++; return replaceVal; });
|
|
if (count === 0) continue;
|
|
|
|
totalOccurrences += count;
|
|
chaptersChanged++;
|
|
pendingContent.set(ch._id, updated);
|
|
dirty.add(ch._id);
|
|
}
|
|
|
|
// Reload current chapter from pending cache if it was changed
|
|
const cur = currentCh();
|
|
if (cur && dirty.has(cur._id) && pendingContent.has(cur._id)) {
|
|
loadingChapter = true;
|
|
editor.setValue(pendingContent.get(cur._id));
|
|
loadingChapter = false;
|
|
document.getElementById('btn-save').disabled = false;
|
|
setStatus('dirty', 'Unsaved changes');
|
|
}
|
|
|
|
renderChapterList();
|
|
updateSaveAll();
|
|
|
|
prog.className = totalOccurrences > 0 ? 'modal-progress ok' : 'modal-progress';
|
|
if (totalOccurrences === 0) {
|
|
prog.textContent = 'No matches found.';
|
|
} else if (currentOnly) {
|
|
prog.textContent = `${totalOccurrences} replacement${totalOccurrences !== 1 ? 's' : ''} in current chapter — not saved yet.`;
|
|
} else {
|
|
prog.textContent = `${totalOccurrences} replacement${totalOccurrences !== 1 ? 's' : ''} in ${chaptersChanged} chapter${chaptersChanged !== 1 ? 's' : ''} — not saved yet.`;
|
|
}
|
|
runBtn.disabled = false;
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function setStatus(cls, text) {
|
|
const el = document.getElementById('save-status');
|
|
el.className = 'save-status' + (cls ? ' ' + cls : '');
|
|
el.textContent = text;
|
|
}
|