src / onenote.ts
/**
* High-level OneNote operations, ported from local-onenote-mcp's server.py.
* Each method drives the PowerShell COM bridge and the local XML helpers. All
* OneNote functionality originates from Peteroooooooo's local-onenote-mcp; this
* is a native TypeScript reimplementation for LM Studio (no MCP, no Python).
*/
import { readFile } from "fs/promises";
import { existsSync } from "fs";
import { mkdir } from "fs/promises";
import { isAbsolute, resolve as resolvePath, dirname, basename } from "path";
import { OneNoteBridge } from "./bridge";
import {
CREATE_FILE_TYPES,
FILING_LOCATIONS,
FILING_LOCATION_TYPES,
HIERARCHY_SCOPES,
NEW_PAGE_STYLES,
PAGE_INFO,
PUBLISH_FORMATS,
SPECIAL_LOCATIONS,
XML_SCHEMA_2013,
enumValue,
} from "./constants";
import { buildImagePageUpdateXml, buildPageUpdateXml } from "./pageXml";
import { proportionalDimensions } from "./imageSize";
import { ContentFormat, contentToInlineHtml, contentToOeXml } from "./htmlToOneNote";
import {
addContentToOutlineXml,
addTodoItemsToOutlineXml,
TodoItemInput,
buildDuplicatePageXml,
collectPageObjects,
countOutlines,
countTables,
deleteRowFromTableXml,
duplicateOutlineInPageXml,
filterItems,
getOutlineXml,
getTableXml,
HierItem,
insertOutlineIntoPageXml,
insertRowIntoTableXml,
extractAllBaseGuids,
normalizeOneNoteId,
oneNoteIdsMatch,
normalizeOnenoteHyperlink,
OutlinePosition,
parseHierarchy,
replaceTableInPageXml,
replaceTextInPageXml,
resolveItem as resolveItemIn,
textFromPageXml,
titleFromPageXml,
updateTableCellTextXml,
extractOnenoteIds,
findHyperlinkReferencesInPageXml,
} from "./parseXml";
import { buildHyperlinkSnippet, buildTodoTagSnippet, TextFormatting } from "./snippets";
const REPLACE_BODY_OBJECT_TYPES = new Set([
"Outline", "Image", "InkDrawing", "FileAttachment", "InsertedFile", "MediaFile",
]);
const IDENTIFIER_RESOLUTION_ORDER = ["id", "exact_path", "unique_name"];
const IDENTIFIER_TYPES = new Set(["notebook", "section_group", "section", "page"]);
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export interface OneNoteOptions {
timeoutSeconds?: number;
markdownTimeoutSeconds?: number;
onLog?: (line: string) => void;
}
export class OneNote {
private readonly bridge: OneNoteBridge;
private readonly mdTimeout: number;
constructor(opts: OneNoteOptions = {}) {
this.bridge = new OneNoteBridge({ timeoutSeconds: opts.timeoutSeconds ?? 90, onLog: opts.onLog });
this.mdTimeout = opts.markdownTimeoutSeconds ?? 30;
}
// --- low-level helpers ----------------------------------------------------
private async hierarchyXml(startId = "", scope = "pages"): Promise<string> {
const res = await this.bridge.callWithRetry("get_hierarchy", {
start_id: startId,
scope: enumValue("scope", scope, HIERARCHY_SCOPES),
schema: XML_SCHEMA_2013,
});
return res.xml as string;
}
private async hierarchyItems(startId = "", scope = "pages"): Promise<HierItem[]> {
return parseHierarchy(await this.hierarchyXml(startId, scope));
}
private looksLikeObjectId(identifier: string): boolean {
return identifier.startsWith("{") && identifier.includes("}{") && identifier.endsWith("}");
}
private async resolveId(identifier: string, itemType?: string | null): Promise<string> {
if (!identifier) return "";
if (this.looksLikeObjectId(identifier)) return identifier;
const items = await this.hierarchyItems("", "pages");
return resolveItemIn(items, identifier, itemType).id;
}
private async resolveItem(identifier: string, itemType?: string | null): Promise<HierItem> {
const items = await this.hierarchyItems("", "pages");
return resolveItemIn(items, identifier, itemType);
}
private async findItemByPath(path: string, itemType?: string | null): Promise<HierItem | null> {
const target = path.toLowerCase();
for (const item of await this.hierarchyItems("", "pages")) {
if (itemType && item.type !== itemType) continue;
if ((item.path || "").toLowerCase() === target) return item;
}
return null;
}
private async findItemById(objectId: string, itemType?: string | null): Promise<HierItem | null> {
if (!objectId) return null;
for (const item of await this.hierarchyItems("", "pages")) {
if (itemType && item.type !== itemType) continue;
if (item.id === objectId) return item;
}
return null;
}
private friendlyChildPath(parentPath: string, childName: string): string {
let normalized = childName.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
if (normalized.toLowerCase().endsWith(".one")) normalized = normalized.slice(0, -4);
return normalized ? `${parentPath}/${normalized}` : parentPath;
}
private async refreshCreatedItem(opts: {
expectedPath: string;
itemType: string;
fallbackId?: string;
retries?: number;
delayMs?: number;
}): Promise<HierItem | null> {
const retries = opts.retries ?? 8;
const delayMs = opts.delayMs ?? 500;
for (let attempt = 0; attempt < retries; attempt++) {
const byPath = await this.findItemByPath(opts.expectedPath, opts.itemType);
if (byPath) return byPath;
if (opts.fallbackId) {
const byId = await this.findItemById(opts.fallbackId, opts.itemType);
if (byId) return byId;
}
if (attempt + 1 < retries) await sleep(delayMs);
}
return null;
}
private createTypeToItemType(createType: string): string | null {
const key = createType.toLowerCase();
if (key === "section") return "section";
if (key === "folder" || key === "section_group") return "section_group";
if (key === "notebook") return "notebook";
return null;
}
private safeLeafName(name: string): string {
let cleaned = name.replace(/[<>:"/\\|?*\x00-\x1f]/g, " ").trim();
cleaned = cleaned.replace(/\s+/g, " ");
if (!cleaned) throw new Error("Name cannot be empty.");
return cleaned;
}
private isRecycleBin(item: HierItem): boolean {
if (item["isInRecycleBin"] === "true" || item["isRecycleBin"] === "true") return true;
return (item.path || "").split("/").includes("OneNote_RecycleBin");
}
private withoutRecycleBin(items: HierItem[]): HierItem[] {
return items.filter((item) => !this.isRecycleBin(item));
}
async pageXml(pageId: string, pageInfo = "basic"): Promise<string> {
const res = await this.bridge.call("get_page_content", {
page_id: pageId,
page_info: enumValue("page_info", pageInfo, PAGE_INFO),
schema: XML_SCHEMA_2013,
});
return res.xml as string;
}
private async localTextSearch(
startId: string,
query: string,
maxResults: number,
includeRecycleBin: boolean,
): Promise<HierItem[]> {
const items = await this.hierarchyItems(startId, "pages");
let pages = filterItems(items, "page");
if (!includeRecycleBin) pages = this.withoutRecycleBin(pages);
const queryLower = query.toLowerCase();
const matches: HierItem[] = [];
for (const page of pages) {
if (matches.length >= Math.max(1, maxResults)) break;
const haystacks = [page.name || "", page.path || ""];
try {
haystacks.push(textFromPageXml(await this.pageXml(page.id, "basic")));
} catch (exc: any) {
page["scan_error"] = String(exc?.message ?? exc);
}
if (haystacks.some((v) => v && v.toLowerCase().includes(queryLower))) matches.push(page);
}
return matches;
}
// --- discovery / reading --------------------------------------------------
async healthCheck(): Promise<Record<string, any>> {
const items = this.withoutRecycleBin(await this.hierarchyItems("", "sections"));
return {
server: "onenote-manager",
transport: "powershell-com-bridge",
identifier_resolution_order: IDENTIFIER_RESOLUTION_ORDER,
content_formats: ["plain", "html", "markdown"],
notebooks: filterItems(items, "notebook").length,
sections: filterItems(items, "section").length,
write_backend: "OneNote desktop COM API",
};
}
async resolveIdentifier(identifier: string, itemType = ""): Promise<Record<string, any>> {
if (!identifier) throw new Error("identifier is required.");
const normalized = itemType.trim().toLowerCase() || null;
if (normalized && !IDENTIFIER_TYPES.has(normalized)) {
throw new Error(`item_type must be empty or one of: ${[...IDENTIFIER_TYPES].sort().join(", ")}`);
}
const item = await this.resolveItem(identifier, normalized);
return { item, identifier_resolution_order: IDENTIFIER_RESOLUTION_ORDER };
}
async getSpecialLocations(): Promise<Record<string, any>> {
const locations: Record<string, string> = {};
for (const [name, value] of Object.entries(SPECIAL_LOCATIONS)) {
const res = await this.bridge.call("get_special_location", { location: value });
locations[name] = res.path as string;
}
return { locations };
}
async listHierarchy(startIdentifier = "", scope = "pages", includeXml = false, includeRecycleBin = false) {
const startId = startIdentifier ? await this.resolveId(startIdentifier) : "";
const xml = await this.hierarchyXml(startId, scope);
let items = parseHierarchy(xml);
if (!includeRecycleBin) items = this.withoutRecycleBin(items);
const data: Record<string, any> = { items, count: items.length };
if (includeXml) data.xml = xml;
return data;
}
async listNotebooks() {
const items = await this.hierarchyItems("", "notebooks");
const notebooks = filterItems(items, "notebook");
return { notebooks, count: notebooks.length };
}
async listSections(notebookIdentifier = "", includeRecycleBin = false) {
let items = await this.hierarchyItems("", "sections");
if (!includeRecycleBin) items = this.withoutRecycleBin(items);
let sections = filterItems(items, "section");
if (notebookIdentifier) {
const notebook = resolveItemIn(items, notebookIdentifier, "notebook");
const prefix = notebook.path + "/";
sections = sections.filter((s) => (s.path || "").startsWith(prefix));
}
return { sections, count: sections.length };
}
async listPages(sectionIdentifier: string, includeXml = false, includeRecycleBin = false) {
const section = await this.resolveItem(sectionIdentifier, "section");
const xml = await this.hierarchyXml(section.id, "pages");
let pages = filterItems(parseHierarchy(xml), "page");
if (!includeRecycleBin) pages = this.withoutRecycleBin(pages);
for (const page of pages) {
if (!(page.path || "").startsWith(section.path + "/")) {
page.path = `${section.path}/${page.name || "(untitled)"}`;
page.notebook_name = section.notebook_name;
page.section_name = section.section_name;
}
}
const data: Record<string, any> = { section, pages, count: pages.length };
if (includeXml) data.xml = xml;
return data;
}
async getPage(pageIdentifier: string, pageInfo = "basic") {
const page = await this.resolveItem(pageIdentifier, "page");
const xml = await this.pageXml(page.id, pageInfo);
return { page, title: titleFromPageXml(xml), text: textFromPageXml(xml), objects: collectPageObjects(xml) };
}
async getPageXmlContent(pageIdentifier: string, pageInfo = "basic"): Promise<string> {
const pageId = await this.resolveId(pageIdentifier, "page");
return this.pageXml(pageId, pageInfo);
}
async getPageText(pageIdentifier: string): Promise<string> {
const pageId = await this.resolveId(pageIdentifier, "page");
return textFromPageXml(await this.pageXml(pageId, "basic"));
}
async getPageObjects(pageIdentifier: string) {
const pageId = await this.resolveId(pageIdentifier, "page");
const objects = collectPageObjects(await this.pageXml(pageId, "all"));
return { objects, count: objects.length };
}
async getBinaryContent(pageIdentifier: string, callbackId: string): Promise<string> {
const pageId = await this.resolveId(pageIdentifier, "page");
const res = await this.bridge.call("get_binary_page_content", { page_id: pageId, callback_id: callbackId });
return res.base64 as string;
}
async searchPages(
query: string,
startIdentifier = "",
maxResults = 20,
includeSnippets = true,
includeUnindexed = false,
includeRecycleBin = false,
) {
const startId = startIdentifier ? await this.resolveId(startIdentifier) : "";
let usedLocalScan = includeUnindexed;
let pages: HierItem[];
if (includeUnindexed) {
pages = await this.localTextSearch(startId, query, maxResults, includeRecycleBin);
} else {
try {
const res = await this.bridge.call("find_pages", {
start_id: startId,
query,
include_unindexed: false,
display: false,
schema: XML_SCHEMA_2013,
});
pages = filterItems(parseHierarchy(res.xml as string), "page");
} catch {
usedLocalScan = true;
pages = await this.localTextSearch(startId, query, maxResults, includeRecycleBin);
}
}
if (!includeRecycleBin) pages = this.withoutRecycleBin(pages);
pages = pages.slice(0, Math.max(1, maxResults));
if (includeSnippets) {
const q = query.toLowerCase();
for (const page of pages) {
try {
const text = textFromPageXml(await this.pageXml(page.id, "basic"));
const idx = text.toLowerCase().indexOf(q);
if (idx >= 0) {
const start = Math.max(0, idx - 160);
const end = Math.min(text.length, idx + query.length + 240);
page.snippet = text.slice(start, end).trim();
}
} catch (exc: any) {
page.snippet_error = String(exc?.message ?? exc);
}
}
}
return { pages, count: pages.length, search_backend: usedLocalScan ? "local_scan" : "onenote_index" };
}
async findMeta(startIdentifier: string, name: string, includeUnindexed = true) {
const startId = startIdentifier ? await this.resolveId(startIdentifier) : "";
const res = await this.bridge.call("find_meta", {
start_id: startId,
name,
include_unindexed: includeUnindexed,
schema: XML_SCHEMA_2013,
});
const items = parseHierarchy(res.xml as string);
return { items, count: items.length };
}
async findReferencesToHyperlink(hyperlinkUrl: string, startIdentifier = "", includeRecycleBin = false, maxSnippetChars = 100) {
if (!hyperlinkUrl) throw new Error("hyperlink_url is required.");
const results: Array<{ page_path: string; page_id: string; page_name: string; references: Array<Record<string, any>> }> = [];
// Batch by section to avoid a single massive GetHierarchy call.
let sections: HierItem[];
if (startIdentifier) {
const startId = await this.resolveId(startIdentifier);
const items = parseHierarchy(await this.hierarchyXml(startId, "sections"));
sections = filterItems(items, "section");
if (!includeRecycleBin) sections = this.withoutRecycleBin(sections);
} else {
const secList = await this.listSections("", includeRecycleBin);
sections = secList.sections;
}
// Extract canonical IDs from the target URL — skip pages whose IDs don't match.
const targetIds = extractOnenoteIds(hyperlinkUrl);
// Phase 1: check if any page's COM API ID matches the target's canonical IDs.
let hasMatchingPages = false;
if (targetIds && (targetIds.sectionId || targetIds.pageId)) {
for (const section of sections) {
try {
const pageXml = await this.hierarchyXml(section.id, "pages");
let pages = filterItems(parseHierarchy(pageXml), "page");
if (!includeRecycleBin) pages = this.withoutRecycleBin(pages);
for (const page of pages) {
const pageGuids = extractAllBaseGuids(page.id);
for (const pg of pageGuids) {
if ((targetIds.sectionId && oneNoteIdsMatch(pg, targetIds.sectionId)) ||
(targetIds.pageId && oneNoteIdsMatch(pg, targetIds.pageId))) {
hasMatchingPages = true; break;
}
}
if (hasMatchingPages) break;
}
if (hasMatchingPages) break;
} catch { /* skip */ }
}
}
// Phase 2: scan pages — apply skip optimization only if matching pages exist.
for (const section of sections) {
try {
const pageXml = await this.hierarchyXml(section.id, "pages");
let pages = filterItems(parseHierarchy(pageXml), "page");
if (!includeRecycleBin) pages = this.withoutRecycleBin(pages);
for (const page of pages) {
try {
// Skip pages whose COM API IDs don't contain the target URL's canonical section-id or page-id.
const pageGuids = extractAllBaseGuids(page.id);
if (!hasMatchingPages || !targetIds || (!targetIds.sectionId && !targetIds.pageId)) {
// No matching pages found or no canonical IDs — scan all pages.
} else {
let matches = false;
for (const pg of pageGuids) {
if (targetIds.sectionId && oneNoteIdsMatch(pg, targetIds.sectionId)) { matches = true; break; }
if (targetIds.pageId && oneNoteIdsMatch(pg, targetIds.pageId)) { matches = true; break; }
}
if (!matches) continue; // Skip this page — its IDs don't match the target.
}
const refs = findHyperlinkReferencesInPageXml(await this.pageXml(page.id, "all"), hyperlinkUrl, maxSnippetChars);
if (refs.length > 0) {
// Construct full hierarchical path: section's complete path + page name.
const fullPath = `${section.path}/${page.name || "(untitled)"}`;
results.push({
page_path: fullPath,
page_id: page.id,
page_name: page.name || "(untitled)",
references: refs.map((r) => ({ href: r.href, object_id: r.object_id ?? null, container_object_id: r.container_object_id ?? null, hyperlink_text: r.hyperlink_text ?? null, text_snippet: r.text_snippet ?? null, container_type: r.container_type ?? null, previous_paragraph_text: r.previous_paragraph_text ?? null, next_paragraph_text: r.next_paragraph_text ?? null })),
});
}
} catch (exc: any) {
// Page may be inaccessible; skip silently.
}
}
} catch (exc: any) {
// Section hierarchy may be inaccessible; skip silently.
}
}
return { results, count: results.length };
}
async getHyperlink(objectIdentifier: string, pageContentObjectId = "", web = false) {
const objectId = await this.resolveId(objectIdentifier);
const op = web ? "get_web_hyperlink" : "get_hyperlink";
const res = await this.bridge.call(op, { object_id: objectId, page_content_object_id: pageContentObjectId });
return { hyperlink: res.hyperlink as string };
}
async getParent(objectIdentifier: string) {
const objectId = await this.resolveId(objectIdentifier);
const res = await this.bridge.call("get_hierarchy_parent", { object_id: objectId });
return { parent_id: res.parent_id as string };
}
// --- creation & structural edits ------------------------------------------
async openHierarchy(path: string, relativeToIdentifier = "", createType = "none") {
const normalizedCreateType = createType.trim().toLowerCase() || "none";
let relativeToId = "";
let expectedPath = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
if (relativeToIdentifier) {
const parent = await this.resolveItem(relativeToIdentifier);
relativeToId = parent.id;
expectedPath = this.friendlyChildPath(parent.path, path);
}
if (normalizedCreateType === "none") {
const existing = await this.findItemByPath(expectedPath);
if (existing) return { object_id: existing.id, item: existing, opened_existing: true };
if (!relativeToIdentifier) {
try {
const resolved = await this.resolveItem(path);
return { object_id: resolved.id, item: resolved, opened_existing: true };
} catch {
/* fall through to create/open */
}
}
}
const res = await this.bridge.call("open_hierarchy", {
path,
relative_to_id: relativeToId,
create_file_type: enumValue("create_type", normalizedCreateType, CREATE_FILE_TYPES),
});
const itemType = this.createTypeToItemType(normalizedCreateType);
const item = itemType
? await this.refreshCreatedItem({ expectedPath, itemType, fallbackId: res.object_id as string })
: null;
const data: Record<string, any> = { object_id: item ? item.id : res.object_id, opened_existing: false };
if (item) data.item = item;
return data;
}
async createNotebook(nameOrPath: string, baseFolder = "") {
let notebookPath: string;
if (isAbsolute(nameOrPath)) {
notebookPath = nameOrPath;
} else {
let root: string;
if (baseFolder) root = baseFolder;
else {
const res = await this.bridge.call("get_special_location", {
location: SPECIAL_LOCATIONS["default_notebook_folder"],
});
root = res.path as string;
}
notebookPath = resolvePath(root, this.safeLeafName(nameOrPath));
}
const res = await this.bridge.call("open_hierarchy", {
path: notebookPath,
relative_to_id: "",
create_file_type: CREATE_FILE_TYPES["notebook"],
});
return { path: notebookPath, notebook_id: res.object_id as string };
}
async createSection(parentIdentifier: string, sectionName: string) {
const parent = await this.resolveItem(parentIdentifier);
if (parent.type !== "notebook" && parent.type !== "section_group") {
throw new Error("parent_identifier must resolve to a notebook or section_group.");
}
let filename = this.safeLeafName(sectionName);
if (!filename.toLowerCase().endsWith(".one")) filename += ".one";
const res = await this.bridge.call("open_hierarchy", {
path: filename,
relative_to_id: parent.id,
create_file_type: CREATE_FILE_TYPES["section"],
});
const expectedPath = this.friendlyChildPath(parent.path, filename);
const section = await this.refreshCreatedItem({
expectedPath,
itemType: "section",
fallbackId: res.object_id as string,
});
return {
parent,
section,
section_id: section ? section.id : (res.object_id as string),
name: sectionName,
path: expectedPath,
};
}
async createSectionGroup(parentIdentifier: string, groupName: string) {
const parent = await this.resolveItem(parentIdentifier);
if (parent.type !== "notebook" && parent.type !== "section_group") {
throw new Error("parent_identifier must resolve to a notebook or section_group.");
}
const res = await this.bridge.call("open_hierarchy", {
path: this.safeLeafName(groupName),
relative_to_id: parent.id,
create_file_type: CREATE_FILE_TYPES["section_group"],
});
const expectedPath = this.friendlyChildPath(parent.path, groupName);
const group = await this.refreshCreatedItem({
expectedPath,
itemType: "section_group",
fallbackId: res.object_id as string,
});
return {
parent,
section_group: group,
section_group_id: group ? group.id : (res.object_id as string),
name: groupName,
path: expectedPath,
};
}
async createPage(
sectionIdentifier: string,
title: string,
content = "",
contentFormat: ContentFormat = "plain",
newPageStyle = "blank_with_title",
) {
const section = await this.resolveItem(sectionIdentifier, "section");
const created = await this.bridge.call("create_new_page", {
section_id: section.id,
new_page_style: enumValue("new_page_style", newPageStyle, NEW_PAGE_STYLES),
});
const pageId = created.page_id as string;
const xml = await buildPageUpdateXml(pageId, { title, content, contentFormat, mdTimeout: this.mdTimeout });
await this.bridge.call("update_page_content", { xml, schema: XML_SCHEMA_2013, force: false });
const expectedPath = this.friendlyChildPath(section.path, title);
const page = await this.refreshCreatedItem({ expectedPath, itemType: "page", fallbackId: pageId });
return { page_id: page ? page.id : pageId, page, section, title, path: expectedPath };
}
async updatePageTitle(pageIdentifier: string, title: string) {
const pageId = await this.resolveId(pageIdentifier, "page");
const xml = await buildPageUpdateXml(pageId, { title });
await this.bridge.call("update_page_content", { xml, schema: XML_SCHEMA_2013, force: false });
return { page_id: pageId, title };
}
async addOutlineToPage(
pageIdentifier: string,
content: string,
contentFormat: ContentFormat = "plain",
x?: number | null,
y?: number | null,
) {
const pageId = await this.resolveId(pageIdentifier, "page");
const xml = await buildPageUpdateXml(pageId, { content, contentFormat, x, y, mdTimeout: this.mdTimeout });
await this.bridge.call("update_page_content", { xml, schema: XML_SCHEMA_2013, force: false });
return { page_id: pageId, added: true };
}
async addContentToPageOutline(
pageIdentifier: string,
outlineId: string | null | undefined,
content: string,
contentFormat: ContentFormat = "plain",
) {
const pageId = await this.resolveId(pageIdentifier, "page");
const oeFragment = await contentToOeXml(content, contentFormat, this.mdTimeout);
if (!oeFragment) throw new Error("Content produced no OneNote elements.");
const currentXml = await this.pageXml(pageId, "basic");
const result = addContentToOutlineXml(currentXml, outlineId, oeFragment);
await this.bridge.call("update_page_content", { xml: result.xml, schema: XML_SCHEMA_2013, force: true });
return { page_id: pageId, outline_id: outlineId ?? null, outline_created: result.outline_created, added: true };
}
async addTodoItemsToPageOutline(
pageIdentifier: string,
outlineId: string | null | undefined,
items: Array<{ text: string; completed?: boolean; indentLevel?: number }>,
contentFormat: ContentFormat = "plain",
position?: number | null,
) {
const pageId = await this.resolveId(pageIdentifier, "page");
const currentXml = await this.pageXml(pageId, "basic");
const rendered: TodoItemInput[] = [];
for (const it of items) {
rendered.push({
html: await contentToInlineHtml(it.text, contentFormat),
completed: it.completed,
indentLevel: it.indentLevel,
});
}
const result = addTodoItemsToOutlineXml(currentXml, outlineId, rendered, position);
await this.bridge.call("update_page_content", { xml: result.xml, schema: XML_SCHEMA_2013, force: true });
return {
page_id: pageId,
outline_id: outlineId ?? null,
outline_created: result.outline_created,
tag_index: result.tag_index,
items_added: result.items_added,
inserted_position: result.inserted_position,
added: true,
};
}
async addImageToPage(
pageIdentifier: string,
imagePath: string,
imageFormat = "",
x = 36.0,
y = 120.0,
width?: number | null,
height?: number | null,
) {
if (!existsSync(imagePath)) throw new Error(`Image file not found: ${imagePath}`);
let fmt = imageFormat;
if (!fmt) {
const dot = imagePath.lastIndexOf(".");
fmt = dot >= 0 ? imagePath.slice(dot + 1) : "";
}
if (!fmt) throw new Error("image_format is required when image_path has no extension.");
const [resolvedWidth, resolvedHeight] = await proportionalDimensions(imagePath, width ?? null, height ?? null);
const imageBase64 = (await readFile(imagePath)).toString("base64");
const pageId = await this.resolveId(pageIdentifier, "page");
const xml = buildImagePageUpdateXml(pageId, {
imageBase64,
imageFormat: fmt,
x,
y,
width: resolvedWidth,
height: resolvedHeight,
});
await this.bridge.call("update_page_content", { xml, schema: XML_SCHEMA_2013, force: false });
return { page_id: pageId, image_path: imagePath, width: resolvedWidth, height: resolvedHeight };
}
async replacePageText(
pageIdentifier: string,
searchText: string,
replacementText: string,
contentFormat: ContentFormat = "plain",
preserveFormatting = true,
) {
const pageId = await this.resolveId(pageIdentifier, "page");
const pageXml = await this.pageXml(pageId, "all");
// Convert the replacement text into a OneNote inline fragment matching the requested format.
const inlineFragment = await contentToInlineHtml(replacementText, contentFormat, this.mdTimeout);
const updatedXml = replaceTextInPageXml(pageXml, searchText, inlineFragment, preserveFormatting);
await this.bridge.call("update_page_content", { xml: updatedXml, schema: XML_SCHEMA_2013, force: false });
return { page_id: pageId };
}
async replaceEntirePageBody(
pageIdentifier: string,
content: string,
title?: string | null,
contentFormat: ContentFormat = "plain",
) {
const pageId = await this.resolveId(pageIdentifier, "page");
const pageXml = await this.pageXml(pageId, "all");
const objects = collectPageObjects(pageXml);
const deleted: string[] = [];
for (const obj of objects) {
if (!REPLACE_BODY_OBJECT_TYPES.has(obj.type)) continue;
const objectId = obj.object_id;
if (!objectId) continue;
await this.bridge.call("delete_page_content", { page_id: pageId, object_id: objectId, force: true });
deleted.push(objectId);
}
const xml = await buildPageUpdateXml(pageId, { title, content, contentFormat, mdTimeout: this.mdTimeout });
await this.bridge.call("update_page_content", { xml, schema: XML_SCHEMA_2013, force: true });
return { page_id: pageId, deleted_objects: deleted, replaced: true };
}
async deletePageContent(pageIdentifier: string, objectId: string) {
const pageId = await this.resolveId(pageIdentifier, "page");
const objects = collectPageObjects(await this.pageXml(pageId, "all"));
const matched = objects.find((o) => o.object_id === objectId);
if (matched && !matched.delete_supported) {
const suggested = matched.delete_object_id;
if (suggested) {
throw new Error(
`Object '${objectId}' is a ${matched.type} child and is not directly deletable by OneNote COM. ` +
`Delete its parent content object '${suggested}' instead.`,
);
}
throw new Error(`Object '${objectId}' is a ${matched.type} child and is not directly deletable by OneNote COM.`);
}
await this.bridge.call("delete_page_content", { page_id: pageId, object_id: objectId, force: true });
return { page_id: pageId, object_id: objectId, deleted: true };
}
async deleteHierarchy(objectIdentifier: string, permanently = false) {
let item = await this.resolveItem(objectIdentifier);
const deletedIds: string[] = [];
for (let attempt = 0; attempt < 4; attempt++) {
const objectId = item.id;
await this.bridge.call("delete_hierarchy", { object_id: objectId, permanently });
deletedIds.push(objectId);
await sleep(500);
const remaining = await this.findItemByPath(item.path, item.type);
if (!remaining) {
return { object_id: objectId, deleted_ids: deletedIds, permanently, deleted: true, verified_gone: true };
}
item = remaining;
if (attempt === 3) {
throw new Error(`Delete returned success, but '${item.path}' still exists with ID ${item.id}.`);
}
}
throw new Error("Delete did not complete.");
}
async updatePageXml(xml: string, force = false) {
await this.bridge.call("update_page_content", { xml, schema: XML_SCHEMA_2013, force });
return { updated: true, force };
}
async updateHierarchyXml(xml: string) {
await this.bridge.call("update_hierarchy", { xml, schema: XML_SCHEMA_2013 });
return { updated: true };
}
// --- file & app control ---------------------------------------------------
async publishObject(objectIdentifier: string, targetPath: string, format = "pdf", overwrite = false) {
let output = isAbsolute(targetPath) ? targetPath : resolvePath(process.cwd(), targetPath);
if (existsSync(output) && !overwrite) throw new Error(`Target already exists: ${targetPath}`);
await mkdir(dirname(output), { recursive: true });
const objectId = await this.resolveId(objectIdentifier);
const res = await this.bridge.call("publish", {
object_id: objectId,
target_path: output,
format: enumValue("format", format, PUBLISH_FORMATS),
});
return { path: res.path as string };
}
async navigateTo(objectIdentifier: string, pageContentObjectId = "", newWindow = false) {
const objectId = await this.resolveId(objectIdentifier);
await this.bridge.call("navigate_to", {
object_id: objectId,
page_content_object_id: pageContentObjectId,
new_window: newWindow,
});
return { navigated: true };
}
async navigateToUrl(url: string, newWindow = false) {
await this.bridge.call("navigate_to_url", { url, new_window: newWindow });
return { navigated: true };
}
async syncHierarchy(objectIdentifier: string) {
const objectId = await this.resolveId(objectIdentifier);
await this.bridge.call("sync_hierarchy", { object_id: objectId });
return { object_id: objectId, synced: true };
}
async closeNotebook(notebookIdentifier: string, force = false) {
const notebookId = await this.resolveId(notebookIdentifier, "notebook");
await this.bridge.call("close_notebook", { notebook_id: notebookId, force });
return { notebook_id: notebookId, closed: true };
}
async mergeSections(sourceSectionIdentifier: string, destinationSectionIdentifier: string) {
const sourceId = await this.resolveId(sourceSectionIdentifier, "section");
const destinationId = await this.resolveId(destinationSectionIdentifier, "section");
await this.bridge.call("merge_sections", {
source_section_id: sourceId,
destination_section_id: destinationId,
});
return { source_section_id: sourceId, destination_section_id: destinationId, merged: true };
}
async setFilingLocation(filingLocation: string, filingLocationType: string, sectionOrPageIdentifier: string) {
const objectId = await this.resolveId(sectionOrPageIdentifier);
await this.bridge.call("set_filing_location", {
filing_location: enumValue("filing_location", filingLocation, FILING_LOCATIONS),
filing_location_type: enumValue("filing_location_type", filingLocationType, FILING_LOCATION_TYPES),
section_or_page_id: objectId,
});
return { object_id: objectId, updated: true };
}
// --- new table & duplicate operations -------------------------------------
async getPageTableXml(pageIdentifier: string, tableId: string) {
const pageId = await this.resolveId(pageIdentifier, "page");
const xml = await this.pageXml(pageId, "basic");
return { page_id: pageId, table_id: tableId, table_count: countTables(xml), table_xml: getTableXml(xml, tableId) };
}
async replacePageTableXml(pageIdentifier: string, tableId: string, tableXml: string) {
const pageId = await this.resolveId(pageIdentifier, "page");
const currentXml = await this.pageXml(pageId, "basic");
const updatedPageXml = replaceTableInPageXml(currentXml, tableId, tableXml);
await this.bridge.call("update_page_content", { xml: updatedPageXml, schema: XML_SCHEMA_2013, force: true });
return { page_id: pageId, table_id: tableId, updated: true };
}
async insertRowIntoTable(
pageIdentifier: string,
tableId: string,
values: string[],
index?: number | null,
templateRowIndex?: number | null,
) {
const pageId = await this.resolveId(pageIdentifier, "page");
const currentXml = await this.pageXml(pageId, "basic");
const result = insertRowIntoTableXml(currentXml, tableId, values, index ?? null, templateRowIndex ?? null);
await this.bridge.call("update_page_content", { xml: result.xml, schema: XML_SCHEMA_2013, force: true });
return {
page_id: pageId,
table_id: tableId,
inserted_at: result.inserted_at,
columns: result.columns,
cells_written: result.cells_written,
inserted: true,
};
}
async replaceTableCellText(pageIdentifier: string, tableId: string, row: number, column: number, text: string) {
const pageId = await this.resolveId(pageIdentifier, "page");
const currentXml = await this.pageXml(pageId, "basic");
const updatedPageXml = updateTableCellTextXml(currentXml, tableId, row, column, text);
await this.bridge.call("update_page_content", { xml: updatedPageXml, schema: XML_SCHEMA_2013, force: true });
return { page_id: pageId, table_id: tableId, row, column, updated: true };
}
async deleteRowFromTable(
pageIdentifier: string,
tableId: string,
rowIndex?: number | null,
firstColumnText?: string | null,
) {
const pageId = await this.resolveId(pageIdentifier, "page");
const currentXml = await this.pageXml(pageId, "basic");
const result = deleteRowFromTableXml(currentXml, tableId, { rowIndex: rowIndex ?? null, firstColumnText: firstColumnText ?? null });
await this.bridge.call("update_page_content", { xml: result.xml, schema: XML_SCHEMA_2013, force: true });
return { page_id: pageId, table_id: tableId, deleted_row: result.deleted_row, deleted: true };
}
// --- snippet generators (no page mutation) --------------------------------
async generateOneNoteHyperlinksXml(
items: Array<{ targetPageIdentifier: string; text: string; formatting?: TextFormatting }>,
web = false,
) {
const op = web ? "get_web_hyperlink" : "get_hyperlink";
const results: Array<{ ok: boolean; target_page_id: string; hyperlink_url: string; inline_html: string; one_t_xml: string; one_oe_xml: string }> = [];
for (const item of items) {
const objectId = await this.resolveId(item.targetPageIdentifier, "page");
const res = await this.bridge.call(op, { object_id: objectId, page_content_object_id: "" });
const url = res.hyperlink as string;
const snippet = buildHyperlinkSnippet(url, item.text, item.formatting ?? {});
results.push({ ok: true, target_page_id: objectId, ...snippet });
}
return { ok: true, items: results };
}
generateTodoTagXml(text?: string | null, completed = false, tagIndex = 0) {
return { ok: true, ...buildTodoTagSnippet({ text: text ?? undefined, completed, tagIndex }) };
}
// --- outlines -------------------------------------------------------------
async getPageOutlineXml(pageIdentifier: string, outlineId: string) {
const pageId = await this.resolveId(pageIdentifier, "page");
const xml = await this.pageXml(pageId, "basic");
return { page_id: pageId, outline_id: outlineId, outline_count: countOutlines(xml), outline_xml: getOutlineXml(xml, outlineId) };
}
async insertPageOutlineXml(pageIdentifier: string, outlineXml: string, position: OutlinePosition = "bottom") {
const pageId = await this.resolveId(pageIdentifier, "page");
const currentXml = await this.pageXml(pageId, "basic");
const updated = insertOutlineIntoPageXml(currentXml, outlineXml, position);
await this.bridge.call("update_page_content", { xml: updated, schema: XML_SCHEMA_2013, force: true });
return { page_id: pageId, position, inserted: true };
}
async duplicatePageOutline(pageIdentifier: string, outlineId: string, position: OutlinePosition = "bottom") {
const pageId = await this.resolveId(pageIdentifier, "page");
const currentXml = await this.pageXml(pageId, "basic");
const result = duplicateOutlineInPageXml(currentXml, outlineId, position);
await this.bridge.call("update_page_content", { xml: result.xml, schema: XML_SCHEMA_2013, force: true });
return { page_id: pageId, outline_id: outlineId, source_index: result.source_index, position, duplicated: true };
}
async duplicatePage(pageIdentifier: string, targetSectionIdentifier = "", newTitle?: string | null) {
const source = await this.resolveItem(pageIdentifier, "page");
const sourceXml = await this.pageXml(source.id, "all");
let section: HierItem;
if (targetSectionIdentifier) {
section = await this.resolveItem(targetSectionIdentifier, "section");
} else {
const sectionId = source.parent_id;
if (!sectionId) throw new Error("Could not determine the source page's section; specify target_section_identifier.");
const found = await this.findItemById(sectionId, "section");
if (!found) throw new Error("Could not resolve the source page's section.");
section = found;
}
const created = await this.bridge.call("create_new_page", {
section_id: section.id,
new_page_style: NEW_PAGE_STYLES["default"],
});
const newPageId = created.page_id as string;
const newXml = buildDuplicatePageXml(sourceXml, newPageId, newTitle ?? null);
await this.bridge.call("update_page_content", { xml: newXml, schema: XML_SCHEMA_2013, force: true });
const title = newTitle ?? source.name;
const expectedPath = this.friendlyChildPath(section.path, String(title));
const page = await this.refreshCreatedItem({ expectedPath, itemType: "page", fallbackId: newPageId });
return {
page_id: page ? page.id : newPageId,
page,
section,
title,
source_page_id: source.id,
path: expectedPath,
};
}
}