Replace markdown editor with native Tiptap editor and add Home navigation
- Migrate renderer from md-editor/md-format to a Tiptap-based native editor (native-doc pipeline: native-doc, markdown-to-native, native-to-markdown, native-assembly, migration, backup) - Use pointer-event-based chapter reordering with grab/grabbing cursors to avoid the Wayland prohibited-drop cursor while preserving reordering - Add a persistent Home button that closes the open book and returns to the Folio library/welcome screen - Update app icons, package deps, theme styles, and tests
This commit is contained in:
parent
0df26d4ce6
commit
786a65ae2a
48 changed files with 5762 additions and 3999 deletions
303
tests/run-migration-test.mjs
Normal file
303
tests/run-migration-test.mjs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
// Standalone test for Stage 2 of the rich-text migration:
|
||||
// - native document model (Tiptap/ProseMirror JSON)
|
||||
// - Markdown -> native conversion (headings, bold, italic, strike, code,
|
||||
// lists, checklists, tables, images, links, wikilinks, callouts,
|
||||
// highlight, blockquote, hr)
|
||||
// - native persistence + schema versioning
|
||||
// - migration + external recovery backup
|
||||
// - idempotency (original .md untouched)
|
||||
// Builds src/main/migration.ts with esbuild (no Electron needed).
|
||||
// Run with: node tests/run-migration-test.mjs
|
||||
import esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
|
||||
const out = path.join(os.tmpdir(), `folio-migration-${process.pid}.mjs`);
|
||||
const entry = path.resolve(".folio-mig-test-entry.ts");
|
||||
fs.writeFileSync(
|
||||
entry,
|
||||
`
|
||||
export { migrateChapterToNative, migrateBookToNative, isNativeChapter } from "./src/main/migration.ts";
|
||||
export { createBook, loadBook, saveMeta } from "./src/main/project.ts";
|
||||
export { setChapterContent, getChapterContent } from "./src/main/chapters.ts";
|
||||
export { markdownToNative } from "./src/main/markdown-to-native.ts";
|
||||
export { parseChapter, serializeChapter, migrateDoc, getChapterDoc, nativeChapterFile, CURRENT_DOC_SCHEMA_VERSION, isValidDoc } from "./src/main/native-doc.ts";
|
||||
export { RECOVERY_ROOT, _resetSessionBackupGuard } from "./src/main/backup.ts";
|
||||
`
|
||||
);
|
||||
await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
outfile: out,
|
||||
write: true,
|
||||
logLevel: "silent",
|
||||
});
|
||||
fs.rmSync(entry, { force: true });
|
||||
|
||||
const mod = await import(url.pathToFileURL(out).href);
|
||||
const {
|
||||
createBook,
|
||||
setChapterContent,
|
||||
getChapterContent,
|
||||
loadBook,
|
||||
saveMeta,
|
||||
migrateChapterToNative,
|
||||
migrateBookToNative,
|
||||
isNativeChapter,
|
||||
getChapterDoc,
|
||||
markdownToNative,
|
||||
parseChapter,
|
||||
serializeChapter,
|
||||
migrateDoc,
|
||||
CURRENT_DOC_SCHEMA_VERSION,
|
||||
RECOVERY_ROOT,
|
||||
_resetSessionBackupGuard,
|
||||
} = mod;
|
||||
|
||||
let failures = 0;
|
||||
function ok(cond, msg) {
|
||||
if (cond) console.log(" ok -", msg);
|
||||
else {
|
||||
failures++;
|
||||
console.error("FAIL -", msg);
|
||||
}
|
||||
}
|
||||
|
||||
function findNodes(node, type, acc = []) {
|
||||
if (!node || typeof node !== "object") return acc;
|
||||
if (node.type === type) acc.push(node);
|
||||
for (const c of node.content ?? []) findNodes(c, type, acc);
|
||||
return acc;
|
||||
}
|
||||
function findMark(node, type, acc = []) {
|
||||
for (const m of node.marks ?? []) if (m.type === type) acc.push(m);
|
||||
for (const c of node.content ?? []) findMark(c, type, acc);
|
||||
return acc;
|
||||
}
|
||||
function allText(node, acc = "") {
|
||||
if (node.text) acc += node.text;
|
||||
for (const c of node.content ?? []) acc = allText(c, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Direct Markdown -> native conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
const md = `# Title
|
||||
|
||||
A paragraph with **bold**, *italic*, ~~strike~~, \`code\` and ==highlight==.
|
||||
|
||||
<u>underline</u> and <span style="color:red">red</span> and <span style="background-color:#ff0">yellow bg</span>.
|
||||
|
||||
> [!note] Callout title
|
||||
> Callout body text.
|
||||
|
||||
> A plain blockquote.
|
||||
|
||||
- bullet one
|
||||
- bullet two
|
||||
|
||||
1. first
|
||||
2. second
|
||||
|
||||
- [ ] todo
|
||||
- [x] done
|
||||
|
||||
| Name | Age |
|
||||
| ---- | --- |
|
||||
| Ada | 36 |
|
||||
|
||||

|
||||
|
||||
A [link](https://example.com) and a [[Other Chapter]] wikilink.
|
||||
|
||||
\`\`\`js
|
||||
const x = 1;
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
Final paragraph.
|
||||
`;
|
||||
|
||||
const doc = markdownToNative(md, {
|
||||
targets: [{ id: "other-chapter-1", title: "Other Chapter" }],
|
||||
});
|
||||
|
||||
ok(doc.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "converted doc has current schema version");
|
||||
ok(doc.doc.type === "doc", "doc root is type 'doc'");
|
||||
ok(findNodes(doc.doc, "heading").length === 1, "one heading converted");
|
||||
ok(findNodes(doc.doc, "heading")[0]?.attrs?.level === 1, "heading is level 1");
|
||||
ok(findNodes(doc.doc, "paragraph").length >= 5, "paragraphs converted");
|
||||
ok(findMark(doc.doc, "bold").length === 1, "bold mark present");
|
||||
ok(findMark(doc.doc, "italic").length === 1, "italic mark present");
|
||||
ok(findMark(doc.doc, "strike").length === 1, "strike mark present");
|
||||
ok(findMark(doc.doc, "code").length === 1, "inline code mark present");
|
||||
ok(findMark(doc.doc, "highlight").length >= 2, "highlight marks present (== and bg span)");
|
||||
ok(findMark(doc.doc, "textColor").length === 1, "textColor mark present");
|
||||
ok(findMark(doc.doc, "underline").length === 1, "underline mark present");
|
||||
ok(findNodes(doc.doc, "blockquote").length === 2, "two blockquotes (callout + quote)");
|
||||
ok(
|
||||
findNodes(doc.doc, "blockquote").some((b) => b.attrs?.callout === "note"),
|
||||
"callout preserved as blockquote with callout type"
|
||||
);
|
||||
ok(allText(doc.doc).includes("Callout title"), "callout title preserved");
|
||||
ok(findNodes(doc.doc, "bulletList").length === 1, "bullet list converted");
|
||||
ok(findNodes(doc.doc, "orderedList").length === 1, "ordered list converted");
|
||||
ok(findNodes(doc.doc, "taskList").length === 1, "checklist converted to taskList");
|
||||
const checked = findNodes(doc.doc, "taskItem").filter((n) => n.attrs?.checked === true);
|
||||
ok(checked.length === 1, "exactly one checklist item marked checked");
|
||||
ok(findNodes(doc.doc, "table").length === 1, "table converted");
|
||||
ok(findNodes(doc.doc, "tableRow").length === 2, "table has two rows");
|
||||
ok(findNodes(doc.doc, "tableHeader").length === 2, "table header cells converted");
|
||||
ok(findNodes(doc.doc, "image").length === 1, "image converted");
|
||||
ok(
|
||||
findNodes(doc.doc, "image")[0]?.attrs?.src === "https://example.com/cat.png",
|
||||
"external image src kept as-is"
|
||||
);
|
||||
const links = findMark(doc.doc, "link");
|
||||
ok(links.length === 2, "two links (standard + wikilink)");
|
||||
ok(
|
||||
links.some((l) => l.attrs?.href === "https://example.com"),
|
||||
"standard link href preserved"
|
||||
);
|
||||
ok(
|
||||
links.some((l) => l.attrs?.href === "folio:chapter/other-chapter-1"),
|
||||
"wikilink resolved to folio:chapter/<id>"
|
||||
);
|
||||
ok(findNodes(doc.doc, "codeBlock").length === 1, "fenced code block converted");
|
||||
ok(
|
||||
findNodes(doc.doc, "codeBlock")[0]?.attrs?.language === "js",
|
||||
"code block language preserved"
|
||||
);
|
||||
ok(findNodes(doc.doc, "horizontalRule").length === 1, "horizontal rule converted");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Round-trip serialize / parse / migrate
|
||||
// ---------------------------------------------------------------------------
|
||||
const reparsed = parseChapter(serializeChapter(doc));
|
||||
ok(reparsed.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "reparsed doc keeps schema version");
|
||||
ok(reparsed.doc.type === "doc", "reparsed doc root intact");
|
||||
ok(
|
||||
JSON.stringify(reparsed.doc) === JSON.stringify(doc.doc),
|
||||
"serialize -> parse is lossless"
|
||||
);
|
||||
|
||||
const oldDoc = { schemaVersion: 0, doc: { type: "doc", content: [] } };
|
||||
const migrated = migrateDoc(oldDoc);
|
||||
ok(migrated.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "migrateDoc bumps old schema version");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. End-to-end migration of a real book (backup + persistence + idempotency)
|
||||
// ---------------------------------------------------------------------------
|
||||
const book = fs.mkdtempSync(path.join(os.tmpdir(), "folio-test-"));
|
||||
_resetSessionBackupGuard();
|
||||
const meta = createBook(book, "Migration Book");
|
||||
const c1 = "chapter-one-1111";
|
||||
const c2 = "chapter-two-2222";
|
||||
meta.chapters[c1] = { id: c1, title: "Chapter One", file: `chapters/${c1}.md` };
|
||||
meta.chapters[c2] = { id: c2, title: "Chapter Two", file: `chapters/${c2}.md` };
|
||||
meta.chapterOrder = [c1, c2];
|
||||
saveMeta(book, meta);
|
||||
fs.mkdirSync(path.join(book, "chapters"), { recursive: true });
|
||||
const md1 = "# One\n\nHello **world**.";
|
||||
const md2 = "## Two\n\n- a\n- b\n\n";
|
||||
fs.writeFileSync(path.join(book, `chapters/${c1}.md`), md1, "utf-8");
|
||||
fs.writeFileSync(path.join(book, `chapters/${c2}.md`), md2, "utf-8");
|
||||
|
||||
const before1 = fs.readFileSync(path.join(book, `chapters/${c1}.md`), "utf-8");
|
||||
const before2 = fs.readFileSync(path.join(book, `chapters/${c2}.md`), "utf-8");
|
||||
|
||||
const results = migrateBookToNative(book);
|
||||
ok(
|
||||
results.every((r) => r.status === "migrated"),
|
||||
"both chapters migrated"
|
||||
);
|
||||
ok(fs.existsSync(path.join(book, `chapters/${c1}.json`)), "chapter json written");
|
||||
ok(fs.existsSync(path.join(book, `chapters/${c2}.json`)), "chapter json written (2)");
|
||||
ok(
|
||||
fs.readFileSync(path.join(book, `chapters/${c1}.md`), "utf-8") === before1,
|
||||
"ORIGINAL .md left untouched (1)"
|
||||
);
|
||||
ok(
|
||||
fs.readFileSync(path.join(book, `chapters/${c2}.md`), "utf-8") === before2,
|
||||
"ORIGINAL .md left untouched (2)"
|
||||
);
|
||||
|
||||
const metaAfter = loadBook(book);
|
||||
ok(metaAfter.docFormat === "folio-doc", "book marked as folio-doc");
|
||||
ok(metaAfter.docSchemaVersion === CURRENT_DOC_SCHEMA_VERSION, "book has doc schema version");
|
||||
ok(metaAfter.chapters[c1].file.endsWith(".json"), "chapter entry repointed to .json");
|
||||
|
||||
ok(isNativeChapter(book, c1), "isNativeChapter true after migration");
|
||||
const readBack = getChapterDoc(book, c1);
|
||||
ok(readBack.doc.type === "doc", "migrated chapter reads back as native doc");
|
||||
ok(
|
||||
findMark(readBack.doc, "bold").length === 1,
|
||||
"migrated chapter preserved bold"
|
||||
);
|
||||
|
||||
// Recovery backup exists under ~/Folio-Recovery and contains folio.json
|
||||
const bookName = path.basename(book);
|
||||
const backups = fs
|
||||
.readdirSync(RECOVERY_ROOT)
|
||||
.filter((d) => d.startsWith(bookName) && fs.statSync(path.join(RECOVERY_ROOT, d)).isDirectory());
|
||||
ok(backups.length >= 1, "recovery backup created under ~/Folio-Recovery");
|
||||
const backupDir = path.join(RECOVERY_ROOT, backups[0]);
|
||||
ok(fs.existsSync(path.join(backupDir, "folio.json")), "backup contains folio.json");
|
||||
ok(
|
||||
fs.existsSync(path.join(backupDir, "chapters", `${c1}.md`)),
|
||||
"backup preserves original .md"
|
||||
);
|
||||
|
||||
// Idempotency: re-migrating does not rewrite .md and reports already-native
|
||||
const results2 = migrateBookToNative(book);
|
||||
ok(
|
||||
results2.every((r) => r.status === "already-native"),
|
||||
"re-migration is idempotent (already-native)"
|
||||
);
|
||||
ok(
|
||||
fs.readFileSync(path.join(book, `chapters/${c1}.md`), "utf-8") === before1,
|
||||
"ORIGINAL .md still untouched after re-migration"
|
||||
);
|
||||
|
||||
// Image asset copy: a local image is copied into assets/ and rewritten.
|
||||
const c3 = "chapter-three-3333";
|
||||
const meta3 = loadBook(book);
|
||||
meta3.chapters[c3] = { id: c3, title: "Chapter Three", file: `chapters/${c3}.md` };
|
||||
meta3.chapterOrder.push(c3);
|
||||
saveMeta(book, meta3);
|
||||
fs.writeFileSync(path.join(book, `chapters/${c3}.md`), "", "utf-8");
|
||||
fs.mkdirSync(path.join(book, "img"), { recursive: true });
|
||||
fs.writeFileSync(path.join(book, "img", "local.png"), "PNGDATA", "utf-8");
|
||||
_resetSessionBackupGuard();
|
||||
const r3 = migrateChapterToNative(book, c3);
|
||||
ok(r3.status === "migrated", "third chapter (local image) migrated");
|
||||
const doc3 = getChapterDoc(book, c3);
|
||||
const img3 = findNodes(doc3.doc, "image")[0];
|
||||
ok(
|
||||
img3?.attrs?.src?.startsWith("assets/"),
|
||||
"local image reference rewritten to assets/"
|
||||
);
|
||||
ok(
|
||||
fs.existsSync(path.join(book, img3.attrs.src)),
|
||||
"local image copied into assets/"
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
fs.rmSync(out, { force: true });
|
||||
fs.rmSync(book, { recursive: true, force: true });
|
||||
for (const d of backups) {
|
||||
fs.rmSync(path.join(RECOVERY_ROOT, d), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(
|
||||
"\nMigration tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED")
|
||||
);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
Loading…
Add table
Add a link
Reference in a new issue