Fix data-loss and security bugs: chapter id validation, reorder merge semantics, autosave cross-write after drag, listener re-registration, delete flush, failed-load handling
This commit is contained in:
parent
b74cfe98ab
commit
c5c7c80b81
2 changed files with 136 additions and 56 deletions
|
|
@ -164,11 +164,23 @@ export interface ChapterSnapshot {
|
|||
index: number;
|
||||
}
|
||||
|
||||
// Chapter ids are always slugified lowercase ids; enforcing that shape here
|
||||
// keeps renderer-supplied snapshots from escaping the book directory.
|
||||
const SAFE_ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
// Re-create a previously deleted chapter at its original position, restoring
|
||||
// its title, color, and content. The chapter is stored as Markdown so it reads
|
||||
// correctly regardless of the format it originally used.
|
||||
// correctly regardless of the format it originally used. Idempotent: restoring
|
||||
// an id that still exists is a no-op instead of a duplicate.
|
||||
export function restoreChapter(bookPath: string, snap: ChapterSnapshot): ChapterEntry {
|
||||
if (!snap || typeof snap.id !== "string" || !SAFE_ID.test(snap.id)) {
|
||||
throw new Error("Invalid chapter id");
|
||||
}
|
||||
const meta = loadBook(bookPath);
|
||||
// Already present (e.g. undo clicked twice): return the existing entry
|
||||
// unchanged rather than writing the file again and duplicating the id in
|
||||
// chapterOrder.
|
||||
if (meta.chapters[snap.id]) return meta.chapters[snap.id];
|
||||
const file = path.posix.join("chapters", chapterFileName(snap.id));
|
||||
fs.mkdirSync(path.join(bookPath, "chapters"), { recursive: true });
|
||||
fs.writeFileSync(path.join(bookPath, file), snap.content, "utf-8");
|
||||
|
|
@ -209,10 +221,25 @@ export function duplicateChapter(
|
|||
export function reorderChapters(bookPath: string, newOrder: string[]): void {
|
||||
const meta = loadBook(bookPath);
|
||||
const valid = new Set(Object.keys(meta.chapters));
|
||||
if (!newOrder.every((id) => valid.has(id))) {
|
||||
if (
|
||||
!Array.isArray(newOrder) ||
|
||||
new Set(newOrder).size !== newOrder.length ||
|
||||
!newOrder.every((id) => valid.has(id))
|
||||
) {
|
||||
throw new Error("Invalid chapter order");
|
||||
}
|
||||
meta.chapterOrder = newOrder;
|
||||
// A partial order is allowed (callers may reorder just a subset), but it
|
||||
// must MERGE with the existing sequence rather than replace it: replacing
|
||||
// wholesale used to drop omitted ids from chapterOrder permanently, which
|
||||
// silently hid those chapters from listings and exports.
|
||||
const rest = meta.chapterOrder.filter((id) => !newOrder.includes(id));
|
||||
const merged = [...newOrder, ...rest];
|
||||
// Safety net: any chapter present in metadata but absent from the stored
|
||||
// order (e.g. legacy folio.json drift) is appended rather than lost.
|
||||
for (const id of Object.keys(meta.chapters)) {
|
||||
if (!merged.includes(id)) merged.push(id);
|
||||
}
|
||||
meta.chapterOrder = merged;
|
||||
saveMeta(bookPath, meta);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -739,30 +739,12 @@ function makeSnippet(plain: string, pos: number, len: number): string {
|
|||
const welcome = $("welcome");
|
||||
const recentList = $("recentList") as HTMLUListElement;
|
||||
|
||||
async function showWelcome() {
|
||||
welcome.classList.remove("hidden");
|
||||
chapterView.classList.add("hidden");
|
||||
fullBookView.classList.add("hidden");
|
||||
editorPlaceholder.classList.add("hidden");
|
||||
// Keep the project bar visible so the Home button is a persistent nav
|
||||
// control. On Home it is shown disabled/active (you are already here).
|
||||
projectBar.classList.remove("hidden");
|
||||
bookTitleEl.textContent = "";
|
||||
bookTitleEl.classList.add("hidden");
|
||||
bookPathEl.classList.add("hidden");
|
||||
bookPathEl.title = "";
|
||||
$("revealBtn")?.classList.add("hidden");
|
||||
const hb = homeBtn as HTMLButtonElement | null;
|
||||
if (hb) {
|
||||
hb.disabled = true;
|
||||
hb.classList.add("active");
|
||||
hb.setAttribute("aria-current", "page");
|
||||
}
|
||||
currentBook = null;
|
||||
meta = null;
|
||||
ordered = [];
|
||||
selectedId = null;
|
||||
await renderRecents();
|
||||
|
||||
// One-time wiring for search UI and the Save button. Previously this
|
||||
// block lived inside showWelcome(), re-registering ~15 listeners every
|
||||
// time the welcome screen was shown (N Home trips = N duplicate saves
|
||||
// per click). Elements are static; bind once instead.
|
||||
function initSearchUI(): void {
|
||||
// ---- search across all chapters ----
|
||||
const searchBtn = $("searchBtn") as HTMLButtonElement;
|
||||
const searchModal = $("searchModal") as HTMLDivElement;
|
||||
|
|
@ -987,6 +969,32 @@ const tmp = createTiptapEditor(host);
|
|||
if (first) first.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function showWelcome() {
|
||||
welcome.classList.remove("hidden");
|
||||
chapterView.classList.add("hidden");
|
||||
fullBookView.classList.add("hidden");
|
||||
editorPlaceholder.classList.add("hidden");
|
||||
// Keep the project bar visible so the Home button is a persistent nav
|
||||
// control. On Home it is shown disabled/active (you are already here).
|
||||
projectBar.classList.remove("hidden");
|
||||
bookTitleEl.textContent = "";
|
||||
bookTitleEl.classList.add("hidden");
|
||||
bookPathEl.classList.add("hidden");
|
||||
bookPathEl.title = "";
|
||||
$("revealBtn")?.classList.add("hidden");
|
||||
const hb = homeBtn as HTMLButtonElement | null;
|
||||
if (hb) {
|
||||
hb.disabled = true;
|
||||
hb.classList.add("active");
|
||||
hb.setAttribute("aria-current", "page");
|
||||
}
|
||||
currentBook = null;
|
||||
meta = null;
|
||||
ordered = [];
|
||||
selectedId = null;
|
||||
await renderRecents();
|
||||
|
||||
setChapterButtons(false);
|
||||
fullBookBtn.classList.remove("active");
|
||||
|
|
@ -1038,6 +1046,17 @@ const tmp = createTiptapEditor(host);
|
|||
}
|
||||
setChapterButtons(!!meta);
|
||||
renderChapterList();
|
||||
// Preserve the chapter currently being read. Metadata refreshes fire on
|
||||
// every rename/color/reorder, and jumping back to the first chapter
|
||||
// here would yank the editor out from under the user mid-session.
|
||||
const keepId = selectedId && meta?.chapters[selectedId] ? selectedId : null;
|
||||
if (keepId) {
|
||||
const entry = meta!.chapters[keepId];
|
||||
chapterTitleEl.textContent = entry.title || keepId;
|
||||
updateCommentsBadge();
|
||||
await refreshFullBook();
|
||||
return;
|
||||
}
|
||||
if (ordered.length) {
|
||||
await selectChapter(ordered[0].id);
|
||||
} else {
|
||||
|
|
@ -1232,6 +1251,10 @@ const tmp = createTiptapEditor(host);
|
|||
const el = $("undoToast");
|
||||
if (!el) return;
|
||||
el.classList.remove("show");
|
||||
// Drop the handler so a second click during the hide animation cannot
|
||||
// run the undo twice (restore would duplicate the chapter entry).
|
||||
const btn = $("undoToastBtn") as HTMLButtonElement | null;
|
||||
if (btn) btn.onclick = null;
|
||||
setTimeout(() => el.classList.add("hidden"), 200);
|
||||
}
|
||||
function showUndoToast(msg: string, onUndo: () => void, timeout = 9000): void {
|
||||
|
|
@ -1243,7 +1266,10 @@ const tmp = createTiptapEditor(host);
|
|||
const msgEl = $("undoToastMsg");
|
||||
const btn = $("undoToastBtn") as HTMLButtonElement | null;
|
||||
if (msgEl) msgEl.textContent = msg;
|
||||
if (btn) btn.onclick = () => {
|
||||
if (btn)
|
||||
btn.onclick = () => {
|
||||
// Single-fire: detach before running so double-clicks are ignored.
|
||||
btn.onclick = null;
|
||||
hideUndoToast();
|
||||
onUndo();
|
||||
};
|
||||
|
|
@ -1275,22 +1301,39 @@ const tmp = createTiptapEditor(host);
|
|||
showToast("Saved");
|
||||
}
|
||||
|
||||
// Guards against overlapping selections: rapid clicks (or metadata
|
||||
// refreshes) can interleave loads; only the newest one may commit state.
|
||||
let selectToken = 0;
|
||||
async function selectChapter(id: string) {
|
||||
clearTimers();
|
||||
if (selectedId && dirty) {
|
||||
// Auto-save silently so navigating away never loses text and returning to
|
||||
// this chapter shows what was just written.
|
||||
// this chapter shows what was just written. The save targets the
|
||||
// chapter still selected right now, so it happens before reassignment.
|
||||
await saveCurrentChapter();
|
||||
}
|
||||
const token = ++selectToken;
|
||||
let res: { doc?: never; error?: string } | null = null;
|
||||
try {
|
||||
res = await api.loadChapterDoc(id);
|
||||
} catch {
|
||||
res = null;
|
||||
}
|
||||
// A newer selection superseded this one: drop the stale reply instead of
|
||||
// pairing its document with the wrong chapter id.
|
||||
if (token !== selectToken) return;
|
||||
if (!(res && "doc" in res)) {
|
||||
// Load failed. Keep the current editor contents and selection intact —
|
||||
// seeding an empty document here would let the next autosave overwrite
|
||||
// the real chapter file with near-empty content.
|
||||
const msg = res && "error" in res ? String(res.error) : "The chapter file could not be read.";
|
||||
void alertMessage("Couldn't open chapter", msg);
|
||||
return;
|
||||
}
|
||||
selectedId = id;
|
||||
renderChapterList();
|
||||
updateCommentsBadge();
|
||||
try {
|
||||
const res = await api.loadChapterDoc(id);
|
||||
const doc =
|
||||
res && "doc" in res
|
||||
? (res as { doc: never }).doc
|
||||
: ({ type: "doc", content: [] } as never);
|
||||
const doc = (res as { doc: never }).doc;
|
||||
const title = (meta && meta.chapters[id]?.title) || id;
|
||||
chapterTitleEl.textContent = title;
|
||||
setEditorDoc(editor, doc);
|
||||
|
|
@ -1305,9 +1348,6 @@ const tmp = createTiptapEditor(host);
|
|||
fullBookView.classList.add("hidden");
|
||||
chapterView.classList.remove("hidden");
|
||||
editorPlaceholder.classList.add("hidden");
|
||||
} catch {
|
||||
showPlaceholder();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- full book view ----
|
||||
|
|
@ -1623,6 +1663,14 @@ const tmp = createTiptapEditor(host);
|
|||
if (!meta || !meta.chapters[id]) return;
|
||||
const entry = meta.chapters[id];
|
||||
const index = meta.chapterOrder.indexOf(id);
|
||||
// Flush unsaved edits before snapshotting: the undo snapshot reads from
|
||||
// disk, so text typed since the last autosave would otherwise be lost
|
||||
// even through Undo. Also stop a pending autosave timer from firing
|
||||
// while the chapter is being removed.
|
||||
if (selectedId === id) {
|
||||
clearTimers();
|
||||
if (dirty) await saveCurrentChapter();
|
||||
}
|
||||
const content = (await api.getChapterContent(id)) as string;
|
||||
const res = await api.deleteChapter(id);
|
||||
if (res && "error" in res) {
|
||||
|
|
@ -1841,7 +1889,11 @@ const tmp = createTiptapEditor(host);
|
|||
ordered = moveInList(ordered, from, to);
|
||||
meta!.chapterOrder = ordered.map((c) => c.id);
|
||||
renderChapterList();
|
||||
selectedId = draggingId;
|
||||
// Selecting the dragged row must go through selectChapter: assigning
|
||||
// selectedId directly would pair the id with whatever document is
|
||||
// still in the editor, and the next autosave would write that
|
||||
// document under the dragged chapter's id.
|
||||
void selectChapter(draggingId);
|
||||
void persistOrder();
|
||||
}
|
||||
}
|
||||
|
|
@ -2301,6 +2353,7 @@ const tmp = createTiptapEditor(host);
|
|||
});
|
||||
|
||||
setChapterButtons(false);
|
||||
initSearchUI();
|
||||
void showWelcome();
|
||||
|
||||
(window as any).__folioEditor = editor;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue