src / notebookDump.ts
/**
* Builds a single well-formed XML document that mirrors a OneNote notebook's
* structure — section groups, sections, and pages — with each page's raw
* OneNote XML embedded inline. Used by save_all_notebook_pages_xml_to_file.
*/
export interface HierItem {
type?: string;
name?: string;
id?: string;
path?: string;
[k: string]: unknown;
}
export interface NotebookRef {
id?: string;
name?: string;
path?: string;
}
export interface DumpStats {
pages: number;
sections: number;
section_groups: number;
errors: number;
}
interface TreeNode extends HierItem {
children: TreeNode[];
}
function xmlAttr(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
function xmlText(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
function stripDeclaration(xml: string): string {
return xml.replace(/^/, "").replace(/^\s*<\?xml[^>]*\?>\s*/i, "").trim();
}
export function sanitizeFileName(name: string): string {
const cleaned = String(name ?? "")
.replace(/[<>:"/\\|?*\x00-\x1f]/g, " ")
.replace(/\s+/g, " ")
.trim();
return cleaned || "notebook";
}
function parentPathOf(path: string): string {
const idx = path.lastIndexOf("/");
return idx >= 0 ? path.slice(0, idx) : "";
}
export async function buildNotebookXml(
notebook: NotebookRef,
items: HierItem[],
fetchPageXml: (pageId: string) => Promise<string>,
opts: {
onProgress?: (done: number, total: number, name: string) => void;
isAborted?: () => boolean;
} = {},
): Promise<{ xml: string; stats: DumpStats }> {
const stats: DumpStats = { pages: 0, sections: 0, section_groups: 0, errors: 0 };
const byPath = new Map<string, TreeNode>();
for (const item of items) {
const node: TreeNode = { ...item, children: [] };
if (typeof node.path === "string") byPath.set(node.path, node);
}
let root: TreeNode | undefined;
for (const node of byPath.values()) {
if (node.id && notebook.id && node.id === notebook.id) {
root = node;
break;
}
if (node.type === "notebook" && node.name === notebook.name) root = node;
}
if (!root) {
root = {
type: "notebook",
name: notebook.name,
id: notebook.id,
path: notebook.path ?? notebook.name ?? "",
children: [],
};
if (typeof root.path === "string") byPath.set(root.path, root);
}
for (const node of byPath.values()) {
if (node === root) continue;
const path = typeof node.path === "string" ? node.path : "";
const parent = byPath.get(parentPathOf(path));
const target = parent && parent !== node ? parent : root;
target.children.push(node);
}
const totalPages = items.filter((i) => i.type === "page").length;
const out: string[] = [];
out.push('<?xml version="1.0" encoding="UTF-8"?>');
const emit = async (node: TreeNode, depth: number): Promise<void> => {
if (opts.isAborted?.()) throw new Error("Aborted by user.");
const indent = " ".repeat(depth);
const attrs =
` name="${xmlAttr(node.name)}"` +
(node.id ? ` id="${xmlAttr(node.id)}"` : "") +
(node.path ? ` path="${xmlAttr(node.path)}"` : "");
if (node.type === "page") {
stats.pages += 1;
opts.onProgress?.(stats.pages, totalPages, String(node.name ?? ""));
let pageXml = "";
try {
pageXml = stripDeclaration(await fetchPageXml(String(node.id ?? "")));
} catch (error: any) {
stats.errors += 1;
pageXml = `<!-- error reading page: ${xmlText(error?.message ?? String(error))} -->`;
}
out.push(`${indent}<page${attrs}>`);
if (pageXml) out.push(pageXml);
out.push(`${indent}</page>`);
return;
}
const tag =
node.type === "notebook"
? "notebook"
: node.type === "section_group"
? "sectionGroup"
: node.type === "section"
? "section"
: "item";
if (node.type === "section") stats.sections += 1;
if (node.type === "section_group") stats.section_groups += 1;
if (node.children.length === 0) {
out.push(`${indent}<${tag}${attrs}/>`);
return;
}
out.push(`${indent}<${tag}${attrs}>`);
for (const child of node.children) {
await emit(child, depth + 1);
}
out.push(`${indent}</${tag}>`);
};
await emit(root, 0);
return { xml: out.join("\n"), stats };
}