src / parseXml.ts
/**
* OneNote XML parsing/reading helpers (ported from xml_utils.py) plus new
* table extraction/replacement and page-clone transforms. Uses @xmldom/xmldom
* so we can find and serialize individual subtrees (e.g. a single table).
*/
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
import { ONE_NS } from "./constants";
import { htmlFragmentToText } from "./htmlText";
import { plainToInlineHtml, reformatLikeTemplate } from "./htmlToOneNote";
const ELEMENT_NODE = 1;
export const DELETABLE_PAGE_OBJECT_TYPES = new Set([
"Outline", "Image", "InkDrawing", "FileAttachment", "InsertedFile", "MediaFile",
]);
const TYPE_MAP: Record<string, string> = {
Notebook: "notebook",
SectionGroup: "section_group",
Section: "section",
Page: "page",
};
export interface HierItem {
type: string;
id: string;
name: string;
path: string;
level: number;
parent_id: string | null;
parent_name: string | null;
notebook_name: string | null;
section_name: string | null;
[k: string]: unknown;
}
function localName(node: any): string {
if (node.localName) return node.localName;
const n: string = node.nodeName || node.tagName || "";
return n.includes(":") ? n.split(":").pop()! : n;
}
function getAttr(el: any, name: string): string | undefined {
if (el.getAttribute) {
const v = el.getAttribute(name);
if (v !== null && v !== undefined) return v;
}
return undefined;
}
function eachChildElement(node: any, cb: (el: any) => void): void {
let child = node.firstChild;
while (child) {
if (child.nodeType === ELEMENT_NODE) cb(child);
child = child.nextSibling;
}
}
function walkElements(node: any, cb: (el: any) => void): void {
if (node.nodeType === ELEMENT_NODE) cb(node);
let child = node.firstChild;
while (child) {
if (child.nodeType === ELEMENT_NODE) walkElements(child, cb);
child = child.nextSibling;
}
}
export function parseXml(xml: string): any {
const errors: string[] = [];
const parser = new DOMParser({
onError: (level: string, msg: string) => {
if (level === "fatalError") errors.push(msg);
},
} as any);
const doc = parser.parseFromString(xml, "text/xml");
if (errors.length || !doc || !(doc as any).documentElement) {
throw new Error("Invalid OneNote XML: " + (errors[0] ?? "no root element"));
}
return doc;
}
export function textFromPageXml(xml: string): string {
const doc = parseXml(xml);
const texts: string[] = [];
walkElements(doc.documentElement, (el) => {
if (localName(el) === "T") {
const t = el.textContent ?? "";
if (t) texts.push(htmlFragmentToText(t));
}
});
return texts.filter((t) => t).join("\n\n").trim();
}
export function titleFromPageXml(xml: string): string | null {
const doc = parseXml(xml);
let result: string | null = null;
walkElements(doc.documentElement, (el) => {
if (result !== null || localName(el) !== "Title") return;
walkElements(el, (node) => {
if (result !== null || localName(node) !== "T") return;
const t = node.textContent ?? "";
if (t) {
const value = htmlFragmentToText(t);
if (value) result = value;
}
});
});
return result;
}
const CONTENT_WITHOUT_OWN_ID = new Set(["Image", "FileAttachment", "InsertedFile", "MediaFile"]);
export function collectPageObjects(xml: string): Array<Record<string, any>> {
const doc = parseXml(xml);
const objects: Array<Record<string, any>> = [];
const walk = (
node: any,
containerObjectId: string | undefined,
deletableContainerId: string | undefined,
inTitle: boolean,
): void => {
const kind = localName(node);
const nextInTitle = inTitle || kind === "Title";
const objectId = getAttr(node, "objectID") ?? getAttr(node, "ID");
const nextContainerId = objectId ?? containerObjectId;
const deleteSupported = DELETABLE_PAGE_OBJECT_TYPES.has(kind) && Boolean(objectId);
const nextDeletableContainerId = deleteSupported ? objectId : deletableContainerId;
if (!nextInTitle && kind !== "Page" && (objectId || CONTENT_WITHOUT_OWN_ID.has(kind))) {
const record: Record<string, any> = { type: kind };
if (objectId) record.object_id = objectId;
else if (containerObjectId) record.container_object_id = containerObjectId;
if (containerObjectId && objectId !== containerObjectId) record.parent_object_id = containerObjectId;
record.delete_supported = deleteSupported;
if (deleteSupported && objectId) record.delete_object_id = objectId;
else if (deletableContainerId) record.delete_object_id = deletableContainerId;
const callbackId = getAttr(node, "callbackID");
if (callbackId !== undefined) record.callback_id = callbackId;
const format = getAttr(node, "format");
if (format !== undefined) record.format = format;
objects.push(record);
}
eachChildElement(node, (child) => walk(child, nextContainerId, nextDeletableContainerId, nextInTitle));
};
walk(doc.documentElement, undefined, undefined, false);
return objects;
}
export function parseHierarchy(xml: string): HierItem[] {
const doc = parseXml(xml);
const items: HierItem[] = [];
const walk = (
node: any,
ancestors: string[],
parentId: string | null,
parentName: string | null,
notebookName: string | null,
sectionName: string | null,
level: number,
): void => {
const nodeType = localName(node);
let nextParentId = parentId;
let nextParentName = parentName;
let nextAncestors = ancestors;
let nextNotebook = notebookName;
let nextSection = sectionName;
let nextLevel = level;
if (nodeType in TYPE_MAP) {
const name = getAttr(node, "name") ?? getAttr(node, "nickname") ?? "(untitled)";
const objectId = getAttr(node, "ID") ?? "";
const pathParts = ancestors.concat([name]);
let currentNotebook = notebookName;
let currentSection = sectionName;
if (nodeType === "Notebook") currentNotebook = name;
else if (nodeType === "Section") currentSection = name;
const attributes: Record<string, string> = {};
const attrs = node.attributes;
if (attrs) {
for (let i = 0; i < attrs.length; i++) {
const a = attrs.item(i);
if (!a) continue;
const key = a.name ?? a.nodeName;
const value = a.value ?? a.nodeValue ?? "";
if (key === "ID" || key === "name") continue;
if (key === "path") attributes["onenote_path"] = value;
else attributes[key] = value;
}
}
const item: HierItem = {
type: TYPE_MAP[nodeType],
id: objectId,
name,
path: pathParts.join("/"),
level,
parent_id: parentId,
parent_name: parentName,
notebook_name: currentNotebook,
section_name: currentSection,
...attributes,
};
items.push(item);
nextParentId = objectId;
nextParentName = name;
nextAncestors = pathParts;
nextNotebook = currentNotebook;
nextSection = currentSection;
nextLevel = level + 1;
}
eachChildElement(node, (child) =>
walk(child, nextAncestors, nextParentId, nextParentName, nextNotebook, nextSection, nextLevel),
);
};
walk(doc.documentElement, [], null, null, null, null, 0);
return items;
}
export function filterItems(items: HierItem[], itemType: string): HierItem[] {
return items.filter((item) => item.type === itemType);
}
export function resolveItem(items: HierItem[], identifier: string, itemType?: string | null): HierItem {
const candidates = items.filter((item) => itemType == null || item.type === itemType);
const typeLabel = itemType || "object";
for (const item of candidates) {
if (item.id === identifier) return item;
}
const lowered = identifier.toLowerCase();
const pathExact = candidates.filter((item) => (item.path || "").toLowerCase() === lowered);
if (pathExact.length === 1) return pathExact[0];
if (pathExact.length > 1) {
const paths = pathExact.slice(0, 10).map((i) => i.path).join(", ");
throw new Error(`Ambiguous ${typeLabel} identifier '${identifier}'. Use an ID or exact path. Matches: ${paths}`);
}
const nameExact = candidates.filter((item) => (item.name || "").toLowerCase() === lowered);
if (nameExact.length === 1) return nameExact[0];
if (nameExact.length > 1) {
const paths = nameExact.slice(0, 10).map((i) => i.path).join(", ");
throw new Error(`Ambiguous ${typeLabel} identifier '${identifier}'. Use an ID or exact path. Matches: ${paths}`);
}
throw new Error(
`No ${typeLabel} found for '${identifier}'. Use an ID or exact path from list_hierarchy, list_sections, or list_pages.`,
);
}
// --- Table extraction / replacement -----------------------------------------
function collectTables(doc: any): any[] {
const tables: any[] = [];
walkElements(doc.documentElement, (el) => {
if (localName(el) === "Table") tables.push(el);
});
return tables;
}
/** Find a table element by objectID (on the table or its nearest ancestor OE) or by 1-based index. */
function findTable(doc: any, tableId: string): any | null {
const tables = collectTables(doc);
const trimmed = tableId.trim();
if (/^[0-9]+$/.test(trimmed)) {
const idx = parseInt(trimmed, 10);
if (idx >= 1 && idx <= tables.length) return tables[idx - 1];
}
for (const t of tables) {
if (getAttr(t, "objectID") === tableId) return t;
}
for (const t of tables) {
let cur = t.parentNode;
while (cur && cur.nodeType === ELEMENT_NODE) {
if (localName(cur) === "OE") {
if (getAttr(cur, "objectID") === tableId) return t;
break;
}
cur = cur.parentNode;
}
}
return null;
}
function ensureOneNamespace(xml: string): string {
if (/^\s*<one:Table\b/.test(xml) && !/\bxmlns:one\s*=/.test(xml)) {
return xml.replace(/^(\s*<one:Table)\b/, `$1 xmlns:one="${ONE_NS}"`);
}
return xml;
}
export function getTableXml(pageXml: string, tableId: string): string {
const doc = parseXml(pageXml);
const table = findTable(doc, tableId);
if (!table) throw new Error(`No table found for id '${tableId}' on this page.`);
return ensureOneNamespace(new XMLSerializer().serializeToString(table));
}
export function countTables(pageXml: string): number {
return collectTables(parseXml(pageXml)).length;
}
/**
* Replace the target table with new table XML inside the full page XML and
* return the whole updated page XML (submit it with force=true).
*/
export function replaceTableInPageXml(pageXml: string, tableId: string, newTableXml: string): string {
const doc = parseXml(pageXml);
const target = findTable(doc, tableId);
if (!target) throw new Error(`No table found for id '${tableId}' on this page.`);
const wrapped = `<one:__wrap xmlns:one="${ONE_NS}">${newTableXml}</one:__wrap>`;
const fragDoc = parseXml(wrapped);
let replacement: any = null;
walkElements(fragDoc.documentElement, (el) => {
if (!replacement && localName(el) === "Table") replacement = el;
});
if (!replacement) throw new Error("Provided XML does not contain a <one:Table> element.");
const imported = (doc as any).importNode(replacement, true);
target.parentNode.replaceChild(imported, target);
return new XMLSerializer().serializeToString(doc);
}
// --- Row / cell editing ------------------------------------------------------
function directChildrenByLocalName(el: any, name: string): any[] {
const out: any[] = [];
let child = el.firstChild;
while (child) {
if (child.nodeType === ELEMENT_NODE && localName(child) === name) out.push(child);
child = child.nextSibling;
}
return out;
}
function tableRows(tableEl: any): any[] {
return directChildrenByLocalName(tableEl, "Row");
}
function rowCells(rowEl: any): any[] {
return directChildrenByLocalName(rowEl, "Cell");
}
function tableColumnCount(tableEl: any): number {
const cols = directChildrenByLocalName(tableEl, "Columns")[0];
if (cols) {
const n = directChildrenByLocalName(cols, "Column").length;
if (n > 0) return n;
}
return tableRows(tableEl).reduce((m, r) => Math.max(m, rowCells(r).length), 0);
}
function firstTextElement(el: any): any | null {
let found: any = null;
walkElements(el, (n) => {
if (!found && localName(n) === "T") found = n;
});
return found;
}
function cellText(cellEl: any): string {
const fragments: string[] = [];
walkElements(cellEl, (n) => {
if (localName(n) === "T") {
const t = n.textContent ?? "";
if (t) fragments.push(t);
}
});
return htmlFragmentToText(fragments.join("\n"));
}
function setCellText(doc: any, cellEl: any, text: string, preserveFormatting = true): void {
let t = firstTextElement(cellEl);
let fragment: string;
if (t && preserveFormatting) {
// Keep the cell's existing character formatting (bold/italic/strikethrough/
// etc.) by re-wrapping the new text in the same inline tags.
fragment = reformatLikeTemplate(t.textContent ?? "", text);
} else {
fragment = plainToInlineHtml(text);
}
if (!t) {
let oeChildren = directChildrenByLocalName(cellEl, "OEChildren")[0];
if (!oeChildren) {
oeChildren = doc.createElementNS(ONE_NS, "one:OEChildren");
cellEl.appendChild(oeChildren);
}
const oe = doc.createElementNS(ONE_NS, "one:OE");
t = doc.createElementNS(ONE_NS, "one:T");
oe.appendChild(t);
oeChildren.appendChild(oe);
}
while (t.firstChild) t.removeChild(t.firstChild);
t.appendChild(doc.createCDATASection(fragment));
}
function stripObjectIds(el: any): void {
walkElements(el, (n) => {
if (n.removeAttribute) {
n.removeAttribute("objectID");
n.removeAttribute("lastModifiedTime");
}
});
}
export interface InsertRowResult {
xml: string;
inserted_at: number;
columns: number;
cells_written: number;
}
/**
* Insert a new row into a table, cloning an existing row so cell formatting is
* preserved, then writing the provided cell text. index and templateRowIndex
* are 1-based; index defaults to appending at the end.
*/
export function insertRowIntoTableXml(
pageXml: string,
tableId: string,
values: string[],
index?: number | null,
templateRowIndex?: number | null,
): InsertRowResult {
const doc = parseXml(pageXml);
const table = findTable(doc, tableId);
if (!table) throw new Error(`No table found for id '${tableId}' on this page.`);
const rows = tableRows(table);
const columns = tableColumnCount(table);
let newRow: any;
let cellsWritten = 0;
if (rows.length) {
const tIdx =
templateRowIndex && templateRowIndex >= 1 && templateRowIndex <= rows.length
? templateRowIndex - 1
: rows.length - 1;
newRow = rows[tIdx].cloneNode(true);
stripObjectIds(newRow);
const cells = rowCells(newRow);
for (let i = 0; i < cells.length; i++) {
setCellText(doc, cells[i], values[i] ?? "");
cellsWritten += 1;
}
} else {
newRow = doc.createElementNS(ONE_NS, "one:Row");
const count = Math.max(columns, values.length);
for (let i = 0; i < count; i++) {
const cell = doc.createElementNS(ONE_NS, "one:Cell");
setCellText(doc, cell, values[i] ?? "");
newRow.appendChild(cell);
cellsWritten += 1;
}
}
const pos = index == null || index < 1 ? rows.length + 1 : index;
if (rows.length && pos <= rows.length) {
const ref = rows[pos - 1];
ref.parentNode.insertBefore(newRow, ref);
} else if (rows.length) {
const last = rows[rows.length - 1];
last.parentNode.insertBefore(newRow, last.nextSibling);
} else {
table.appendChild(newRow);
}
return {
xml: new XMLSerializer().serializeToString(doc),
inserted_at: Math.min(pos, rows.length + 1),
columns,
cells_written: cellsWritten,
};
}
/** Update the text of a single cell by 1-based row/column. Preserves cell formatting. */
export function updateTableCellTextXml(
pageXml: string,
tableId: string,
row: number,
column: number,
text: string,
): string {
const doc = parseXml(pageXml);
const table = findTable(doc, tableId);
if (!table) throw new Error(`No table found for id '${tableId}' on this page.`);
const rows = tableRows(table);
if (row < 1 || row > rows.length) throw new Error(`row ${row} is out of range (1..${rows.length}).`);
const cells = rowCells(rows[row - 1]);
if (column < 1 || column > cells.length) throw new Error(`column ${column} is out of range (1..${cells.length}).`);
setCellText(doc, cells[column - 1], text);
return new XMLSerializer().serializeToString(doc);
}
export interface DeleteRowResult {
xml: string;
deleted_row: number;
}
/**
* Delete a row by 1-based index, or by matching the text of its first column
* (case-insensitive, trimmed). If first_column_text is given it takes priority.
*/
export function deleteRowFromTableXml(
pageXml: string,
tableId: string,
opts: { rowIndex?: number | null; firstColumnText?: string | null },
): DeleteRowResult {
const doc = parseXml(pageXml);
const table = findTable(doc, tableId);
if (!table) throw new Error(`No table found for id '${tableId}' on this page.`);
const rows = tableRows(table);
let targetIndex = -1;
if (opts.firstColumnText != null && opts.firstColumnText !== "") {
const want = opts.firstColumnText.trim().toLowerCase();
for (let i = 0; i < rows.length; i++) {
const cells = rowCells(rows[i]);
if (cells.length && cellText(cells[0]).trim().toLowerCase() === want) {
targetIndex = i;
break;
}
}
if (targetIndex < 0) throw new Error(`No row found whose first column matches '${opts.firstColumnText}'.`);
} else if (opts.rowIndex != null) {
if (opts.rowIndex < 1 || opts.rowIndex > rows.length) {
throw new Error(`row_index ${opts.rowIndex} is out of range (1..${rows.length}).`);
}
targetIndex = opts.rowIndex - 1;
} else {
throw new Error("Provide either row_index or first_column_text.");
}
const row = rows[targetIndex];
row.parentNode.removeChild(row);
return { xml: new XMLSerializer().serializeToString(doc), deleted_row: targetIndex + 1 };
}
/**
* Transform a source page's XML into content for a new page: point it at the
* new page ID, strip object IDs so OneNote assigns fresh ones, and optionally
* override the title.
*/
export function buildDuplicatePageXml(sourcePageXml: string, newPageId: string, newTitle?: string | null): string {
const doc = parseXml(sourcePageXml);
const page = doc.documentElement;
page.setAttribute("ID", newPageId);
page.removeAttribute("lastModifiedTime");
walkElements(page, (el) => {
if (el === page) return;
if (el.removeAttribute) {
el.removeAttribute("objectID");
el.removeAttribute("lastModifiedTime");
}
});
if (newTitle !== undefined && newTitle !== null) {
walkElements(page, (el) => {
if (localName(el) !== "Title") return;
// Replace the first one:T text within the title.
walkElements(el, (node) => {
if (localName(node) !== "T") return;
while (node.firstChild) node.removeChild(node.firstChild);
const cdataNode = (doc as any).createCDATASection(newTitle);
node.appendChild(cdataNode);
});
});
}
return new XMLSerializer().serializeToString(doc);
}
// --- Outlines ----------------------------------------------------------------
const OUTLINE_DEFAULT_X = 36;
const OUTLINE_DEFAULT_Y = 86;
const OUTLINE_ASSUMED_WIDTH = 300;
const OUTLINE_ASSUMED_HEIGHT = 200;
const OUTLINE_GAP = 20;
export type OutlinePosition = "bottom" | "right";
/** Direct-child one:Outline elements of the page, in document order. */
function pageOutlines(doc: any): any[] {
return directChildrenByLocalName(doc.documentElement, "Outline");
}
function findOutline(doc: any, outlineId: string): any | null {
const outlines = pageOutlines(doc);
const trimmed = outlineId.trim();
if (/^[0-9]+$/.test(trimmed)) {
const idx = parseInt(trimmed, 10);
if (idx >= 1 && idx <= outlines.length) return outlines[idx - 1];
}
for (const o of outlines) {
if (getAttr(o, "objectID") === outlineId) return o;
}
return null;
}
function outlineMetrics(outlineEl: any): { x: number; y: number; width?: number; height?: number } {
let x = OUTLINE_DEFAULT_X;
let y = OUTLINE_DEFAULT_Y;
let width: number | undefined;
let height: number | undefined;
const pos = directChildrenByLocalName(outlineEl, "Position")[0];
if (pos) {
const px = parseFloat(getAttr(pos, "x") ?? "");
const py = parseFloat(getAttr(pos, "y") ?? "");
if (!Number.isNaN(px)) x = px;
if (!Number.isNaN(py)) y = py;
}
const size = directChildrenByLocalName(outlineEl, "Size")[0];
if (size) {
const w = parseFloat(getAttr(size, "width") ?? "");
const h = parseFloat(getAttr(size, "height") ?? "");
if (!Number.isNaN(w)) width = w;
if (!Number.isNaN(h)) height = h;
}
return { x, y, width, height };
}
/** Compute where a new outline should sit relative to existing outlines. */
function placementFor(existing: any[], position: OutlinePosition): { x: number; y: number } {
if (!existing.length) return { x: OUTLINE_DEFAULT_X, y: OUTLINE_DEFAULT_Y };
const metrics = existing.map(outlineMetrics);
if (position === "right") {
const maxRight = Math.max(...metrics.map((m) => m.x + (m.width ?? OUTLINE_ASSUMED_WIDTH)));
const minY = Math.min(...metrics.map((m) => m.y));
return { x: maxRight + OUTLINE_GAP, y: minY };
}
const maxBottom = Math.max(...metrics.map((m) => m.y + (m.height ?? OUTLINE_ASSUMED_HEIGHT)));
const minX = Math.min(...metrics.map((m) => m.x));
return { x: minX, y: maxBottom + OUTLINE_GAP };
}
function setOutlinePosition(doc: any, outlineEl: any, x: number, y: number): void {
let pos = directChildrenByLocalName(outlineEl, "Position")[0];
if (!pos) {
pos = doc.createElementNS(ONE_NS, "one:Position");
outlineEl.insertBefore(pos, outlineEl.firstChild);
}
pos.setAttribute("x", x.toFixed(2));
pos.setAttribute("y", y.toFixed(2));
pos.setAttribute("z", "0");
}
function ensureOutlineNamespace(xml: string): string {
if (/^\s*<one:Outline\b/.test(xml) && !/\bxmlns:one\s*=/.test(xml)) {
return xml.replace(/^(\s*<one:Outline)\b/, `$1 xmlns:one="${ONE_NS}"`);
}
return xml;
}
export function countOutlines(pageXml: string): number {
return pageOutlines(parseXml(pageXml)).length;
}
export function getOutlineXml(pageXml: string, outlineId: string): string {
const doc = parseXml(pageXml);
const outline = findOutline(doc, outlineId);
if (!outline) throw new Error(`No outline found for id '${outlineId}' on this page.`);
return ensureOutlineNamespace(new XMLSerializer().serializeToString(outline));
}
/** Parse provided XML into a one:Outline element (wrapping loose OE/content if needed). */
function outlineFromXml(doc: any, outlineXml: string): any {
const wrapped = `<one:__wrap xmlns:one="${ONE_NS}">${outlineXml}</one:__wrap>`;
const fragDoc = parseXml(wrapped);
const wrap = fragDoc.documentElement;
const found = directChildrenByLocalName(wrap, "Outline")[0];
if (found) return (doc as any).importNode(found, true);
// No <one:Outline>: wrap the provided content in one.
const outline = doc.createElementNS(ONE_NS, "one:Outline");
const oeChildren = doc.createElementNS(ONE_NS, "one:OEChildren");
outline.appendChild(oeChildren);
let child = wrap.firstChild;
while (child) {
const next = child.nextSibling;
if (child.nodeType === ELEMENT_NODE) {
const imported = (doc as any).importNode(child, true);
if (localName(imported) === "OE") {
oeChildren.appendChild(imported);
} else {
const oe = doc.createElementNS(ONE_NS, "one:OE");
oe.appendChild(imported);
oeChildren.appendChild(oe);
}
}
child = next;
}
if (!oeChildren.firstChild) throw new Error("outline_xml did not contain any usable content.");
return outline;
}
/** Insert a new outline into the page, placed after existing outlines (bottom or right). */
export function insertOutlineIntoPageXml(pageXml: string, outlineXml: string, position: OutlinePosition): string {
const doc = parseXml(pageXml);
const existing = pageOutlines(doc);
const outline = outlineFromXml(doc, outlineXml);
stripObjectIds(outline);
const { x, y } = placementFor(existing, position);
setOutlinePosition(doc, outline, x, y);
doc.documentElement.appendChild(outline);
return new XMLSerializer().serializeToString(doc);
}
export interface DuplicateOutlineResult {
xml: string;
source_index: number;
}
function outlineOEChildren(doc: any, outlineEl: any): any {
let oec = directChildrenByLocalName(outlineEl, "OEChildren")[0];
if (!oec) {
oec = doc.createElementNS(ONE_NS, "one:OEChildren");
outlineEl.appendChild(oec);
}
return oec;
}
/** Create a new empty outline placed after existing outlines, and append it to the page. */
function createEmptyOutline(doc: any): any {
const existing = pageOutlines(doc);
const outline = doc.createElementNS(ONE_NS, "one:Outline");
outline.appendChild(doc.createElementNS(ONE_NS, "one:OEChildren"));
const { x, y } = placementFor(existing, "bottom");
setOutlinePosition(doc, outline, x, y);
doc.documentElement.appendChild(outline);
return outline;
}
/**
* Resolve the target outline, creating one when appropriate:
* - id given and found -> use it;
* - id given, not found, but the page has NO outlines -> create one;
* - id given, not found, and outlines exist -> error (bad id);
* - no id and outlines exist -> use the first outline;
* - no id and no outlines -> create one.
*/
function ensureOutline(doc: any, outlineId?: string | null): { outline: any; created: boolean } {
const id = (outlineId ?? "").trim();
const outlines = pageOutlines(doc);
if (id) {
const found = findOutline(doc, id);
if (found) return { outline: found, created: false };
if (outlines.length === 0) return { outline: createEmptyOutline(doc), created: true };
throw new Error(`No outline found for id '${id}' on this page.`);
}
if (outlines.length) return { outline: outlines[0], created: false };
return { outline: createEmptyOutline(doc), created: true };
}
/** Ensure the page has a "To Do" TagDef (symbol 3); return the index to reference. */
function ensureTodoTagDef(doc: any): number {
const page = doc.documentElement;
const defs = directChildrenByLocalName(page, "TagDef");
for (const d of defs) {
if (getAttr(d, "symbol") === "3") {
const idx = parseInt(getAttr(d, "index") ?? "", 10);
if (!Number.isNaN(idx)) return idx;
}
}
let maxIdx = -1;
for (const d of defs) {
const i = parseInt(getAttr(d, "index") ?? "", 10);
if (!Number.isNaN(i)) maxIdx = Math.max(maxIdx, i);
}
const newIdx = maxIdx + 1;
const def = doc.createElementNS(ONE_NS, "one:TagDef");
def.setAttribute("index", String(newIdx));
def.setAttribute("type", "0");
def.setAttribute("symbol", "3");
def.setAttribute("fontColor", "automatic");
def.setAttribute("highlightColor", "none");
def.setAttribute("name", "To Do");
// TagDefs must precede Title/Outlines, so place it first on the page.
page.insertBefore(def, page.firstChild);
return newIdx;
}
function buildTodoOE(doc: any, tagIndex: number, inlineHtml: string, completed: boolean): any {
const oe = doc.createElementNS(ONE_NS, "one:OE");
const tag = doc.createElementNS(ONE_NS, "one:Tag");
tag.setAttribute("index", String(tagIndex));
tag.setAttribute("completed", completed ? "true" : "false");
tag.setAttribute("disabled", "false");
oe.appendChild(tag);
const t = doc.createElementNS(ONE_NS, "one:T");
t.appendChild(doc.createCDATASection(inlineHtml));
oe.appendChild(t);
return oe;
}
export interface TodoItemInput {
/** Ready-to-embed inline HTML fragment for the item's one:T (already format-converted). */
html: string;
completed?: boolean;
indentLevel?: number;
}
export interface AddTodoResult {
xml: string;
tag_index: number;
items_added: number;
inserted_position: number;
outline_created: boolean;
}
/**
* Add one or more To Do items to an outline, creating the outline if needed and
* a page TagDef if needed. Each item's indentLevel nests it relative to the
* running structure: depth 0 is the base container (reached by descending the
* last item at each level of any existing structure), and an item can go at
* most one level deeper than the previous item (deeper values are clamped).
* position (1-based) places the FIRST item among the siblings in its container;
* subsequent items follow contiguously as a block. When position is omitted the
* first item is appended (continuing an existing list).
*/
export function addTodoItemsToOutlineXml(
pageXml: string,
outlineId: string | null | undefined,
items: TodoItemInput[],
position?: number | null,
): AddTodoResult {
if (!items || items.length === 0) throw new Error("No to-do items provided.");
const doc = parseXml(pageXml);
const { outline, created } = ensureOutline(doc, outlineId);
const tagIndex = ensureTodoTagDef(doc);
const root = outlineOEChildren(doc, outline);
// containers[d] = the OEChildren that holds OEs at depth d.
// lastOE[d] = the most recent OE we inserted at depth d.
const containers: any[] = [root];
const lastOE: any[] = [];
// Descend existing structure to reach the first item's requested base depth,
// nesting under the last OE at each level (stops early if nothing to nest under).
const baseDepth = Math.max(0, (items[0].indentLevel ?? 0) | 0);
for (let d = 1; d <= baseDepth; d++) {
const parent = containers[d - 1];
const oes = directChildrenByLocalName(parent, "OE");
const lo = oes[oes.length - 1];
if (!lo) break; // can't go deeper — no anchor to nest under
let childOEC = directChildrenByLocalName(lo, "OEChildren")[0];
if (!childOEC) {
childOEC = doc.createElementNS(ONE_NS, "one:OEChildren");
lo.appendChild(childOEC);
}
containers[d] = childOEC;
}
const effectiveBase = containers.length - 1;
let firstInsertedPos = 0;
let prevDepth = -1;
for (let i = 0; i < items.length; i++) {
const it = items[i];
const newOE = buildTodoOE(doc, tagIndex, it.html, !!it.completed);
let depth: number;
if (i === 0) {
depth = effectiveBase;
} else {
const want = Math.max(0, (it.indentLevel ?? 0) | 0);
depth = Math.min(want, prevDepth + 1); // at most one level deeper than previous
}
if (i > 0 && depth > prevDepth) {
// Nest under the previously inserted OE.
const parentOE = lastOE[prevDepth];
let childOEC = directChildrenByLocalName(parentOE, "OEChildren")[0];
if (!childOEC) {
childOEC = doc.createElementNS(ONE_NS, "one:OEChildren");
parentOE.appendChild(childOEC);
}
containers[depth] = childOEC;
childOEC.appendChild(newOE);
} else {
let container = containers[depth];
if (!container) container = containers[depth] = root; // safety fallback
if (i === 0) {
const siblings = directChildrenByLocalName(container, "OE");
if (position == null || position > siblings.length) {
container.appendChild(newOE);
firstInsertedPos = siblings.length + 1;
} else {
const p = Math.max(1, position | 0);
container.insertBefore(newOE, siblings[p - 1]);
firstInsertedPos = p;
}
} else {
// Keep the batch contiguous: insert right after the previous item at this depth.
const ref = lastOE[depth] ? lastOE[depth].nextSibling : null;
if (ref) container.insertBefore(newOE, ref);
else container.appendChild(newOE);
}
}
lastOE[depth] = newOE;
// Moving to depth means anything deeper is no longer the "current" branch.
for (let d = depth + 1; d < containers.length; d++) {
containers[d] = undefined;
lastOE[d] = undefined;
}
prevDepth = depth;
}
return {
xml: new XMLSerializer().serializeToString(doc),
tag_index: tagIndex,
items_added: items.length,
inserted_position: firstInsertedPos,
outline_created: created,
};
}
export interface AddContentResult {
xml: string;
outline_created: boolean;
}
/** Append already-converted one:OE / one:Table content to an outline (creating it if needed). */
export function addContentToOutlineXml(
pageXml: string,
outlineId: string | null | undefined,
oeFragment: string,
): AddContentResult {
const doc = parseXml(pageXml);
const { outline, created } = ensureOutline(doc, outlineId);
const root = outlineOEChildren(doc, outline);
const wrapped = `<one:__wrap xmlns:one="${ONE_NS}">${oeFragment}</one:__wrap>`;
const fragDoc = parseXml(wrapped);
let child = fragDoc.documentElement.firstChild;
let appended = 0;
while (child) {
const next = child.nextSibling;
if (child.nodeType === ELEMENT_NODE) {
root.appendChild((doc as any).importNode(child, true));
appended += 1;
}
child = next;
}
if (appended === 0) throw new Error("No content to add to the outline.");
return { xml: new XMLSerializer().serializeToString(doc), outline_created: created };
}
/** Duplicate an existing outline and place the copy after all outlines (bottom or right). */
export function duplicateOutlineInPageXml(
pageXml: string,
outlineId: string,
position: OutlinePosition,
): DuplicateOutlineResult {
const doc = parseXml(pageXml);
const existing = pageOutlines(doc);
const source = findOutline(doc, outlineId);
if (!source) throw new Error(`No outline found for id '${outlineId}' on this page.`);
const sourceIndex = existing.indexOf(source) + 1;
const clone = source.cloneNode(true);
stripObjectIds(clone);
const { x, y } = placementFor(existing, position);
setOutlinePosition(doc, clone, x, y);
doc.documentElement.appendChild(clone);
return { xml: new XMLSerializer().serializeToString(doc), source_index: sourceIndex };
}
// --- Text find / replace -----------------------------------------------------
/**
* Replace all occurrences of `searchText` with `replacementText` inside every
* <one:T> element on the page. When `preserveFormatting` is true, each matched
* T's inline character formatting (bold/italic/strikethrough/etc.) is kept by
* re-wrapping the replacement text in the same tags. Returns the full updated
* page XML.
*/
export function replaceTextInPageXml(
pageXml: string,
searchText: string,
replacementText: string,
preserveFormatting = true,
): string {
const doc = parseXml(pageXml);
let matches = 0;
walkElements(doc.documentElement, (el) => {
if (localName(el) !== "T") return;
const raw = el.textContent ?? "";
if (!raw.includes(searchText)) return;
// Replace all occurrences in the CDATA fragment.
let fragment: string;
if (preserveFormatting) {
fragment = reformatLikeTemplate(raw, replacementText);
} else {
fragment = plainToInlineHtml(replacementText);
}
while (el.firstChild) el.removeChild(el.firstChild);
el.appendChild(doc.createCDATASection(fragment));
matches += 1;
});
if (matches === 0) throw new Error(`No text found matching '${searchText}' on this page.`);
return new XMLSerializer().serializeToString(doc);
}
export interface HyperlinkReference {
href: string;
object_id: string | null;
container_object_id: string | null;
parent_object_id: string | null;
hyperlink_text: string | null; // The visible display text of the hyperlink anchor itself.
text_snippet: string | null; // Surrounding paragraph text with all HTML stripped, limited by maxSnippetChars.
container_type: string | null; // e.g. "Table Cell", "Outline", "To Do Item".
previous_paragraph_text: string | null; // Plain-text of the paragraph immediately before this reference.
next_paragraph_text: string | null; // Plain-text of the paragraph immediately after this reference.
}
/** Extract canonical IDs from an onenote: URI. Supports both formats:
* - Canonical shorthand: `{sectionGUID}+{pageGUID}`
* - Explicit parameters: `§ion-id={GUID}&page-id={GUID}`
* Handles slashes, HTML entities, URL encoding, and trailing parameters. */
export function normalizeOnenoteHyperlink(url: string): { sectionId: string; pageId: string; objectId?: string } | null {
if (!url || !url.toLowerCase().startsWith("onenote:")) return null;
// Normalize slashes — OneNote URIs mix backslashes and forward slashes.
let normalized = url.replace(/\\/g, "/");
// Decode HTML entities — XML attributes encode `&` as `&`.
normalized = normalized.replace(/&/gi, "&");
let sectionId: string | undefined;
let pageId: string | undefined;
// Try canonical shorthand format first: `{GUID}+{GUID}`.
const guidPairMatch = normalized.match(/[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\+[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/);
if (guidPairMatch) {
const parts = guidPairMatch[0].split('+');
sectionId = parts[0];
pageId = parts[1];
} else {
// Try explicit parameter format: `§ion-id={GUID}&page-id={GUID}`.
const sectionMatch = normalized.match(/§ion-id=[{][0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}[}]/);
const pageMatch = normalized.match(/&page-id=[{][0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}[}]/);
if (sectionMatch && pageMatch) {
sectionId = sectionMatch[0].replace(/§ion-id=[{]/, '').replace(/[}]$/, '');
pageId = pageMatch[0].replace(/&page-id=[{]/, '').replace(/[}]$/, '');
} else {
return null;
}
}
// Also capture optional &object-id={GUID} parameter for paragraph/object-level links.
const objMatch = normalized.match(/&object-id=[{][0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}[}]/);
return { sectionId: sectionId!, pageId: pageId!, objectId: objMatch ? objMatch[0].replace(/&object-id=[{]/, '').replace(/[}]$/, '') : undefined };
}
/** Extract all base GUIDs from a COM API ID (including version/revision suffixes).
* e.g. `{AC0A2936...}{1}{E1820998...}` → `["AC0A2936...", "E1820998..."]` */
export function extractAllBaseGuids(id: string): string[] {
if (!id) return [];
const matches = id.matchAll(/[{][0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}[}]/g);
return [...matches].map(m => m[0].replace(/^[{]/, '').replace(/[}]$/, ''));
}
/** Strip OneNote version/revision suffixes from an ID. COM API returns IDs like `{GUID}{Version}{Revision}` while onenote: URIs use just the base `{GUID}`. */
export function normalizeOneNoteId(id: string): string {
if (!id) return "";
// Remove any trailing `{...}` blocks after the first GUID (version/revision).
const match = id.match(/[{][0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}[}]/);
if (!match) return id;
return match[0].replace(/^[{]/, '').replace(/[}]$/, '');
}
/** Compare two OneNote IDs (with or without version/revision suffixes). */
export function oneNoteIdsMatch(idA: string, idB: string): boolean {
return normalizeOneNoteId(idA.toLowerCase()) === normalizeOneNoteId(idB.toLowerCase());
}
/** Extract canonical IDs from an onenote: URI href. Supports both formats:
* - Canonical shorthand: `{sectionGUID}+{pageGUID}`
* - Explicit parameters: `§ion-id={GUID}&page-id={GUID}` */
export function extractOnenoteIds(href: string): { sectionId?: string; pageId?: string; objectId?: string } | null {
if (!href || !href.toLowerCase().startsWith("onenote:")) return null;
// Normalize slashes — OneNote URIs mix backslashes and forward slashes.
let normalized = href.replace(/\\/g, "/");
// Decode HTML entities — XML attributes encode `&` as `&`.
normalized = normalized.replace(/&/gi, "&");
const result: { sectionId?: string; pageId?: string; objectId?: string } = {};
// Try canonical shorthand format first: `{GUID}+{GUID}`.
const guidPairMatch = normalized.match(/[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\+[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/);
if (guidPairMatch) {
const parts = guidPairMatch[0].split('+');
result.sectionId = parts[0];
result.pageId = parts[1];
} else {
// Try explicit parameter format: `§ion-id={GUID}&page-id={GUID}`.
const sectionMatch = normalized.match(/§ion-id=[{][0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}[}]/);
const pageMatch = normalized.match(/&page-id=[{][0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}[}]/);
if (sectionMatch && pageMatch) {
result.sectionId = sectionMatch[0].replace(/§ion-id=[{]/, '').replace(/[}]$/, '');
result.pageId = pageMatch[0].replace(/&page-id=[{]/, '').replace(/[}]$/, '');
} else {
return null;
}
}
// Also capture optional &object-id={GUID} parameter for paragraph/object-level links.
const objMatch = normalized.match(/&object-id=[{][0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}[}]/);
if (objMatch) result.objectId = objMatch[0].replace(/&object-id=[{]/, '').replace(/[}]$/, '');
return result;
}
/** Walk a page XML and collect all `<one:A>` anchor elements whose `href` resolves to the same destination as the target URL. */
export function findHyperlinkReferencesInPageXml(
xml: string,
targetHref: string,
maxSnippetChars = 100,
): HyperlinkReference[] {
const doc = parseXml(xml);
// Extract canonical IDs from the target URL — this is the primary matching strategy.
// OneNote hyperlinks vary wildly in path formatting (relative vs absolute, slashes vs backslashes),
// trailing parameters (&object-id&N, &base-path, numeric suffixes), and encoding (& vs &, %20).
// The only stable identifiers are section-id, page-id, and optional object-id.
const targetIds = extractOnenoteIds(targetHref);
let hrefMatchesTarget: (href: string) => boolean;
if (targetIds && targetIds.sectionId) {
const sId = targetIds.sectionId!.toLowerCase();
const pId = targetIds.pageId?.toLowerCase() ?? "";
const tObjId = targetIds.objectId?.toLowerCase() ?? "";
hrefMatchesTarget = (href: string) => {
const ids = extractOnenoteIds(href);
if (!ids || !ids.sectionId) return false;
// Must match section-id. If target also specifies page-id, must match that too.
if (ids.sectionId!.toLowerCase() !== sId) return false;
if (pId && ids.pageId?.toLowerCase() !== pId) return false;
// If target specifies an object-id, the anchor must also have that same object-id.
if (tObjId && (!ids.objectId || ids.objectId.toLowerCase() !== tObjId)) return false;
return true;
};
} else {
// No canonical IDs extracted — fall back to prefix match for partial URLs.
const targetLower = targetHref.toLowerCase();
hrefMatchesTarget = (href: string) => href.toLowerCase().startsWith(targetLower);
}
/** Detect container type by walking upward from an element. */
function detectContainerType(el: any): string | null {
let parent = el.parentNode;
while (parent) {
const kind = localName(parent);
if (kind === "Cell") return "Table Cell";
if (kind === "Row") { parent = parent.parentNode; continue; }
if (kind === "Table") { parent = parent.parentNode; continue; }
// Check for To Do Item: Tag immediately before OE in same parent.
if (kind === "OE") {
const grandParent = parent.parentNode;
if (grandParent) {
let prevSibling = parent.previousSibling;
while (prevSibling && localName(prevSibling) !== "Tag") {
prevSibling = prevSibling.previousSibling;
}
if (prevSibling && localName(prevSibling) === "Tag") return "To Do Item";
}
}
// Continue walking upward past OE/Outline to check ancestors.
parent = parent.parentNode;
}
// Fallback: if the anchor is directly inside an OE or Outline, classify as Outline.
const directParentKind = localName(el.parentNode);
if (directParentKind === "OE" || directParentKind === "Outline") return "Outline";
return null;
}
/** Extract adjacent paragraph text from sibling T elements before/after the given element. */
function extractAdjacentParagraphs(el: any): { prevText: string | null; nextText: string | null; debugInfo?: string } {
// Walk up to find the containing OE or Row (the first structural container).
let container = el.parentNode;
while (container) {
const kind = localName(container);
if (kind === "OE" || kind === "Row") break;
container = container.parentNode;
}
if (!container) return { prevText: null, nextText: null };
// Promote to highest-level container (Outline or Table) by walking up through all intermediate layers.
let promotedContainer: any = null;
// Walk up from the initial container, collecting the first Outline/Table/Page we hit.
// If it's a Table that sits inside an Outline via intermediate layers, keep walking to find Outline.
{
let node = container;
while (node && localName(node) !== "Outline" && localName(node) !== "Table" && localName(node) !== "Page") {
node = node.parentNode;
}
// If we hit a Table, check if it's nested inside an Outline via intermediate layers.
if (node && localName(node) === "Table") {
let deeperNode = node;
while (deeperNode && localName(deeperNode) !== "Outline" && localName(deeperNode) !== "Page") {
deeperNode = deeperNode.parentNode;
}
if (deeperNode && localName(deeperNode) === "Outline") {
promotedContainer = deeperNode;
} else {
promotedContainer = node;
}
} else if (node && localName(node) === "Outline") {
promotedContainer = node;
}
}
// Use the promoted container if found, otherwise use the original container.
const finalContainer = promotedContainer || container;
// Collect all sibling T elements from the container using recursive walk in document order.
const siblingTElements: any[] = [];
function collectAllTFromContainer(cont: any): void {
eachChildElement(cont, (siblingEl) => {
if (localName(siblingEl) === "T") {
siblingTElements.push(siblingEl);
} else {
// Recursively walk children at all levels to find T elements.
collectAllTFromContainer(siblingEl);
}
});
}
collectAllTFromContainer(finalContainer);
// Find which index in the array corresponds to our anchor element.
let anchorIndex = -1;
for (let i = 0; i < siblingTElements.length; i++) {
if (siblingTElements[i] === el) {
anchorIndex = i;
break;
}
}
// If the anchor is a standalone <A> element, find its containing T.
if (anchorIndex === -1 && localName(el) === "A") {
let tParent = el.parentNode;
while (tParent) {
const tKind = localName(tParent);
if (tKind === "T" || tKind === "OE" || tKind === "Row" || tKind === "Outline" || tKind === "Table") break;
tParent = tParent.parentNode;
}
if (tParent && localName(tParent) === "T") {
for (let i = 0; i < siblingTElements.length; i++) {
if (siblingTElements[i] === tParent) {
anchorIndex = i;
break;
}
}
}
}
let prevText: string | null = null;
let nextText: string | null = null;
if (anchorIndex >= 0) {
// Previous paragraph: scan up to 3 positions before this one, returning the first non-null text.
for (let offset = 1; offset <= 3 && anchorIndex - offset >= 0; offset++) {
const prevEl = siblingTElements[anchorIndex - offset];
const text = htmlFragmentToText(prevEl.textContent ?? "");
if (text) { prevText = text; break; }
}
// Next paragraph: scan up to 3 positions after this one, returning the first non-null text.
for (let offset = 1; offset <= 3 && anchorIndex + offset < siblingTElements.length; offset++) {
const nextEl = siblingTElements[anchorIndex + offset];
const text = htmlFragmentToText(nextEl.textContent ?? "");
if (text) { nextText = text; break; }
}
}
return { prevText, nextText };
}
// First pass: collect all matching anchors — standalone <A> elements.
const refs: HyperlinkReference[] = [];
const seenRefs: Set<string> = new Set(); // dedup key: href + container_object_id
walkElements(doc.documentElement, (el) => {
if (localName(el) !== "A") return;
const href = getAttr(el, "href");
if (!href || !hrefMatchesTarget(href)) return;
const objectId = getAttr(el, "objectID") ?? null;
// Extract display text from <A> element's children or attributes.
let hyperlinkText: string | null = null;
const displayTextAttr = getAttr(el, "displayText");
if (displayTextAttr) {
hyperlinkText = displayTextAttr.trim();
} else {
// Try to get plain text from child elements (<one:T>, <OE>, etc.).
const extractPlainText = (node: any): string => {
if (!node) return "";
let result = "";
if (node.nodeType === 3 /* TEXT_NODE */) {
result += node.nodeValue ?? "";
} else if (node.childNodes) {
for (let i = 0; i < node.childNodes.length; i++) {
result += extractPlainText(node.childNodes[i]);
}
}
return result;
};
eachChildElement(el, (childEl) => {
const ct = extractPlainText(childEl);
if (ct && !hyperlinkText) hyperlinkText = ct.trim();
});
}
// text_snippet for standalone <A> anchors: use the hyperlink display text itself.
let snippet: string | null = null;
if (maxSnippetChars === 0 || !hyperlinkText) {
snippet = hyperlinkText ?? null;
} else {
snippet = (hyperlinkText ?? "").slice(0, maxSnippetChars);
}
// Container type and adjacent paragraphs for standalone <A> anchors.
const containerType = detectContainerType(el);
let prevParagraph: string | null = null;
let nextParagraph: string | null = null;
if (containerType) {
const adj = extractAdjacentParagraphs(el);
prevParagraph = adj.prevText;
nextParagraph = adj.nextText;
}
refs.push({
href: href,
object_id: objectId,
container_object_id: undefined as any, // resolved below
parent_object_id: undefined as any, // resolved below
hyperlink_text: hyperlinkText,
text_snippet: snippet,
container_type: containerType,
previous_paragraph_text: prevParagraph,
next_paragraph_text: nextParagraph,
});
});
// Second pass: collect container IDs for standalone <A> anchors.
// Use index-based mapping so multiple anchors with the same href but different containers are preserved.
const anchorContainers: Map<number, string | undefined> = new Map();
let currentContainerId: string | undefined;
const reWalk = (node: any): void => {
const kind = localName(node);
const oid = getAttr(node, "objectID") ?? getAttr(node, "ID");
if (oid) currentContainerId = oid;
if (kind === "A") {
const href = getAttr(node, "href");
if (href && hrefMatchesTarget(href)) {
// Find ALL matching anchors in refs to store their container IDs.
for (let i = 0; i < refs.length; i++) {
if (refs[i].href === href) {
anchorContainers.set(i, currentContainerId);
}
}
}
}
eachChildElement(node, reWalk);
};
reWalk(doc.documentElement);
for (let i = 0; i < refs.length; i++) {
const ref = refs[i];
const container = anchorContainers.get(i);
if (container) ref.container_object_id = container;
else ref.container_object_id = null;
// Deduplicate: skip if we've already seen this href+container combo.
const dedupKey = `${ref.href}|${ref.container_object_id ?? "null"}`;
if (seenRefs.has(dedupKey)) {
refs.splice(i, 1);
i--; // adjust index after splice
} else {
seenRefs.add(dedupKey);
}
}
// Third pass: extract inline HTML anchors from CDATA inside <T> elements only.
// OneNote stores many hyperlinks as <a href="..."> inside CDATA blocks rather than
// as standalone <one:A> XML elements. These appear in text content like:
// <one:T><![CDATA[<a href="onenote:...">link text</a>]]></one:T>
const inlineAnchorRegex = /href=["']([^"']*onenote:[^"']*)["']/gi;
walkElements(doc.documentElement, (el) => {
if (localName(el) !== "T") return;
// Get CDATA/text content from this element.
const textContent = el.textContent ?? "";
let match: RegExpExecArray | null;
inlineAnchorRegex.lastIndex = 0;
while ((match = inlineAnchorRegex.exec(textContent)) !== null) {
const href = match[1];
if (!hrefMatchesTarget(href)) continue;
// Extract object-id from the href URL (e.g., &object-id={GUID}).
const ids = extractOnenoteIds(href);
const objectId = ids?.objectId ?? null;
// Find the nearest container object ID by walking UPWARD from this element.
let containerId: string | undefined;
let parent = el.parentNode;
while (parent) {
const oid = getAttr(parent, "objectID") ?? getAttr(parent, "ID");
if (oid) { containerId = oid; break; }
parent = parent.parentNode;
}
// Extract hyperlink display text — the visible text between <a href="..."> and </a>,
// decoded (HTML entities + inline formatting tags stripped).
let hyperlinkText: string | null = null;
const anchorOpenRegex = /<a\s+href=["']([^"']*onenote:[^"']*)["'][^>]*>/gi;
anchorOpenRegex.lastIndex = 0;
while ((anchorOpenRegex.exec(textContent)) !== null) {
if (anchorOpenRegex.lastIndex > match.index - 1 && anchorOpenRegex.lastIndex <= match.index + match[0].length + 5) {
// Found the opening tag for this anchor — extract text until </a>.
const afterTag = textContent.slice(anchorOpenRegex.lastIndex);
const closeMatch = afterTag.match(/<\/a>/i);
if (closeMatch) {
hyperlinkText = htmlFragmentToText(afterTag.slice(0, closeMatch.index));
} else {
// No closing </a> — take remaining text.
hyperlinkText = htmlFragmentToText(afterTag.trim().slice(0, 250));
}
break;
}
}
// Extract text snippet — full paragraph text from the <T> element's CDATA with all HTML stripped.
let snippet: string | null;
if (maxSnippetChars === 0) {
// Unlimited: collect ALL sibling T elements in this paragraph context, decode entities properly.
const parent = el.parentNode;
let fullText = "";
if (parent && localName(parent) !== "Title") {
eachChildElement(parent, (siblingEl) => {
if (localName(siblingEl) === "T") {
fullText += htmlFragmentToText(siblingEl.textContent ?? "");
}
});
} else {
// No parent OE — just use this T's content.
fullText = htmlFragmentToText(textContent);
}
snippet = fullText.replace(/\s+/g, " ").trim();
} else {
// Limited: extract surrounding context around the anchor, strip all HTML properly.
const start = Math.max(0, match.index - 80);
const end = Math.min(textContent.length, match.index + match[0].length + 80);
let rawSnippet = textContent.slice(start, end).trim();
// Strip complete tags first.
rawSnippet = rawSnippet.replace(/<[^>]*>/g, "");
// Remove leftover partial tag fragments at slice boundaries (opening/closing angle brackets).
rawSnippet = rawSnippet.replace(/<[^>]*$/, "").replace(/^<[^>]*/, "");
// Also strip CDATA markers and remaining XML-like fragments (e.g., ]]>, <![CDATA[).
rawSnippet = rawSnippet.replace(/\]\]>\s*|<!\[CDATA\[/g, "");
// Remove any trailing/leading bracket/angle fragments (e.g., ]>, >]) that remain after tag stripping.
rawSnippet = rawSnippet.replace(/^[\]>]+\s*|[\]>]+\s*$/, "");
// Decode HTML entities (", &, <, >).
rawSnippet = rawSnippet.replace(/"/g, '"').replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
snippet = rawSnippet.replace(/\s+/g, " ").trim().slice(0, maxSnippetChars);
}
// Container type and adjacent paragraphs for inline <a href="..."> anchors.
const containerType = detectContainerType(el);
let prevParagraph: string | null = null;
let nextParagraph: string | null = null;
let debugInfo: string | undefined;
if (containerType) {
const adj = extractAdjacentParagraphs(el);
prevParagraph = adj.prevText;
nextParagraph = adj.nextText;
debugInfo = adj.debugInfo;
}
// Dedup key includes match position within the text so multiple instances of same href in same container are kept.
const dedupKey = `${href}|${containerId ?? "null"}|${match.index}`;
if (seenRefs.has(dedupKey)) continue; // Skip duplicate inline anchor at same position.
seenRefs.add(dedupKey);
refs.push({
href: href,
object_id: objectId,
container_object_id: containerId ?? null,
parent_object_id: null,
hyperlink_text: hyperlinkText,
text_snippet: snippet || null,
container_type: containerType,
previous_paragraph_text: prevParagraph,
next_paragraph_text: nextParagraph,
});
}
});
return refs;
}