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 { Parser } from "htmlparser2";
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, template?: any | null): void {
let t = firstTextElement(cellEl);
let fragment: string;
// Determine the formatting source: prefer template if given, else use existing cell content.
let formatSource: string | null = null;
if (template) {
const tTemplate = firstTextElement(template);
if (tTemplate) formatSource = tTemplate.textContent ?? "";
} else if (t) {
formatSource = t.textContent ?? "";
}
if (preserveFormatting && formatSource) {
// Keep character formatting (bold/italic/strikethrough/underline/etc.) by re-wrapping
// the new text in the same inline tags from the template or existing cell.
fragment = reformatLikeTemplate(formatSource, 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);
// Copy OE-level formatting attributes from template (e.g., alignment for horizontal text alignment).
if (template) {
const templateOEC = directChildrenByLocalName(template, "OEChildren")[0];
if (templateOEC) {
const templateOE = directChildrenByLocalName(templateOEC, "OE")[0];
if (templateOE && templateOE.attributes) {
for (let i = 0; i < templateOE.attributes.length; i++) {
const attrName = templateOE.attributes[i].name.toLowerCase();
// Copy formatting attributes like alignment; skip structural ones.
if (attrName === "objectid" || attrName === "lastmodifiedtime") continue;
oe.setAttribute(templateOE.attributes[i].name, templateOE.attributes[i].value);
}
}
}
}
} else {
// If OE already exists and we have a template, copy its formatting attributes too.
if (template) {
const parentOe = t.parentNode;
if (parentOe && localName(parentOe) === "OE") {
const templateOEC = directChildrenByLocalName(template, "OEChildren")[0];
if (templateOEC) {
const templateOE = directChildrenByLocalName(templateOEC, "OE")[0];
if (templateOE && templateOE.attributes) {
for (let i = 0; i < templateOE.attributes.length; i++) {
const attrName = templateOE.attributes[i].name.toLowerCase();
if (attrName === "objectid" || attrName === "lastmodifiedtime") continue;
parentOe.setAttribute(templateOE.attributes[i].name, templateOE.attributes[i].value);
}
}
}
}
}
}
while (t.firstChild) t.removeChild(t.firstChild);
t.appendChild(doc.createCDATASection(fragment));
}
/** Copy Cell-level formatting attributes from a template cell to a target cell. */
function copyCellFormattingAttributes(targetCell: any, templateCell: any | null): void {
if (!templateCell || !targetCell) return;
const attrs = templateCell.attributes;
if (!attrs) return;
for (let i = 0; i < attrs.length; i++) {
const attrName = attrs[i].name.toLowerCase();
// Skip structural/identity attributes; copy formatting ones like vertAlign.
if (attrName === "objectid" || attrName === "lastmodifiedtime") continue;
targetCell.setAttribute(attrs[i].name, attrs[i].value);
}
}
function stripObjectIds(el: any): void {
walkElements(el, (n) => {
if (n.removeAttribute) {
n.removeAttribute("objectID");
n.removeAttribute("lastModifiedTime");
}
});
}
/**
* Remove all <a> (hyperlink) tags from an element tree while preserving their
* children and any formatting attributes on those children. OneNote stores
* hyperlinks as text inside CDATA sections of one:T elements, not as structural
* XML elements — so DOM-based getElementsByTagName won't find them. This strips
* <a> tags from both CDATA content (CDATA-level links) and structural XML-level
* <a> elements while preserving character formatting on children.
*/
function stripHyperlinkTags(el: any): void {
// Strip <a href="...">...</a> from CDATA content of one:T elements.
const tElements = Array.from((el as any).getElementsByTagName("*") || []);
for (const el of tElements) {
if ((el as any).localName?.toLowerCase() !== "t") continue;
// Find CDATA section child and strip <a> tags from its content.
const cdataSection = Array.from((el as any).childNodes || []).find(
(n: any) => n.nodeType === 4 /* CDATA_SECTION_NODE */,
);
if (!cdataSection) continue;
let value = (cdataSection as any).nodeValue ?? "";
// Strip <a href="...">...</a> tags, preserving inner content and formatting.
const stripped = value.replace(/<a\b[^>]*>(.*?)<\/a>/gi, "$1");
if (stripped !== value) {
(cdataSection as any).nodeValue = stripped;
}
}
// Also strip structural XML-level <a> elements (if any exist at DOM level).
const allElements = Array.from((el as any).getElementsByTagName("*") || []);
for (const node of allElements) {
if ((node as any).localName?.toLowerCase() !== "a") continue;
const parent = (node as any).parentNode;
if (!parent) continue;
while ((node as any).firstChild) {
parent.insertBefore((node as any).firstChild, node);
}
parent.removeChild(node);
}
}
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);
stripHyperlinkTags(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 };
}
export interface CloneTableSchemaResult {
/** Updated target page XML. */
xml: string;
/** Properties (column headers or row labels) that were added to the target table. */
added: string[];
/** Properties that existed in target but not source and were deleted from target. */
deleted: string[];
/** Whether any properties were reordered in the target table. */
reordered: boolean;
}
/**
* Clone a table's schema (column headers or row labels) from a source table to a
* target table, reordering and adding/deleting as needed while preserving existing
* data cells and target formatting.
*
* - Horizontal mode: properties are the first-row cell texts (column headers). The
* function ensures the target's columns match the source's column order by deleting
* extra columns, adding missing ones, and reordering to match.
* - Vertical mode: properties are each row's first-column text (row labels). The
* function ensures the target's rows match the source's row label order by deleting
* extra rows, adding missing ones, and reordering to match.
*/
export function cloneTableSchemaXml(
sourcePageXml: string,
sourceTableId: string,
targetPageXml: string,
targetTableId: string,
orientation: "horizontal" | "vertical",
): CloneTableSchemaResult {
const srcDoc = parseXml(sourcePageXml);
const tgtDoc = parseXml(targetPageXml);
const sourceTable = findTable(srcDoc, sourceTableId);
if (!sourceTable) throw new Error(`No table found for id '${sourceTableId}' on the source page.`);
const targetTable = findTable(tgtDoc, targetTableId);
if (!targetTable) throw new Error(`No table found for id '${targetTableId}' on the target page.`);
const added: string[] = [];
const deleted: string[] = [];
let reordered = false;
if (orientation === "horizontal") {
// Properties are first-row cell texts (column headers).
const srcRows = tableRows(sourceTable);
const tgtRows = tableRows(targetTable);
if (!srcRows.length) throw new Error("Source table has no rows.");
if (!tgtRows.length) throw new Error("Target table has no rows.");
// Read source column headers from first row.
const srcHeaderCells = rowCells(srcRows[0]);
const srcHeaders: string[] = srcHeaderCells.map((c) => cellText(c).trim());
// Read target column headers from first row.
const tgtHeaderCells = rowCells(tgtRows[0]);
const tgtHeaders: string[] = tgtHeaderCells.map((c) => cellText(c).trim());
// Build a map of source header -> index for matching.
const srcIndexMap = new Map<string, number>();
for (let i = 0; i < srcHeaders.length; i++) {
if (!srcIndexMap.has(srcHeaders[i])) srcIndexMap.set(srcHeaders[i], i);
}
// Determine which target columns to keep and their desired order.
interface KeepCol {
index: number;
header: string;
srcIndex: number;
}
const keepCols: KeepCol[] = [];
for (let i = 0; i < tgtHeaders.length; i++) {
if (srcIndexMap.has(tgtHeaders[i])) {
keepCols.push({ index: i, header: tgtHeaders[i], srcIndex: srcIndexMap.get(tgtHeaders[i])! });
} else {
deleted.push(tgtHeaders[i]);
}
}
// Sort kept columns by source order.
const sortedKeep = [...keepCols].sort((a, b) => a.srcIndex - b.srcIndex);
// Check if reordering is needed.
reordered = keepCols.length !== sortedKeep.length ||
keepCols.some((k, i) => k.header !== sortedKeep[i]?.header);
// Determine which columns need to be added and where.
const keptHeadersSet = new Set(keepCols.map((k) => k.header));
interface AddCol {
header: string;
srcIndex: number;
}
const addCols: AddCol[] = [];
for (let i = 0; i < srcHeaders.length; i++) {
if (!keptHeadersSet.has(srcHeaders[i])) {
addCols.push({ header: srcHeaders[i], srcIndex: i });
}
}
// Merge kept and added columns into final desired order.
interface FinalCol {
header: string;
fromTarget?: number; // original target column index if kept
newHeader?: boolean; // true if this is a newly added column
}
const finalCols: FinalCol[] = [];
let keepPtr = 0;
let addPtr = 0;
while (keepPtr < sortedKeep.length || addPtr < addCols.length) {
if (addPtr < addCols.length && (keepPtr >= sortedKeep.length || addCols[addPtr].srcIndex < sortedKeep[keepPtr].srcIndex)) {
finalCols.push({ header: addCols[addPtr].header, newHeader: true });
added.push(addCols[addPtr].header);
addPtr++;
} else {
finalCols.push({ header: sortedKeep[keepPtr].header, fromTarget: sortedKeep[keepPtr].index });
keepPtr++;
}
}
// Now rebuild the target table's columns and rows to match.
const numFinalCols = finalCols.length;
// Update Columns element widths if it exists.
let colsEl = directChildrenByLocalName(targetTable, "Columns")[0];
if (colsEl) {
while (colsEl.firstChild) colsEl.removeChild(colsEl.firstChild);
for (let i = 0; i < numFinalCols; i++) {
const col = tgtDoc.createElementNS(ONE_NS, "one:Column");
col.setAttribute("width", "150"); // default width
colsEl.appendChild(col);
}
} else {
colsEl = tgtDoc.createElementNS(ONE_NS, "one:Columns");
for (let i = 0; i < numFinalCols; i++) {
const col = tgtDoc.createElementNS(ONE_NS, "one:Column");
col.setAttribute("width", "150");
colsEl.appendChild(col);
}
targetTable.insertBefore(colsEl, targetTable.firstChild);
}
// Rebuild each row's cells to match the final column order.
for (const tgtRow of tgtRows) {
const oldCells = rowCells(tgtRow);
while (tgtRow.firstChild && localName(tgtRow.firstChild) === "Cell") {
tgtRow.removeChild(tgtRow.firstChild);
}
// Build a map from header -> cell content for kept columns.
const cellByHeader = new Map<string, any>();
for (let i = 0; i < oldCells.length && i < tgtHeaders.length; i++) {
if (!cellByHeader.has(tgtHeaders[i])) {
cellByHeader.set(tgtHeaders[i], oldCells[i]);
}
}
// Build a map from header -> source formatting template for new columns.
const fmtTemplateByHeader = new Map<string, any>();
for (let i = 0; i < srcHeaderCells.length && i < srcHeaders.length; i++) {
if (!fmtTemplateByHeader.has(srcHeaders[i])) {
fmtTemplateByHeader.set(srcHeaders[i], srcHeaderCells[i]);
}
}
// Create cells in final order.
for (const fc of finalCols) {
let cell: any;
if (fc.newHeader) {
// New column: create empty cell with header text in first row only, copying source formatting.
cell = tgtDoc.createElementNS(ONE_NS, "one:Cell");
const isHeaderRow = tgtRows.indexOf(tgtRow) === 0;
if (isHeaderRow) {
// Copy the source table's corresponding header cell's formatting to this new column.
let template: any | null = null;
for (let i = 0; i < srcHeaderCells.length && i < srcHeaders.length; i++) {
if (!template && cellText(srcHeaderCells[i]).trim() === fc.header) {
template = srcHeaderCells[i];
break;
}
}
setCellText(tgtDoc, cell, fc.header, true, template);
copyCellFormattingAttributes(cell, template);
} else {
// Empty data cell.
const oeChildren = tgtDoc.createElementNS(ONE_NS, "one:OEChildren");
const oe = tgtDoc.createElementNS(ONE_NS, "one:OE");
const t = tgtDoc.createElementNS(ONE_NS, "one:T");
t.appendChild(tgtDoc.createCDATASection(""));
oe.appendChild(t);
oeChildren.appendChild(oe);
cell.appendChild(oeChildren);
}
} else {
// Kept column: reuse existing cell content.
const origIdx = fc.fromTarget!;
if (origIdx < oldCells.length) {
cell = oldCells[origIdx];
} else {
cell = tgtDoc.createElementNS(ONE_NS, "one:Cell");
const oeChildren = tgtDoc.createElementNS(ONE_NS, "one:OEChildren");
const oe = tgtDoc.createElementNS(ONE_NS, "one:OE");
const t = tgtDoc.createElementNS(ONE_NS, "one:T");
t.appendChild(tgtDoc.createCDATASection(""));
oe.appendChild(t);
oeChildren.appendChild(oe);
cell.appendChild(oeChildren);
}
}
tgtRow.appendChild(cell);
}
}
} else {
// Vertical mode: properties are each row's first-column text (row labels).
const srcRows = tableRows(sourceTable);
const tgtRows = tableRows(targetTable);
if (!srcRows.length) throw new Error("Source table has no rows.");
if (!tgtRows.length) throw new Error("Target table has no rows.");
// Read source row labels from first column.
const srcLabels: string[] = [];
for (const row of srcRows) {
const cells = rowCells(row);
if (cells.length > 0) {
srcLabels.push(cellText(cells[0]).trim());
} else {
srcLabels.push("");
}
}
// Read target row labels from first column.
interface TgtRowInfo {
index: number;
label: string;
element: any;
}
const tgtRowsInfo: TgtRowInfo[] = [];
for (let i = 0; i < tgtRows.length; i++) {
const cells = rowCells(tgtRows[i]);
const label = cells.length > 0 ? cellText(cells[0]).trim() : "";
tgtRowsInfo.push({ index: i, label, element: tgtRows[i] });
}
// Build a map of source label -> first occurrence index.
const srcIndexMap = new Map<string, number>();
for (let i = 0; i < srcLabels.length; i++) {
if (!srcIndexMap.has(srcLabels[i])) srcIndexMap.set(srcLabels[i], i);
}
// Determine which target rows to keep and their desired order.
interface KeepRow {
index: number;
label: string;
srcIndex: number;
element: any;
}
const keepRows: KeepRow[] = [];
for (const tr of tgtRowsInfo) {
if (srcIndexMap.has(tr.label)) {
keepRows.push({ index: tr.index, label: tr.label, srcIndex: srcIndexMap.get(tr.label)!, element: tr.element });
} else {
deleted.push(tr.label);
}
}
// Sort kept rows by source order.
const sortedKeep = [...keepRows].sort((a, b) => a.srcIndex - b.srcIndex);
// Check if reordering is needed.
reordered = keepRows.length !== sortedKeep.length ||
keepRows.some((k, i) => k.label !== sortedKeep[i]?.label);
// Determine which rows need to be added and where.
const keptLabelsSet = new Set(keepRows.map((k) => k.label));
interface AddRow {
label: string;
srcIndex: number;
}
const addRows: AddRow[] = [];
for (let i = 0; i < srcLabels.length; i++) {
if (!keptLabelsSet.has(srcLabels[i])) {
addRows.push({ label: srcLabels[i], srcIndex: i });
}
}
// Merge kept and added rows into final desired order.
interface FinalRow {
label: string;
fromTarget?: any; // original target row element if kept
newLabel?: boolean; // true if this is a newly added row
}
const finalRows: FinalRow[] = [];
let keepPtr = 0;
let addPtr = 0;
while (keepPtr < sortedKeep.length || addPtr < addRows.length) {
if (addPtr < addRows.length && (keepPtr >= sortedKeep.length || addRows[addPtr].srcIndex < sortedKeep[keepPtr].srcIndex)) {
finalRows.push({ label: addRows[addPtr].label, newLabel: true });
added.push(addRows[addPtr].label);
addPtr++;
} else {
finalRows.push({ label: sortedKeep[keepPtr].label, fromTarget: sortedKeep[keepPtr].element });
keepPtr++;
}
}
// Determine max column count across source table for new rows.
const srcMaxCols = Math.max(...srcRows.map((r) => rowCells(r).length));
// Rebuild the target table's rows in final order.
// First, remove all existing rows from the table.
while (targetTable.firstChild && localName(targetTable.firstChild) === "Row") {
targetTable.removeChild(targetTable.firstChild);
}
for (const fr of finalRows) {
let row: any;
if (fr.newLabel) {
// New row: create it with the label in first column and empty cells, copying source formatting.
row = tgtDoc.createElementNS(ONE_NS, "one:Row");
const numCols = Math.max(srcMaxCols, 1);
for (let c = 0; c < numCols; c++) {
const cell = tgtDoc.createElementNS(ONE_NS, "one:Cell");
if (c === 0) {
// First column gets the label. Copy the source table's corresponding row label cell's formatting to this new row.
let template: any | null = null;
for (const srcRow of srcRows) {
const cells = rowCells(srcRow);
if (cells.length > 0 && cellText(cells[0]).trim() === fr.label) {
template = cells[0];
break;
}
}
setCellText(tgtDoc, cell, fr.label, true, template);
copyCellFormattingAttributes(cell, template);
} else {
// Other columns are empty.
const oeChildren = tgtDoc.createElementNS(ONE_NS, "one:OEChildren");
const oe = tgtDoc.createElementNS(ONE_NS, "one:OE");
const t = tgtDoc.createElementNS(ONE_NS, "one:T");
t.appendChild(tgtDoc.createCDATASection(""));
oe.appendChild(t);
oeChildren.appendChild(oe);
cell.appendChild(oeChildren);
}
row.appendChild(cell);
}
} else {
// Kept row: reuse existing row element.
row = fr.fromTarget;
}
targetTable.appendChild(row);
}
}
return {
xml: new XMLSerializer().serializeToString(tgtDoc),
added,
deleted,
reordered,
};
}
/**
* 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;
// Assumed dimensions when Size element is missing (in pixels). OneNote outlines are typically large.
const OUTLINE_ASSUMED_WIDTH = 400; // ~400 pixels wide
const OUTLINE_ASSUMED_HEIGHT = 300; // ~300 pixels tall
const OUTLINE_GAP = 20; // pixels
/** Extra height/width added for overlap detection in add_buffer_between_page_outlines (in pixels). OneNote's reported dimensions don't match visual rendering, so we add a small buffer to catch near-touching outlines. */
const OUTLINE_HEIGHT_ADJUSTMENT_PIXELS = 21; // 21 pixels — used by addBufferBetweenPageOutlines for overlap detection.
const OUTLINE_WIDTH_ADJUSTMENT_PIXELS = 15; // 15 pixels — used by addBufferBetweenPageOutlines for overlap detection.
/** Height/width adjustment for vertical overlap detection in set_all_outlines_width_on_page (in pixels). Negative values shrink the effective height to avoid false overlaps between stacked outlines. */
const OUTLINE_HEIGHT_ADJUSTMENT_FOR_WIDTH_SET = -15; // -15 pixels — used by setAllOutlinesWidthOnPage for vertical overlap detection.
const OUTLINE_WIDTH_ADJUSTMENT_FOR_WIDTH_SET = -9; // -9 pixels — used by setAllOutlinesWidthOnPage for width calculations.
export interface OutlineInfo {
element: any;
x: number;
y: number;
width: number;
height: number;
}
/** Check if two rectangles overlap. */
function rectsOverlap(a: OutlineInfo, b: OutlineInfo): boolean {
return !(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y);
}
/** Check if two rectangles overlap vertically (same X range but different Y). */
function rectsOverlapVertically(a: OutlineInfo, b: OutlineInfo): boolean {
const xOverlap = !(a.x + a.width <= b.x || b.x + b.width <= a.x);
return xOverlap && (Math.max(a.y, b.y) < Math.min(a.y + a.height, b.y + b.height));
}
/** Check if two rectangles overlap horizontally (same Y range but different X). */
function rectsOverlapHorizontally(a: OutlineInfo, b: OutlineInfo): boolean {
const yOverlap = !(a.y + a.height <= b.y || b.y + b.height <= a.y);
return yOverlap && (Math.max(a.x, b.x) < Math.min(a.x + a.width, b.x + b.width));
}
/** Calculate the amount of overlap on each axis in twips. Returns { xOverlap, yOverlap } or null if no overlap. */
function calcAxisOverlaps(a: OutlineInfo, b: OutlineInfo): { xOverlap: number; yOverlap: number } | null {
const xStart = Math.max(a.x, b.x);
const xEnd = Math.min(a.x + a.width, b.x + b.width);
const xOverlap = xEnd - xStart;
const yStart = Math.max(a.y, b.y);
const yEnd = Math.min(a.y + a.height, b.y + b.height);
const yOverlap = yEnd - yStart;
if (xOverlap <= 0 || yOverlap <= 0) return null; // No overlap.
return { xOverlap, yOverlap };
}
/**
* Adjust outline positions on a page to ensure they are separated by the given buffer.
* Returns updated page XML and count of outlines adjusted.
*/
export function addBufferBetweenPageOutlines(
pageXml: string,
bufferPixels: number,
adjustmentMode: "only_overlapped" | "all_directions" | "top_to_bottom" | "left_to_right",
horizontalAdjustmentBehavior?: "only_same_row" | "all_outlines",
verticalAdjustmentBehavior?: "only_same_column" | "all_outlines",
): { xml: string; outlines_adjusted: number } {
const doc = parseXml(pageXml);
// OneNote XML uses pixels for Position/Size, so use buffer directly without conversion.
const bufferPixelsEffective = Math.max(0, bufferPixels);
if (bufferPixels < 0) throw new Error("buffer_pixels must be non-negative.");
const outlines = pageOutlines(doc)
.filter((el) => outlineHasContent(el)) // Skip empty/hidden outlines (e.g., OneNote artifacts).
.map((el) => {
const m = outlineMetrics(el);
// Add small buffers because OneNote's reported dimensions don't match visual rendering.
return {
element: el,
x: m.x,
y: m.y,
width: (m.width ?? OUTLINE_ASSUMED_WIDTH) + OUTLINE_WIDTH_ADJUSTMENT_PIXELS,
height: (m.height ?? OUTLINE_ASSUMED_HEIGHT) + OUTLINE_HEIGHT_ADJUSTMENT_PIXELS,
} as OutlineInfo;
});
if (outlines.length <= 1) {
return { xml: new XMLSerializer().serializeToString(doc), outlines_adjusted: 0 };
}
let adjusted = 0;
switch (adjustmentMode) {
case "only_overlapped":
adjusted = adjustOnlyOverlapped(outlines, bufferPixelsEffective);
break;
case "all_directions":
adjusted = adjustAllDirections(
outlines,
bufferPixelsEffective,
horizontalAdjustmentBehavior ?? "only_same_row",
verticalAdjustmentBehavior ?? "only_same_column",
);
break;
case "top_to_bottom":
adjusted = adjustTopToBottom(outlines, bufferPixelsEffective);
break;
case "left_to_right":
adjusted = adjustLeftToRight(outlines, bufferPixelsEffective);
break;
}
// Write new positions back to the XML.
for (const o of outlines) {
setOutlinePosition(doc, o.element, o.x, o.y);
}
return { xml: new XMLSerializer().serializeToString(doc), outlines_adjusted: adjusted };
}
/** Only move outlines that actually overlap with another outline (both axes). */
function adjustOnlyOverlapped(outlines: OutlineInfo[], bufferPixelsEffective: number): number {
let adjusted = 0;
// Sort by Y then X for consistent processing order.
const sorted = [...outlines].sort((a, b) => a.y - b.y || a.x - b.x);
for (let i = 0; i < sorted.length; i++) {
let movedX = false;
let movedY = false;
for (let j = 0; j < sorted.length; j++) {
if (i === j) continue;
const a = sorted[i];
const b = sorted[j];
// Calculate overlap amounts on each axis.
const overlaps = calcAxisOverlaps(a, b);
if (!overlaps) continue; // No overlap between these two outlines.
const { xOverlap, yOverlap } = overlaps;
// Decide which axis to move along: prefer the one with LESS overlap (easier separation).
// If equal, prioritize horizontal movement.
const moveHorizontally = xOverlap <= yOverlap;
if (moveHorizontally && !movedX) {
// Only move 'a' rightward if it's on the right side of 'b'. Never push left to avoid disrupting layout.
if (a.x >= b.x) {
const targetX = b.x + b.width + bufferPixelsEffective;
if (targetX > a.x) {
a.x = targetX;
movedX = true;
adjusted++;
}
}
} else if (!moveHorizontally && !movedY) {
// Only move 'a' downward if it's below 'b'. Never push up to avoid overlapping page title.
if (a.y >= b.y) {
const targetY = b.y + b.height + bufferPixelsEffective;
if (targetY > a.y) {
a.y = targetY;
movedY = true;
adjusted++;
}
}
}
}
}
return adjusted;
}
/** Ensure all outlines are separated by at least the buffer distance while preserving relative layout. */
function adjustAllDirections(
outlines: OutlineInfo[],
bufferPixelsEffective: number,
horizontalBehavior: "only_same_row" | "all_outlines",
verticalBehavior: "only_same_column" | "all_outlines",
): number {
let adjusted = 0;
// Tolerance for detecting same row/column (floating point positions may not be exact).
const positionTolerance = 5;
// Anchor positions: don't move outlines at these positions.
const anchorX = OUTLINE_DEFAULT_X; // 36 — left edge of page
const anchorY = OUTLINE_DEFAULT_Y; // 86 (but actual pages may use ~68.4)
// Detect the actual top anchor Y from the first row of outlines on this page.
const sortedByY = [...outlines].sort((a, b) => a.y - b.y);
const detectedAnchorY = sortedByY[0]?.y ?? anchorY;
// Store original positions for row/column detection (before any adjustments).
const origX: number[] = outlines.map((o) => o.x);
const origY: number[] = outlines.map((o) => o.y);
/** Check if two outlines are in the same row based on their ORIGINAL y positions. */
function sameRow(i: number, j: number): boolean {
return Math.abs(origY[i] - origY[j]) <= positionTolerance;
}
/** Check if two outlines are in the same column based on their ORIGINAL x positions. */
function sameColumn(i: number, j: number): boolean {
return Math.abs(origX[i] - origX[j]) <= positionTolerance;
}
// ========== HORIZONTAL PASS (left-to-right) ==========
// Sort outlines by original x position for left-to-right processing.
const indicesByX = Array.from({ length: outlines.length }, (_, i) => i).sort((a, b) => origX[a] - origX[b]);
if (horizontalBehavior === "only_same_row") {
// Group outlines into rows based on original y positions.
const rows: number[][] = [];
for (const idx of indicesByX) {
let placed = false;
for (const row of rows) {
if (row.some((rIdx) => sameRow(rIdx, idx))) {
row.push(idx);
placed = true;
break;
}
}
if (!placed) rows.push([idx]);
}
// For each row, pack outlines left-to-right with buffer spacing.
for (const row of rows) {
let maxRight = -Infinity;
for (const idx of row) {
const o = outlines[idx];
if (Math.abs(o.x - anchorX) <= positionTolerance) {
// This outline is at the left anchor — don't move it, but update maxRight.
maxRight = Math.max(maxRight, o.x + o.width);
} else {
const targetX = maxRight + bufferPixelsEffective;
if (targetX !== o.x) {
o.x = targetX;
adjusted++;
}
maxRight = o.x + o.width;
}
}
}
} else {
// "all_outlines": pack all outlines left-to-right regardless of row.
let maxRight = -Infinity;
for (const idx of indicesByX) {
const o = outlines[idx];
if (Math.abs(o.x - anchorX) <= positionTolerance) {
maxRight = Math.max(maxRight, o.x + o.width);
} else {
const targetX = maxRight + bufferPixelsEffective;
if (targetX !== o.x) {
o.x = targetX;
adjusted++;
}
maxRight = o.x + o.width;
}
}
}
// ========== VERTICAL PASS (top-to-bottom) ==========
// Sort outlines by original y position for top-to-bottom processing.
const indicesByY = Array.from({ length: outlines.length }, (_, i) => i).sort((a, b) => origY[a] - origY[b]);
if (verticalBehavior === "only_same_column") {
// Group outlines into columns based on ORIGINAL x positions.
const columns: number[][] = [];
for (const idx of indicesByY) {
let placed = false;
for (const col of columns) {
if (col.some((cIdx) => sameColumn(cIdx, idx))) {
col.push(idx);
placed = true;
break;
}
}
if (!placed) columns.push([idx]);
}
// For each column, pack outlines top-to-bottom with buffer spacing.
for (const col of columns) {
let maxBottom = -Infinity;
for (const idx of col) {
const o = outlines[idx];
if (Math.abs(o.y - detectedAnchorY) <= positionTolerance) {
// This outline is at the top anchor — don't move it, but update maxBottom.
maxBottom = Math.max(maxBottom, o.y + o.height);
} else {
const targetY = maxBottom + bufferPixelsEffective;
if (targetY !== o.y) {
o.y = targetY;
adjusted++;
}
maxBottom = o.y + o.height;
}
}
}
} else {
// "all_outlines": pack all outlines top-to-bottom regardless of column.
let maxBottom = -Infinity;
for (const idx of indicesByY) {
const o = outlines[idx];
if (Math.abs(o.y - detectedAnchorY) <= positionTolerance) {
maxBottom = Math.max(maxBottom, o.y + o.height);
} else {
const targetY = maxBottom + bufferPixelsEffective;
if (targetY !== o.y) {
o.y = targetY;
adjusted++;
}
maxBottom = o.y + o.height;
}
}
}
return adjusted;
}
/** Adjust outlines vertically: only push apart outlines that share horizontal space. */
function adjustTopToBottom(outlines: OutlineInfo[], bufferPixelsEffective: number): number {
let adjusted = 0;
// Sort by Y position (top edge).
const sorted = [...outlines].sort((a, b) => a.y - b.y);
for (let i = 0; i < sorted.length; i++) {
for (let j = i + 1; j < sorted.length; j++) {
const a = sorted[i]; // higher outline
const b = sorted[j]; // lower outline
// Only adjust if they share horizontal space.
const xOverlap = !(a.x + a.width <= b.x || b.x + b.width <= a.x);
if (!xOverlap) continue;
// Ensure 'b' is below 'a' by at least buffer.
const minY = a.y + a.height + bufferPixelsEffective;
if (b.y < minY) {
b.y = minY;
adjusted++;
}
}
}
return adjusted;
}
/** Adjust outlines horizontally: only push apart outlines that share vertical space. */
function adjustLeftToRight(outlines: OutlineInfo[], bufferPixelsEffective: number): number {
let adjusted = 0;
// Sort by X position (left edge).
const sorted = [...outlines].sort((a, b) => a.x - b.x);
for (let i = 0; i < sorted.length; i++) {
for (let j = i + 1; j < sorted.length; j++) {
const a = sorted[i]; // left outline
const b = sorted[j]; // right outline
// Only adjust if they share vertical space.
const yOverlap = !(a.y + a.height <= b.y || b.y + b.height <= a.y);
if (!yOverlap) continue;
// Ensure 'b' is to the right of 'a' by at least buffer.
const minX = a.x + a.width + bufferPixelsEffective;
if (b.x < minX) {
b.x = minX;
adjusted++;
}
}
}
return adjusted;
}
export type OutlinePosition = "bottom" | "right";
/** All one:Outline elements on the page (including nested outlines), in document order. */
function pageOutlines(doc: any): any[] {
const allOutlines: any[] = [];
function collect(node: any) {
if (!node || !node.localName) return;
if (node.localName === "Outline") {
allOutlines.push(node);
}
// Recurse into children to find nested outlines.
for (let i = 0; i < node.childNodes.length; i++) {
collect(node.childNodes[i]);
}
}
collect(doc.documentElement);
return allOutlines;
}
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 };
}
/** Check if an outline has visible content (text or child elements). Used to skip empty/hidden outlines. */
function outlineHasContent(outlineEl: any): boolean {
// Check for OEChildren with nested OEs (paragraphs) that contain T (text) elements.
const oeChildren = directChildrenByLocalName(outlineEl, "OEChildren")[0];
if (!oeChildren) return false;
function hasText(node: any): boolean {
if (!node || !node.childNodes) return false;
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i];
if (child.localName === "T" && child.textContent?.trim()) return true;
if (hasText(child)) return true;
}
return false;
}
return hasText(oeChildren);
}
/** 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;
}
/** Set all outlines on a page to the same width and adjust adjacent outlines' positions accordingly. */
export interface SetAllOutlinesWidthResult {
xml: string;
outlines_adjusted: number;
tables_adjusted: number;
}
export function setAllOutlinesWidthOnPage(
pageXml: string,
targetWidthPixels: number,
targetTableIndex?: number, // 1-based column index; default=9999 means last column; 0 disables table adjustment.
): SetAllOutlinesWidthResult {
const doc = parseXml(pageXml);
if (targetWidthPixels <= 0) throw new Error("target_width_pixels must be positive.");
// Default: use last column of each table for width adjustments.
const effectiveTableIndex = targetTableIndex ?? 9999;
const adjustTables = effectiveTableIndex > 0;
const outlines = pageOutlines(doc)
.filter((el) => outlineHasContent(el)) // Skip empty/hidden outlines.
.map((el) => {
const m = outlineMetrics(el);
// Apply adjustment buffers specific to this function for accurate vertical overlap detection.
return {
element: el,
x: m.x,
y: m.y,
width: (m.width ?? OUTLINE_ASSUMED_WIDTH) + OUTLINE_WIDTH_ADJUSTMENT_FOR_WIDTH_SET,
height: (m.height ?? OUTLINE_ASSUMED_HEIGHT) + OUTLINE_HEIGHT_ADJUSTMENT_FOR_WIDTH_SET,
} as OutlineInfo;
});
if (outlines.length === 0) {
return { xml: new XMLSerializer().serializeToString(doc), outlines_adjusted: 0, tables_adjusted: 0 };
}
let adjustedCount = 0;
let tablesAdjustedCount = 0;
// Sort all outlines by x position.
const sortedByX = [...outlines].sort((a, b) => a.x - b.x);
/** Check if two outlines share vertical space (overlap in Y axis). */
function sharesVerticalSpace(a: OutlineInfo, b: OutlineInfo): boolean {
return !(a.y + a.height <= b.y || b.y + b.height <= a.y);
}
// Track cumulative shift for each outline based on width changes of neighbors to its left.
const shifts = new Map<OutlineInfo, number>();
for (const o of outlines) shifts.set(o, 0);
for (const o of sortedByX) {
const originalWidth = o.width;
const widthDelta = targetWidthPixels - originalWidth;
if (Math.abs(widthDelta) > 0.1) {
setOutlineSize(doc, o.element, targetWidthPixels);
adjustedCount++;
}
// Adjust tables within this outline if enabled.
if (adjustTables) {
const tablesInOutline = collectDescendantElements(o.element, "Table");
for (const table of tablesInOutline) {
tablesAdjustedCount += adjustTableWidthToMatch(doc, table, targetWidthPixels, effectiveTableIndex);
}
}
// Propagate this outline's width change to outlines on its right that share vertical space.
if (Math.abs(widthDelta) > 0.1) {
for (const other of sortedByX) {
if (other.x <= o.x) continue; // Skip outlines at or left of current one.
if (!sharesVerticalSpace(o, other)) continue; // Only affect vertically overlapping neighbors.
shifts.set(other, (shifts.get(other) ?? 0) + widthDelta);
}
}
}
// Apply all accumulated position shifts.
for (const [o, shift] of shifts.entries()) {
if (Math.abs(shift) > 0.1) {
const newX = o.x + shift;
setOutlinePosition(doc, o.element, newX, o.y);
}
}
return { xml: new XMLSerializer().serializeToString(doc), outlines_adjusted: adjustedCount, tables_adjusted: tablesAdjustedCount };
}
/** Set or update the Size element of an outline. */
function setOutlineSize(doc: any, outlineEl: any, widthPixels: number): void {
let size = directChildrenByLocalName(outlineEl, "Size")[0];
if (!size) {
size = doc.createElementNS(ONE_NS, "one:Size");
outlineEl.insertBefore(size, outlineEl.firstChild);
}
size.setAttribute("width", widthPixels.toFixed(2));
}
/** Collect all descendant elements with the given local name. */
function collectDescendantElements(node: any, localNameTarget: string): any[] {
const results: any[] = [];
walkElements(node, (el) => {
if (localName(el) === localNameTarget) results.push(el);
});
return results;
}
/** Adjust a table's column widths so its total width matches targetWidthPixels. Returns 1 if adjusted, 0 otherwise. */
function adjustTableWidthToMatch(doc: any, tableEl: any, targetWidthPixels: number, targetColumnIndex: number): number {
const colsEl = directChildrenByLocalName(tableEl, "Columns")[0];
if (!colsEl) return 0;
const columns = directChildrenByLocalName(colsEl, "Column");
if (columns.length === 0) return 0;
// Calculate current total table width from column widths.
let currentTotalWidth = 0;
for (const col of columns) {
const wStr = getAttr(col, "width") ?? "";
const w = parseFloat(wStr);
if (!Number.isNaN(w)) {
currentTotalWidth += w;
}
}
if (currentTotalWidth <= 0) return 0;
// Determine which column to adjust: use targetColumnIndex, but clamp to last column if beyond range.
const numColumns = columns.length;
const adjustedColIdx = Math.min(Math.max(targetColumnIndex, 1), numColumns); // Clamp to [1, numColumns].
// Calculate the width delta needed and apply it entirely to the target column.
const widthDelta = targetWidthPixels - currentTotalWidth;
if (Math.abs(widthDelta) <= 0.1) return 0; // No meaningful change needed.
const colEl = columns[adjustedColIdx - 1]; // Convert from 1-based to 0-based index.
const currentColWidthStr = getAttr(colEl, "width") ?? "";
let currentColWidth = parseFloat(currentColWidthStr);
if (Number.isNaN(currentColWidth)) currentColWidth = 50; // Default fallback width.
// Ensure the adjusted column doesn't go negative or too small.
const newColWidth = Math.max(10, currentColWidth + widthDelta);
colEl.setAttribute("width", newColWidth.toFixed(2));
return 1;
}
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);
}
/**
* Find any element in the document by its objectID attribute. Returns null if not found.
*/
function findElementByObjectId(doc: any, objectId: string): any | null {
let found: any = null;
walkElements(doc.documentElement, (el) => {
if (!found && getAttr(el, "objectID") === objectId) found = el;
});
return found;
}
/**
* Wrap all occurrences of `searchText` with a hyperlink anchor inside every
* <one:T> element on the page. If containerObjectId is provided, only search
* within descendants of that container (e.g. a table, cell, outline, or OE).
* Existing inline formatting (bold/italic/etc.) around or within matched text
* is preserved by inserting the <a> tag at the correct nesting level. Returns
* the full updated page XML and match count.
*/
export function setHyperlinkOnTextInPageXml(
pageXml: string,
searchText: string,
hyperlinkUrl: string,
containerObjectId?: string | null,
): { xml: string; matches: number } {
const doc = parseXml(pageXml);
let matches = 0;
// If a container is specified, find it and scope the search to its descendants.
let rootToSearch: any = doc.documentElement;
if (containerObjectId) {
const container = findElementByObjectId(doc, containerObjectId);
if (!container) throw new Error(`No object found with ID '${containerObjectId}' on this page.`);
rootToSearch = container;
}
walkElements(rootToSearch, (el) => {
if (localName(el) !== "T") return;
const raw = el.textContent ?? "";
if (!raw.includes(searchText)) return;
// Parse the CDATA fragment as HTML and wrap matched text with <a> tags.
const newFragment = wrapTextWithHyperlink(raw, searchText, hyperlinkUrl);
matches += countOccurrences(newFragment, `href="${escapeAttr(hyperlinkUrl)}"`);
while (el.firstChild) el.removeChild(el.firstChild);
el.appendChild(doc.createCDATASection(newFragment));
});
if (matches === 0) throw new Error(`No text found matching '${searchText}' on this page.`);
return { xml: new XMLSerializer().serializeToString(doc), matches };
}
/** Count occurrences of a substring. */
function countOccurrences(str: string, sub: string): number {
let count = 0;
let idx = 0;
while ((idx = str.indexOf(sub, idx)) !== -1) {
count++;
idx += sub.length;
}
return count;
}
/** Escape an attribute value for HTML. */
function escapeAttr(value: string): string {
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
}
/** Self-closing / void tags that don't get a closing tag. */
const VOID_TAGS = new Set(["br", "hr", "img", "input"]);
/**
* Given an HTML-like fragment (from a one:T CDATA section), find all occurrences
* of `searchText` in text nodes and wrap each with <a href="url">...</a>.
* Preserves existing formatting tags by inserting the anchor at the correct level.
*/
function wrapTextWithHyperlink(fragment: string, searchText: string, url: string): string {
const stack: string[] = []; // open tag names (lowercase)
let inAnchor = false;
const result: string[] = [];
const parser = new Parser(
{
onopentag(name: string, attribs: Record<string, string>) {
const n = name.toLowerCase();
if (n === "a") {
inAnchor = true;
}
// Reconstruct the opening tag.
const attrs = Object.entries(attribs).map(([k, v]) => ` ${k}="${v}"`).join("");
result.push(`<${name}${attrs}>`);
if (!VOID_TAGS.has(n)) {
stack.push(n);
}
},
onclosetag(name: string) {
const n = name.toLowerCase();
if (n === "a") {
inAnchor = false;
}
result.push(`</${name}>`);
// Pop matching open tag(s).
for (let i = stack.length - 1; i >= 0; i--) {
if (stack[i] === n) {
stack.splice(i, 1);
break;
}
}
},
ontext(text: string) {
if (!text || !text.includes(searchText)) {
result.push(text);
return;
}
// If we're inside an existing <a> tag, don't wrap.
if (inAnchor) {
result.push(text);
return;
}
const href = `href="${escapeAttr(url)}"`;
let remaining = text;
while (remaining.length > 0) {
const idx = remaining.indexOf(searchText);
if (idx === -1) {
result.push(remaining);
break;
}
// Text before the match.
if (idx > 0) result.push(remaining.slice(0, idx));
// Wrap matched text with <a> tag at current nesting level.
const matchText = remaining.slice(idx, idx + searchText.length);
result.push(`<a ${href}>${matchText}</a>`);
remaining = remaining.slice(idx + searchText.length);
}
},
},
{ decodeEntities: true },
);
parser.write(fragment);
parser.end();
return result.join("");
}
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;
}
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 { Parser } from "htmlparser2";
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, template?: any | null): void {
let t = firstTextElement(cellEl);
let fragment: string;
// Determine the formatting source: prefer template if given, else use existing cell content.
let formatSource: string | null = null;
if (template) {
const tTemplate = firstTextElement(template);
if (tTemplate) formatSource = tTemplate.textContent ?? "";
} else if (t) {
formatSource = t.textContent ?? "";
}
if (preserveFormatting && formatSource) {
// Keep character formatting (bold/italic/strikethrough/underline/etc.) by re-wrapping
// the new text in the same inline tags from the template or existing cell.
fragment = reformatLikeTemplate(formatSource, 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);
// Copy OE-level formatting attributes from template (e.g., alignment for horizontal text alignment).
if (template) {
const templateOEC = directChildrenByLocalName(template, "OEChildren")[0];
if (templateOEC) {
const templateOE = directChildrenByLocalName(templateOEC, "OE")[0];
if (templateOE && templateOE.attributes) {
for (let i = 0; i < templateOE.attributes.length; i++) {
const attrName = templateOE.attributes[i].name.toLowerCase();
// Copy formatting attributes like alignment; skip structural ones.
if (attrName === "objectid" || attrName === "lastmodifiedtime") continue;
oe.setAttribute(templateOE.attributes[i].name, templateOE.attributes[i].value);
}
}
}
}
} else {
// If OE already exists and we have a template, copy its formatting attributes too.
if (template) {
const parentOe = t.parentNode;
if (parentOe && localName(parentOe) === "OE") {
const templateOEC = directChildrenByLocalName(template, "OEChildren")[0];
if (templateOEC) {
const templateOE = directChildrenByLocalName(templateOEC, "OE")[0];
if (templateOE && templateOE.attributes) {
for (let i = 0; i < templateOE.attributes.length; i++) {
const attrName = templateOE.attributes[i].name.toLowerCase();
if (attrName === "objectid" || attrName === "lastmodifiedtime") continue;
parentOe.setAttribute(templateOE.attributes[i].name, templateOE.attributes[i].value);
}
}
}
}
}
}
while (t.firstChild) t.removeChild(t.firstChild);
t.appendChild(doc.createCDATASection(fragment));
}
/** Copy Cell-level formatting attributes from a template cell to a target cell. */
function copyCellFormattingAttributes(targetCell: any, templateCell: any | null): void {
if (!templateCell || !targetCell) return;
const attrs = templateCell.attributes;
if (!attrs) return;
for (let i = 0; i < attrs.length; i++) {
const attrName = attrs[i].name.toLowerCase();
// Skip structural/identity attributes; copy formatting ones like vertAlign.
if (attrName === "objectid" || attrName === "lastmodifiedtime") continue;
targetCell.setAttribute(attrs[i].name, attrs[i].value);
}
}
function stripObjectIds(el: any): void {
walkElements(el, (n) => {
if (n.removeAttribute) {
n.removeAttribute("objectID");
n.removeAttribute("lastModifiedTime");
}
});
}
/**
* Remove all <a> (hyperlink) tags from an element tree while preserving their
* children and any formatting attributes on those children. OneNote stores
* hyperlinks as text inside CDATA sections of one:T elements, not as structural
* XML elements — so DOM-based getElementsByTagName won't find them. This strips
* <a> tags from both CDATA content (CDATA-level links) and structural XML-level
* <a> elements while preserving character formatting on children.
*/
function stripHyperlinkTags(el: any): void {
// Strip <a href="...">...</a> from CDATA content of one:T elements.
const tElements = Array.from((el as any).getElementsByTagName("*") || []);
for (const el of tElements) {
if ((el as any).localName?.toLowerCase() !== "t") continue;
// Find CDATA section child and strip <a> tags from its content.
const cdataSection = Array.from((el as any).childNodes || []).find(
(n: any) => n.nodeType === 4 /* CDATA_SECTION_NODE */,
);
if (!cdataSection) continue;
let value = (cdataSection as any).nodeValue ?? "";
// Strip <a href="...">...</a> tags, preserving inner content and formatting.
const stripped = value.replace(/<a\b[^>]*>(.*?)<\/a>/gi, "$1");
if (stripped !== value) {
(cdataSection as any).nodeValue = stripped;
}
}
// Also strip structural XML-level <a> elements (if any exist at DOM level).
const allElements = Array.from((el as any).getElementsByTagName("*") || []);
for (const node of allElements) {
if ((node as any).localName?.toLowerCase() !== "a") continue;
const parent = (node as any).parentNode;
if (!parent) continue;
while ((node as any).firstChild) {
parent.insertBefore((node as any).firstChild, node);
}
parent.removeChild(node);
}
}
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);
stripHyperlinkTags(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 };
}
export interface CloneTableSchemaResult {
/** Updated target page XML. */
xml: string;
/** Properties (column headers or row labels) that were added to the target table. */
added: string[];
/** Properties that existed in target but not source and were deleted from target. */
deleted: string[];
/** Whether any properties were reordered in the target table. */
reordered: boolean;
}
/**
* Clone a table's schema (column headers or row labels) from a source table to a
* target table, reordering and adding/deleting as needed while preserving existing
* data cells and target formatting.
*
* - Horizontal mode: properties are the first-row cell texts (column headers). The
* function ensures the target's columns match the source's column order by deleting
* extra columns, adding missing ones, and reordering to match.
* - Vertical mode: properties are each row's first-column text (row labels). The
* function ensures the target's rows match the source's row label order by deleting
* extra rows, adding missing ones, and reordering to match.
*/
export function cloneTableSchemaXml(
sourcePageXml: string,
sourceTableId: string,
targetPageXml: string,
targetTableId: string,
orientation: "horizontal" | "vertical",
): CloneTableSchemaResult {
const srcDoc = parseXml(sourcePageXml);
const tgtDoc = parseXml(targetPageXml);
const sourceTable = findTable(srcDoc, sourceTableId);
if (!sourceTable) throw new Error(`No table found for id '${sourceTableId}' on the source page.`);
const targetTable = findTable(tgtDoc, targetTableId);
if (!targetTable) throw new Error(`No table found for id '${targetTableId}' on the target page.`);
const added: string[] = [];
const deleted: string[] = [];
let reordered = false;
if (orientation === "horizontal") {
// Properties are first-row cell texts (column headers).
const srcRows = tableRows(sourceTable);
const tgtRows = tableRows(targetTable);
if (!srcRows.length) throw new Error("Source table has no rows.");
if (!tgtRows.length) throw new Error("Target table has no rows.");
// Read source column headers from first row.
const srcHeaderCells = rowCells(srcRows[0]);
const srcHeaders: string[] = srcHeaderCells.map((c) => cellText(c).trim());
// Read target column headers from first row.
const tgtHeaderCells = rowCells(tgtRows[0]);
const tgtHeaders: string[] = tgtHeaderCells.map((c) => cellText(c).trim());
// Build a map of source header -> index for matching.
const srcIndexMap = new Map<string, number>();
for (let i = 0; i < srcHeaders.length; i++) {
if (!srcIndexMap.has(srcHeaders[i])) srcIndexMap.set(srcHeaders[i], i);
}
// Determine which target columns to keep and their desired order.
interface KeepCol {
index: number;
header: string;
srcIndex: number;
}
const keepCols: KeepCol[] = [];
for (let i = 0; i < tgtHeaders.length; i++) {
if (srcIndexMap.has(tgtHeaders[i])) {
keepCols.push({ index: i, header: tgtHeaders[i], srcIndex: srcIndexMap.get(tgtHeaders[i])! });
} else {
deleted.push(tgtHeaders[i]);
}
}
// Sort kept columns by source order.
const sortedKeep = [...keepCols].sort((a, b) => a.srcIndex - b.srcIndex);
// Check if reordering is needed.
reordered = keepCols.length !== sortedKeep.length ||
keepCols.some((k, i) => k.header !== sortedKeep[i]?.header);
// Determine which columns need to be added and where.
const keptHeadersSet = new Set(keepCols.map((k) => k.header));
interface AddCol {
header: string;
srcIndex: number;
}
const addCols: AddCol[] = [];
for (let i = 0; i < srcHeaders.length; i++) {
if (!keptHeadersSet.has(srcHeaders[i])) {
addCols.push({ header: srcHeaders[i], srcIndex: i });
}
}
// Merge kept and added columns into final desired order.
interface FinalCol {
header: string;
fromTarget?: number; // original target column index if kept
newHeader?: boolean; // true if this is a newly added column
}
const finalCols: FinalCol[] = [];
let keepPtr = 0;
let addPtr = 0;
while (keepPtr < sortedKeep.length || addPtr < addCols.length) {
if (addPtr < addCols.length && (keepPtr >= sortedKeep.length || addCols[addPtr].srcIndex < sortedKeep[keepPtr].srcIndex)) {
finalCols.push({ header: addCols[addPtr].header, newHeader: true });
added.push(addCols[addPtr].header);
addPtr++;
} else {
finalCols.push({ header: sortedKeep[keepPtr].header, fromTarget: sortedKeep[keepPtr].index });
keepPtr++;
}
}
// Now rebuild the target table's columns and rows to match.
const numFinalCols = finalCols.length;
// Update Columns element widths if it exists.
let colsEl = directChildrenByLocalName(targetTable, "Columns")[0];
if (colsEl) {
while (colsEl.firstChild) colsEl.removeChild(colsEl.firstChild);
for (let i = 0; i < numFinalCols; i++) {
const col = tgtDoc.createElementNS(ONE_NS, "one:Column");
col.setAttribute("width", "150"); // default width
colsEl.appendChild(col);
}
} else {
colsEl = tgtDoc.createElementNS(ONE_NS, "one:Columns");
for (let i = 0; i < numFinalCols; i++) {
const col = tgtDoc.createElementNS(ONE_NS, "one:Column");
col.setAttribute("width", "150");
colsEl.appendChild(col);
}
targetTable.insertBefore(colsEl, targetTable.firstChild);
}
// Rebuild each row's cells to match the final column order.
for (const tgtRow of tgtRows) {
const oldCells = rowCells(tgtRow);
while (tgtRow.firstChild && localName(tgtRow.firstChild) === "Cell") {
tgtRow.removeChild(tgtRow.firstChild);
}
// Build a map from header -> cell content for kept columns.
const cellByHeader = new Map<string, any>();
for (let i = 0; i < oldCells.length && i < tgtHeaders.length; i++) {
if (!cellByHeader.has(tgtHeaders[i])) {
cellByHeader.set(tgtHeaders[i], oldCells[i]);
}
}
// Build a map from header -> source formatting template for new columns.
const fmtTemplateByHeader = new Map<string, any>();
for (let i = 0; i < srcHeaderCells.length && i < srcHeaders.length; i++) {
if (!fmtTemplateByHeader.has(srcHeaders[i])) {
fmtTemplateByHeader.set(srcHeaders[i], srcHeaderCells[i]);
}
}
// Create cells in final order.
for (const fc of finalCols) {
let cell: any;
if (fc.newHeader) {
// New column: create empty cell with header text in first row only, copying source formatting.
cell = tgtDoc.createElementNS(ONE_NS, "one:Cell");
const isHeaderRow = tgtRows.indexOf(tgtRow) === 0;
if (isHeaderRow) {
// Copy the source table's corresponding header cell's formatting to this new column.
let template: any | null = null;
for (let i = 0; i < srcHeaderCells.length && i < srcHeaders.length; i++) {
if (!template && cellText(srcHeaderCells[i]).trim() === fc.header) {
template = srcHeaderCells[i];
break;
}
}
setCellText(tgtDoc, cell, fc.header, true, template);
copyCellFormattingAttributes(cell, template);
} else {
// Empty data cell.
const oeChildren = tgtDoc.createElementNS(ONE_NS, "one:OEChildren");
const oe = tgtDoc.createElementNS(ONE_NS, "one:OE");
const t = tgtDoc.createElementNS(ONE_NS, "one:T");
t.appendChild(tgtDoc.createCDATASection(""));
oe.appendChild(t);
oeChildren.appendChild(oe);
cell.appendChild(oeChildren);
}
} else {
// Kept column: reuse existing cell content.
const origIdx = fc.fromTarget!;
if (origIdx < oldCells.length) {
cell = oldCells[origIdx];
} else {
cell = tgtDoc.createElementNS(ONE_NS, "one:Cell");
const oeChildren = tgtDoc.createElementNS(ONE_NS, "one:OEChildren");
const oe = tgtDoc.createElementNS(ONE_NS, "one:OE");
const t = tgtDoc.createElementNS(ONE_NS, "one:T");
t.appendChild(tgtDoc.createCDATASection(""));
oe.appendChild(t);
oeChildren.appendChild(oe);
cell.appendChild(oeChildren);
}
}
tgtRow.appendChild(cell);
}
}
} else {
// Vertical mode: properties are each row's first-column text (row labels).
const srcRows = tableRows(sourceTable);
const tgtRows = tableRows(targetTable);
if (!srcRows.length) throw new Error("Source table has no rows.");
if (!tgtRows.length) throw new Error("Target table has no rows.");
// Read source row labels from first column.
const srcLabels: string[] = [];
for (const row of srcRows) {
const cells = rowCells(row);
if (cells.length > 0) {
srcLabels.push(cellText(cells[0]).trim());
} else {
srcLabels.push("");
}
}
// Read target row labels from first column.
interface TgtRowInfo {
index: number;
label: string;
element: any;
}
const tgtRowsInfo: TgtRowInfo[] = [];
for (let i = 0; i < tgtRows.length; i++) {
const cells = rowCells(tgtRows[i]);
const label = cells.length > 0 ? cellText(cells[0]).trim() : "";
tgtRowsInfo.push({ index: i, label, element: tgtRows[i] });
}
// Build a map of source label -> first occurrence index.
const srcIndexMap = new Map<string, number>();
for (let i = 0; i < srcLabels.length; i++) {
if (!srcIndexMap.has(srcLabels[i])) srcIndexMap.set(srcLabels[i], i);
}
// Determine which target rows to keep and their desired order.
interface KeepRow {
index: number;
label: string;
srcIndex: number;
element: any;
}
const keepRows: KeepRow[] = [];
for (const tr of tgtRowsInfo) {
if (srcIndexMap.has(tr.label)) {
keepRows.push({ index: tr.index, label: tr.label, srcIndex: srcIndexMap.get(tr.label)!, element: tr.element });
} else {
deleted.push(tr.label);
}
}
// Sort kept rows by source order.
const sortedKeep = [...keepRows].sort((a, b) => a.srcIndex - b.srcIndex);
// Check if reordering is needed.
reordered = keepRows.length !== sortedKeep.length ||
keepRows.some((k, i) => k.label !== sortedKeep[i]?.label);
// Determine which rows need to be added and where.
const keptLabelsSet = new Set(keepRows.map((k) => k.label));
interface AddRow {
label: string;
srcIndex: number;
}
const addRows: AddRow[] = [];
for (let i = 0; i < srcLabels.length; i++) {
if (!keptLabelsSet.has(srcLabels[i])) {
addRows.push({ label: srcLabels[i], srcIndex: i });
}
}
// Merge kept and added rows into final desired order.
interface FinalRow {
label: string;
fromTarget?: any; // original target row element if kept
newLabel?: boolean; // true if this is a newly added row
}
const finalRows: FinalRow[] = [];
let keepPtr = 0;
let addPtr = 0;
while (keepPtr < sortedKeep.length || addPtr < addRows.length) {
if (addPtr < addRows.length && (keepPtr >= sortedKeep.length || addRows[addPtr].srcIndex < sortedKeep[keepPtr].srcIndex)) {
finalRows.push({ label: addRows[addPtr].label, newLabel: true });
added.push(addRows[addPtr].label);
addPtr++;
} else {
finalRows.push({ label: sortedKeep[keepPtr].label, fromTarget: sortedKeep[keepPtr].element });
keepPtr++;
}
}
// Determine max column count across source table for new rows.
const srcMaxCols = Math.max(...srcRows.map((r) => rowCells(r).length));
// Rebuild the target table's rows in final order.
// First, remove all existing rows from the table.
while (targetTable.firstChild && localName(targetTable.firstChild) === "Row") {
targetTable.removeChild(targetTable.firstChild);
}
for (const fr of finalRows) {
let row: any;
if (fr.newLabel) {
// New row: create it with the label in first column and empty cells, copying source formatting.
row = tgtDoc.createElementNS(ONE_NS, "one:Row");
const numCols = Math.max(srcMaxCols, 1);
for (let c = 0; c < numCols; c++) {
const cell = tgtDoc.createElementNS(ONE_NS, "one:Cell");
if (c === 0) {
// First column gets the label. Copy the source table's corresponding row label cell's formatting to this new row.
let template: any | null = null;
for (const srcRow of srcRows) {
const cells = rowCells(srcRow);
if (cells.length > 0 && cellText(cells[0]).trim() === fr.label) {
template = cells[0];
break;
}
}
setCellText(tgtDoc, cell, fr.label, true, template);
copyCellFormattingAttributes(cell, template);
} else {
// Other columns are empty.
const oeChildren = tgtDoc.createElementNS(ONE_NS, "one:OEChildren");
const oe = tgtDoc.createElementNS(ONE_NS, "one:OE");
const t = tgtDoc.createElementNS(ONE_NS, "one:T");
t.appendChild(tgtDoc.createCDATASection(""));
oe.appendChild(t);
oeChildren.appendChild(oe);
cell.appendChild(oeChildren);
}
row.appendChild(cell);
}
} else {
// Kept row: reuse existing row element.
row = fr.fromTarget;
}
targetTable.appendChild(row);
}
}
return {
xml: new XMLSerializer().serializeToString(tgtDoc),
added,
deleted,
reordered,
};
}
/**
* 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;
// Assumed dimensions when Size element is missing (in pixels). OneNote outlines are typically large.
const OUTLINE_ASSUMED_WIDTH = 400; // ~400 pixels wide
const OUTLINE_ASSUMED_HEIGHT = 300; // ~300 pixels tall
const OUTLINE_GAP = 20; // pixels
/** Extra height/width added for overlap detection in add_buffer_between_page_outlines (in pixels). OneNote's reported dimensions don't match visual rendering, so we add a small buffer to catch near-touching outlines. */
const OUTLINE_HEIGHT_ADJUSTMENT_PIXELS = 21; // 21 pixels — used by addBufferBetweenPageOutlines for overlap detection.
const OUTLINE_WIDTH_ADJUSTMENT_PIXELS = 15; // 15 pixels — used by addBufferBetweenPageOutlines for overlap detection.
/** Height/width adjustment for vertical overlap detection in set_all_outlines_width_on_page (in pixels). Negative values shrink the effective height to avoid false overlaps between stacked outlines. */
const OUTLINE_HEIGHT_ADJUSTMENT_FOR_WIDTH_SET = -15; // -15 pixels — used by setAllOutlinesWidthOnPage for vertical overlap detection.
const OUTLINE_WIDTH_ADJUSTMENT_FOR_WIDTH_SET = -9; // -9 pixels — used by setAllOutlinesWidthOnPage for width calculations.
export interface OutlineInfo {
element: any;
x: number;
y: number;
width: number;
height: number;
}
/** Check if two rectangles overlap. */
function rectsOverlap(a: OutlineInfo, b: OutlineInfo): boolean {
return !(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y);
}
/** Check if two rectangles overlap vertically (same X range but different Y). */
function rectsOverlapVertically(a: OutlineInfo, b: OutlineInfo): boolean {
const xOverlap = !(a.x + a.width <= b.x || b.x + b.width <= a.x);
return xOverlap && (Math.max(a.y, b.y) < Math.min(a.y + a.height, b.y + b.height));
}
/** Check if two rectangles overlap horizontally (same Y range but different X). */
function rectsOverlapHorizontally(a: OutlineInfo, b: OutlineInfo): boolean {
const yOverlap = !(a.y + a.height <= b.y || b.y + b.height <= a.y);
return yOverlap && (Math.max(a.x, b.x) < Math.min(a.x + a.width, b.x + b.width));
}
/** Calculate the amount of overlap on each axis in twips. Returns { xOverlap, yOverlap } or null if no overlap. */
function calcAxisOverlaps(a: OutlineInfo, b: OutlineInfo): { xOverlap: number; yOverlap: number } | null {
const xStart = Math.max(a.x, b.x);
const xEnd = Math.min(a.x + a.width, b.x + b.width);
const xOverlap = xEnd - xStart;
const yStart = Math.max(a.y, b.y);
const yEnd = Math.min(a.y + a.height, b.y + b.height);
const yOverlap = yEnd - yStart;
if (xOverlap <= 0 || yOverlap <= 0) return null; // No overlap.
return { xOverlap, yOverlap };
}
/**
* Adjust outline positions on a page to ensure they are separated by the given buffer.
* Returns updated page XML and count of outlines adjusted.
*/
export function addBufferBetweenPageOutlines(
pageXml: string,
bufferPixels: number,
adjustmentMode: "only_overlapped" | "all_directions" | "top_to_bottom" | "left_to_right",
horizontalAdjustmentBehavior?: "only_same_row" | "all_outlines",
verticalAdjustmentBehavior?: "only_same_column" | "all_outlines",
): { xml: string; outlines_adjusted: number } {
const doc = parseXml(pageXml);
// OneNote XML uses pixels for Position/Size, so use buffer directly without conversion.
const bufferPixelsEffective = Math.max(0, bufferPixels);
if (bufferPixels < 0) throw new Error("buffer_pixels must be non-negative.");
const outlines = pageOutlines(doc)
.filter((el) => outlineHasContent(el)) // Skip empty/hidden outlines (e.g., OneNote artifacts).
.map((el) => {
const m = outlineMetrics(el);
// Add small buffers because OneNote's reported dimensions don't match visual rendering.
return {
element: el,
x: m.x,
y: m.y,
width: (m.width ?? OUTLINE_ASSUMED_WIDTH) + OUTLINE_WIDTH_ADJUSTMENT_PIXELS,
height: (m.height ?? OUTLINE_ASSUMED_HEIGHT) + OUTLINE_HEIGHT_ADJUSTMENT_PIXELS,
} as OutlineInfo;
});
if (outlines.length <= 1) {
return { xml: new XMLSerializer().serializeToString(doc), outlines_adjusted: 0 };
}
let adjusted = 0;
switch (adjustmentMode) {
case "only_overlapped":
adjusted = adjustOnlyOverlapped(outlines, bufferPixelsEffective);
break;
case "all_directions":
adjusted = adjustAllDirections(
outlines,
bufferPixelsEffective,
horizontalAdjustmentBehavior ?? "only_same_row",
verticalAdjustmentBehavior ?? "only_same_column",
);
break;
case "top_to_bottom":
adjusted = adjustTopToBottom(outlines, bufferPixelsEffective);
break;
case "left_to_right":
adjusted = adjustLeftToRight(outlines, bufferPixelsEffective);
break;
}
// Write new positions back to the XML.
for (const o of outlines) {
setOutlinePosition(doc, o.element, o.x, o.y);
}
return { xml: new XMLSerializer().serializeToString(doc), outlines_adjusted: adjusted };
}
/** Only move outlines that actually overlap with another outline (both axes). */
function adjustOnlyOverlapped(outlines: OutlineInfo[], bufferPixelsEffective: number): number {
let adjusted = 0;
// Sort by Y then X for consistent processing order.
const sorted = [...outlines].sort((a, b) => a.y - b.y || a.x - b.x);
for (let i = 0; i < sorted.length; i++) {
let movedX = false;
let movedY = false;
for (let j = 0; j < sorted.length; j++) {
if (i === j) continue;
const a = sorted[i];
const b = sorted[j];
// Calculate overlap amounts on each axis.
const overlaps = calcAxisOverlaps(a, b);
if (!overlaps) continue; // No overlap between these two outlines.
const { xOverlap, yOverlap } = overlaps;
// Decide which axis to move along: prefer the one with LESS overlap (easier separation).
// If equal, prioritize horizontal movement.
const moveHorizontally = xOverlap <= yOverlap;
if (moveHorizontally && !movedX) {
// Only move 'a' rightward if it's on the right side of 'b'. Never push left to avoid disrupting layout.
if (a.x >= b.x) {
const targetX = b.x + b.width + bufferPixelsEffective;
if (targetX > a.x) {
a.x = targetX;
movedX = true;
adjusted++;
}
}
} else if (!moveHorizontally && !movedY) {
// Only move 'a' downward if it's below 'b'. Never push up to avoid overlapping page title.
if (a.y >= b.y) {
const targetY = b.y + b.height + bufferPixelsEffective;
if (targetY > a.y) {
a.y = targetY;
movedY = true;
adjusted++;
}
}
}
}
}
return adjusted;
}
/** Ensure all outlines are separated by at least the buffer distance while preserving relative layout. */
function adjustAllDirections(
outlines: OutlineInfo[],
bufferPixelsEffective: number,
horizontalBehavior: "only_same_row" | "all_outlines",
verticalBehavior: "only_same_column" | "all_outlines",
): number {
let adjusted = 0;
// Tolerance for detecting same row/column (floating point positions may not be exact).
const positionTolerance = 5;
// Anchor positions: don't move outlines at these positions.
const anchorX = OUTLINE_DEFAULT_X; // 36 — left edge of page
const anchorY = OUTLINE_DEFAULT_Y; // 86 (but actual pages may use ~68.4)
// Detect the actual top anchor Y from the first row of outlines on this page.
const sortedByY = [...outlines].sort((a, b) => a.y - b.y);
const detectedAnchorY = sortedByY[0]?.y ?? anchorY;
// Store original positions for row/column detection (before any adjustments).
const origX: number[] = outlines.map((o) => o.x);
const origY: number[] = outlines.map((o) => o.y);
/** Check if two outlines are in the same row based on their ORIGINAL y positions. */
function sameRow(i: number, j: number): boolean {
return Math.abs(origY[i] - origY[j]) <= positionTolerance;
}
/** Check if two outlines are in the same column based on their ORIGINAL x positions. */
function sameColumn(i: number, j: number): boolean {
return Math.abs(origX[i] - origX[j]) <= positionTolerance;
}
// ========== HORIZONTAL PASS (left-to-right) ==========
// Sort outlines by original x position for left-to-right processing.
const indicesByX = Array.from({ length: outlines.length }, (_, i) => i).sort((a, b) => origX[a] - origX[b]);
if (horizontalBehavior === "only_same_row") {
// Group outlines into rows based on original y positions.
const rows: number[][] = [];
for (const idx of indicesByX) {
let placed = false;
for (const row of rows) {
if (row.some((rIdx) => sameRow(rIdx, idx))) {
row.push(idx);
placed = true;
break;
}
}
if (!placed) rows.push([idx]);
}
// For each row, pack outlines left-to-right with buffer spacing.
for (const row of rows) {
let maxRight = -Infinity;
for (const idx of row) {
const o = outlines[idx];
if (Math.abs(o.x - anchorX) <= positionTolerance) {
// This outline is at the left anchor — don't move it, but update maxRight.
maxRight = Math.max(maxRight, o.x + o.width);
} else {
const targetX = maxRight + bufferPixelsEffective;
if (targetX !== o.x) {
o.x = targetX;
adjusted++;
}
maxRight = o.x + o.width;
}
}
}
} else {
// "all_outlines": pack all outlines left-to-right regardless of row.
let maxRight = -Infinity;
for (const idx of indicesByX) {
const o = outlines[idx];
if (Math.abs(o.x - anchorX) <= positionTolerance) {
maxRight = Math.max(maxRight, o.x + o.width);
} else {
const targetX = maxRight + bufferPixelsEffective;
if (targetX !== o.x) {
o.x = targetX;
adjusted++;
}
maxRight = o.x + o.width;
}
}
}
// ========== VERTICAL PASS (top-to-bottom) ==========
// Sort outlines by original y position for top-to-bottom processing.
const indicesByY = Array.from({ length: outlines.length }, (_, i) => i).sort((a, b) => origY[a] - origY[b]);
if (verticalBehavior === "only_same_column") {
// Group outlines into columns based on ORIGINAL x positions.
const columns: number[][] = [];
for (const idx of indicesByY) {
let placed = false;
for (const col of columns) {
if (col.some((cIdx) => sameColumn(cIdx, idx))) {
col.push(idx);
placed = true;
break;
}
}
if (!placed) columns.push([idx]);
}
// For each column, pack outlines top-to-bottom with buffer spacing.
for (const col of columns) {
let maxBottom = -Infinity;
for (const idx of col) {
const o = outlines[idx];
if (Math.abs(o.y - detectedAnchorY) <= positionTolerance) {
// This outline is at the top anchor — don't move it, but update maxBottom.
maxBottom = Math.max(maxBottom, o.y + o.height);
} else {
const targetY = maxBottom + bufferPixelsEffective;
if (targetY !== o.y) {
o.y = targetY;
adjusted++;
}
maxBottom = o.y + o.height;
}
}
}
} else {
// "all_outlines": pack all outlines top-to-bottom regardless of column.
let maxBottom = -Infinity;
for (const idx of indicesByY) {
const o = outlines[idx];
if (Math.abs(o.y - detectedAnchorY) <= positionTolerance) {
maxBottom = Math.max(maxBottom, o.y + o.height);
} else {
const targetY = maxBottom + bufferPixelsEffective;
if (targetY !== o.y) {
o.y = targetY;
adjusted++;
}
maxBottom = o.y + o.height;
}
}
}
return adjusted;
}
/** Adjust outlines vertically: only push apart outlines that share horizontal space. */
function adjustTopToBottom(outlines: OutlineInfo[], bufferPixelsEffective: number): number {
let adjusted = 0;
// Sort by Y position (top edge).
const sorted = [...outlines].sort((a, b) => a.y - b.y);
for (let i = 0; i < sorted.length; i++) {
for (let j = i + 1; j < sorted.length; j++) {
const a = sorted[i]; // higher outline
const b = sorted[j]; // lower outline
// Only adjust if they share horizontal space.
const xOverlap = !(a.x + a.width <= b.x || b.x + b.width <= a.x);
if (!xOverlap) continue;
// Ensure 'b' is below 'a' by at least buffer.
const minY = a.y + a.height + bufferPixelsEffective;
if (b.y < minY) {
b.y = minY;
adjusted++;
}
}
}
return adjusted;
}
/** Adjust outlines horizontally: only push apart outlines that share vertical space. */
function adjustLeftToRight(outlines: OutlineInfo[], bufferPixelsEffective: number): number {
let adjusted = 0;
// Sort by X position (left edge).
const sorted = [...outlines].sort((a, b) => a.x - b.x);
for (let i = 0; i < sorted.length; i++) {
for (let j = i + 1; j < sorted.length; j++) {
const a = sorted[i]; // left outline
const b = sorted[j]; // right outline
// Only adjust if they share vertical space.
const yOverlap = !(a.y + a.height <= b.y || b.y + b.height <= a.y);
if (!yOverlap) continue;
// Ensure 'b' is to the right of 'a' by at least buffer.
const minX = a.x + a.width + bufferPixelsEffective;
if (b.x < minX) {
b.x = minX;
adjusted++;
}
}
}
return adjusted;
}
export type OutlinePosition = "bottom" | "right";
/** All one:Outline elements on the page (including nested outlines), in document order. */
function pageOutlines(doc: any): any[] {
const allOutlines: any[] = [];
function collect(node: any) {
if (!node || !node.localName) return;
if (node.localName === "Outline") {
allOutlines.push(node);
}
// Recurse into children to find nested outlines.
for (let i = 0; i < node.childNodes.length; i++) {
collect(node.childNodes[i]);
}
}
collect(doc.documentElement);
return allOutlines;
}
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 };
}
/** Check if an outline has visible content (text or child elements). Used to skip empty/hidden outlines. */
function outlineHasContent(outlineEl: any): boolean {
// Check for OEChildren with nested OEs (paragraphs) that contain T (text) elements.
const oeChildren = directChildrenByLocalName(outlineEl, "OEChildren")[0];
if (!oeChildren) return false;
function hasText(node: any): boolean {
if (!node || !node.childNodes) return false;
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i];
if (child.localName === "T" && child.textContent?.trim()) return true;
if (hasText(child)) return true;
}
return false;
}
return hasText(oeChildren);
}
/** 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;
}
/** Set all outlines on a page to the same width and adjust adjacent outlines' positions accordingly. */
export interface SetAllOutlinesWidthResult {
xml: string;
outlines_adjusted: number;
tables_adjusted: number;
}
export function setAllOutlinesWidthOnPage(
pageXml: string,
targetWidthPixels: number,
targetTableIndex?: number, // 1-based column index; default=9999 means last column; 0 disables table adjustment.
): SetAllOutlinesWidthResult {
const doc = parseXml(pageXml);
if (targetWidthPixels <= 0) throw new Error("target_width_pixels must be positive.");
// Default: use last column of each table for width adjustments.
const effectiveTableIndex = targetTableIndex ?? 9999;
const adjustTables = effectiveTableIndex > 0;
const outlines = pageOutlines(doc)
.filter((el) => outlineHasContent(el)) // Skip empty/hidden outlines.
.map((el) => {
const m = outlineMetrics(el);
// Apply adjustment buffers specific to this function for accurate vertical overlap detection.
return {
element: el,
x: m.x,
y: m.y,
width: (m.width ?? OUTLINE_ASSUMED_WIDTH) + OUTLINE_WIDTH_ADJUSTMENT_FOR_WIDTH_SET,
height: (m.height ?? OUTLINE_ASSUMED_HEIGHT) + OUTLINE_HEIGHT_ADJUSTMENT_FOR_WIDTH_SET,
} as OutlineInfo;
});
if (outlines.length === 0) {
return { xml: new XMLSerializer().serializeToString(doc), outlines_adjusted: 0, tables_adjusted: 0 };
}
let adjustedCount = 0;
let tablesAdjustedCount = 0;
// Sort all outlines by x position.
const sortedByX = [...outlines].sort((a, b) => a.x - b.x);
/** Check if two outlines share vertical space (overlap in Y axis). */
function sharesVerticalSpace(a: OutlineInfo, b: OutlineInfo): boolean {
return !(a.y + a.height <= b.y || b.y + b.height <= a.y);
}
// Track cumulative shift for each outline based on width changes of neighbors to its left.
const shifts = new Map<OutlineInfo, number>();
for (const o of outlines) shifts.set(o, 0);
for (const o of sortedByX) {
const originalWidth = o.width;
const widthDelta = targetWidthPixels - originalWidth;
if (Math.abs(widthDelta) > 0.1) {
setOutlineSize(doc, o.element, targetWidthPixels);
adjustedCount++;
}
// Adjust tables within this outline if enabled.
if (adjustTables) {
const tablesInOutline = collectDescendantElements(o.element, "Table");
for (const table of tablesInOutline) {
tablesAdjustedCount += adjustTableWidthToMatch(doc, table, targetWidthPixels, effectiveTableIndex);
}
}
// Propagate this outline's width change to outlines on its right that share vertical space.
if (Math.abs(widthDelta) > 0.1) {
for (const other of sortedByX) {
if (other.x <= o.x) continue; // Skip outlines at or left of current one.
if (!sharesVerticalSpace(o, other)) continue; // Only affect vertically overlapping neighbors.
shifts.set(other, (shifts.get(other) ?? 0) + widthDelta);
}
}
}
// Apply all accumulated position shifts.
for (const [o, shift] of shifts.entries()) {
if (Math.abs(shift) > 0.1) {
const newX = o.x + shift;
setOutlinePosition(doc, o.element, newX, o.y);
}
}
return { xml: new XMLSerializer().serializeToString(doc), outlines_adjusted: adjustedCount, tables_adjusted: tablesAdjustedCount };
}
/** Set or update the Size element of an outline. */
function setOutlineSize(doc: any, outlineEl: any, widthPixels: number): void {
let size = directChildrenByLocalName(outlineEl, "Size")[0];
if (!size) {
size = doc.createElementNS(ONE_NS, "one:Size");
outlineEl.insertBefore(size, outlineEl.firstChild);
}
size.setAttribute("width", widthPixels.toFixed(2));
}
/** Collect all descendant elements with the given local name. */
function collectDescendantElements(node: any, localNameTarget: string): any[] {
const results: any[] = [];
walkElements(node, (el) => {
if (localName(el) === localNameTarget) results.push(el);
});
return results;
}
/** Adjust a table's column widths so its total width matches targetWidthPixels. Returns 1 if adjusted, 0 otherwise. */
function adjustTableWidthToMatch(doc: any, tableEl: any, targetWidthPixels: number, targetColumnIndex: number): number {
const colsEl = directChildrenByLocalName(tableEl, "Columns")[0];
if (!colsEl) return 0;
const columns = directChildrenByLocalName(colsEl, "Column");
if (columns.length === 0) return 0;
// Calculate current total table width from column widths.
let currentTotalWidth = 0;
for (const col of columns) {
const wStr = getAttr(col, "width") ?? "";
const w = parseFloat(wStr);
if (!Number.isNaN(w)) {
currentTotalWidth += w;
}
}
if (currentTotalWidth <= 0) return 0;
// Determine which column to adjust: use targetColumnIndex, but clamp to last column if beyond range.
const numColumns = columns.length;
const adjustedColIdx = Math.min(Math.max(targetColumnIndex, 1), numColumns); // Clamp to [1, numColumns].
// Calculate the width delta needed and apply it entirely to the target column.
const widthDelta = targetWidthPixels - currentTotalWidth;
if (Math.abs(widthDelta) <= 0.1) return 0; // No meaningful change needed.
const colEl = columns[adjustedColIdx - 1]; // Convert from 1-based to 0-based index.
const currentColWidthStr = getAttr(colEl, "width") ?? "";
let currentColWidth = parseFloat(currentColWidthStr);
if (Number.isNaN(currentColWidth)) currentColWidth = 50; // Default fallback width.
// Ensure the adjusted column doesn't go negative or too small.
const newColWidth = Math.max(10, currentColWidth + widthDelta);
colEl.setAttribute("width", newColWidth.toFixed(2));
return 1;
}
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);
}
/**
* Find any element in the document by its objectID attribute. Returns null if not found.
*/
function findElementByObjectId(doc: any, objectId: string): any | null {
let found: any = null;
walkElements(doc.documentElement, (el) => {
if (!found && getAttr(el, "objectID") === objectId) found = el;
});
return found;
}
/**
* Wrap all occurrences of `searchText` with a hyperlink anchor inside every
* <one:T> element on the page. If containerObjectId is provided, only search
* within descendants of that container (e.g. a table, cell, outline, or OE).
* Existing inline formatting (bold/italic/etc.) around or within matched text
* is preserved by inserting the <a> tag at the correct nesting level. Returns
* the full updated page XML and match count.
*/
export function setHyperlinkOnTextInPageXml(
pageXml: string,
searchText: string,
hyperlinkUrl: string,
containerObjectId?: string | null,
): { xml: string; matches: number } {
const doc = parseXml(pageXml);
let matches = 0;
// If a container is specified, find it and scope the search to its descendants.
let rootToSearch: any = doc.documentElement;
if (containerObjectId) {
const container = findElementByObjectId(doc, containerObjectId);
if (!container) throw new Error(`No object found with ID '${containerObjectId}' on this page.`);
rootToSearch = container;
}
walkElements(rootToSearch, (el) => {
if (localName(el) !== "T") return;
const raw = el.textContent ?? "";
if (!raw.includes(searchText)) return;
// Parse the CDATA fragment as HTML and wrap matched text with <a> tags.
const newFragment = wrapTextWithHyperlink(raw, searchText, hyperlinkUrl);
matches += countOccurrences(newFragment, `href="${escapeAttr(hyperlinkUrl)}"`);
while (el.firstChild) el.removeChild(el.firstChild);
el.appendChild(doc.createCDATASection(newFragment));
});
if (matches === 0) throw new Error(`No text found matching '${searchText}' on this page.`);
return { xml: new XMLSerializer().serializeToString(doc), matches };
}
/** Count occurrences of a substring. */
function countOccurrences(str: string, sub: string): number {
let count = 0;
let idx = 0;
while ((idx = str.indexOf(sub, idx)) !== -1) {
count++;
idx += sub.length;
}
return count;
}
/** Escape an attribute value for HTML. */
function escapeAttr(value: string): string {
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
}
/** Self-closing / void tags that don't get a closing tag. */
const VOID_TAGS = new Set(["br", "hr", "img", "input"]);
/**
* Given an HTML-like fragment (from a one:T CDATA section), find all occurrences
* of `searchText` in text nodes and wrap each with <a href="url">...</a>.
* Preserves existing formatting tags by inserting the anchor at the correct level.
*/
function wrapTextWithHyperlink(fragment: string, searchText: string, url: string): string {
const stack: string[] = []; // open tag names (lowercase)
let inAnchor = false;
const result: string[] = [];
const parser = new Parser(
{
onopentag(name: string, attribs: Record<string, string>) {
const n = name.toLowerCase();
if (n === "a") {
inAnchor = true;
}
// Reconstruct the opening tag.
const attrs = Object.entries(attribs).map(([k, v]) => ` ${k}="${v}"`).join("");
result.push(`<${name}${attrs}>`);
if (!VOID_TAGS.has(n)) {
stack.push(n);
}
},
onclosetag(name: string) {
const n = name.toLowerCase();
if (n === "a") {
inAnchor = false;
}
result.push(`</${name}>`);
// Pop matching open tag(s).
for (let i = stack.length - 1; i >= 0; i--) {
if (stack[i] === n) {
stack.splice(i, 1);
break;
}
}
},
ontext(text: string) {
if (!text || !text.includes(searchText)) {
result.push(text);
return;
}
// If we're inside an existing <a> tag, don't wrap.
if (inAnchor) {
result.push(text);
return;
}
const href = `href="${escapeAttr(url)}"`;
let remaining = text;
while (remaining.length > 0) {
const idx = remaining.indexOf(searchText);
if (idx === -1) {
result.push(remaining);
break;
}
// Text before the match.
if (idx > 0) result.push(remaining.slice(0, idx));
// Wrap matched text with <a> tag at current nesting level.
const matchText = remaining.slice(idx, idx + searchText.length);
result.push(`<a ${href}>${matchText}</a>`);
remaining = remaining.slice(idx + searchText.length);
}
},
},
{ decodeEntities: true },
);
parser.write(fragment);
parser.end();
return result.join("");
}
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;
}