Upgrade OneNote sync with section/page targeting and edit mode

- Add GET /api/onenote/section-pages endpoint for listing pages in a section
- Extend /api/onenote/append with syncMode add/edit support
- Support editing an existing note block by noteId on a selected page
- Keep add mode for appending to selected page or creating new page in section
- Add Note + Sync UI controls for sync mode, section/page target, existing noteId,
  and optional new page title
- Wire frontend flow to load sync targets and pages, then sync with selected mode
This commit is contained in:
2026-03-24 18:45:27 +00:00
parent 513e975530
commit 8ca2c97e37
2 changed files with 236 additions and 39 deletions
+160 -34
View File
@@ -163,6 +163,35 @@
<label class="text-sm font-medium text-slate-300">Body</label>
<textarea id="noteBody" rows="8" class="field mt-1" placeholder="Write note content..."></textarea>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="text-sm font-medium text-slate-300">Sync Mode</label>
<select id="syncModeSelect" class="field mt-1">
<option value="add">add new note block</option>
<option value="edit">edit existing note block</option>
</select>
</div>
<div>
<label class="text-sm font-medium text-slate-300">Existing Note ID (edit mode)</label>
<input id="syncEditNoteId" class="field mt-1" placeholder="Paste noteId to replace on selected page" />
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<button id="loadSyncTargetsBtn" class="btn btn-secondary">Load sync targets</button>
<span id="syncTargetsStatus" class="text-xs text-slate-400"></span>
</div>
<div>
<label class="text-sm font-medium text-slate-300">OneNote Section (sync target)</label>
<select id="syncSectionSelect" class="field mt-1"><option value="">— click Load sync targets —</option></select>
</div>
<div>
<label class="text-sm font-medium text-slate-300">OneNote Page (optional)</label>
<select id="syncPageSelect" class="field mt-1"><option value="">— create new page in selected section —</option></select>
</div>
<div>
<label class="text-sm font-medium text-slate-300">New Page Title (used when page is not selected)</label>
<input id="syncPageTitle" class="field mt-1" placeholder="Optional (defaults to note title)" />
</div>
</div>
<div class="mt-4 flex flex-wrap gap-3">
<button id="submitNoteBtn" class="btn btn-accent-indigo">Submit to Prism Notes</button>
@@ -219,6 +248,12 @@
const oneNoteAuthStatus = document.getElementById('oneNoteAuthStatus');
const resolveStatus = document.getElementById('resolveStatus');
const sectionSelect = document.getElementById('sectionSelect');
const syncTargetsStatus = document.getElementById('syncTargetsStatus');
const syncModeSelect = document.getElementById('syncModeSelect');
const syncEditNoteId = document.getElementById('syncEditNoteId');
const syncSectionSelect = document.getElementById('syncSectionSelect');
const syncPageSelect = document.getElementById('syncPageSelect');
const syncPageTitle = document.getElementById('syncPageTitle');
let resolvedSiteId = null;
const noteTitle = document.getElementById('noteTitle');
@@ -230,6 +265,7 @@
const proofBody = document.getElementById('proofBody');
proofTitle.value = `Prism Notes Proof ${new Date().toISOString()}`;
syncPageTitle.value = `Prism Notes Sync ${new Date().toISOString().slice(0, 10)}`;
proofBody.value = [
'Proof that OneNote can be updated programmatically from outside OneNote.',
`Created: ${new Date().toISOString()}`,
@@ -461,14 +497,15 @@
writeOutput({ message: 'PocketBase session cleared.' });
}
function buildNotePayload() {
function buildNotePayload(options = {}) {
const bodyPlain = noteBody.value.trim();
if (!bodyPlain) {
throw new Error('Note body is required');
}
const title = noteTitle.value.trim() || 'Untitled';
const requestedNoteId = String(options.noteId || '').trim();
return {
noteId: globalThis.crypto?.randomUUID?.() || `note-${Date.now()}`,
noteId: requestedNoteId || globalThis.crypto?.randomUUID?.() || `note-${Date.now()}`,
title,
bodyPlain,
noteType: noteType.value || 'personal',
@@ -477,6 +514,81 @@
};
}
async function loadSyncTargets() {
syncTargetsStatus.textContent = 'Loading sections…';
if (!hasUsableToken()) {
syncTargetsStatus.textContent = '⚠ Connect OneNote first';
throw new Error('OneNote connection required');
}
const resp = await fetch('/api/onenote/site-sections', {
headers: { Authorization: `Bearer ${delegatedAccessToken}` },
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
const msg = data?.message || JSON.stringify(data?.error || data);
syncTargetsStatus.textContent = `${msg}`;
throw new Error(msg);
}
resolvedSiteId = data.siteId;
const sections = Array.isArray(data.sections) ? data.sections : [];
syncSectionSelect.innerHTML = '';
sectionSelect.innerHTML = '';
for (const section of sections) {
const label = `${section.displayName || section.id}${section.id}`;
const syncOpt = document.createElement('option');
syncOpt.value = section.id;
syncOpt.textContent = label;
syncSectionSelect.appendChild(syncOpt);
const proofOpt = document.createElement('option');
proofOpt.value = section.id;
proofOpt.textContent = label;
sectionSelect.appendChild(proofOpt);
}
syncTargetsStatus.textContent = `${sections.length} section(s) loaded`;
resolveStatus.textContent = `${sections.length} section(s) loaded`;
await loadSyncPagesForSelectedSection();
writeOutput({ message: `Loaded ${sections.length} section(s)`, siteId: resolvedSiteId, sections });
}
async function loadSyncPagesForSelectedSection() {
const sectionId = syncSectionSelect.value.trim();
syncPageSelect.innerHTML = '<option value="">— create new page in selected section —</option>';
if (!sectionId) {
syncTargetsStatus.textContent = 'Select a section first';
return;
}
syncTargetsStatus.textContent = 'Loading pages…';
const resp = await fetch(`/api/onenote/section-pages?sectionId=${encodeURIComponent(sectionId)}`, {
headers: { Authorization: `Bearer ${delegatedAccessToken}` },
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
const msg = data?.message || JSON.stringify(data?.error || data);
syncTargetsStatus.textContent = `${msg}`;
throw new Error(msg);
}
const pages = Array.isArray(data.pages) ? data.pages : [];
for (const page of pages) {
const opt = document.createElement('option');
opt.value = page.id;
opt.textContent = `${page.title || '(untitled)'}${page.id}`;
syncPageSelect.appendChild(opt);
}
syncTargetsStatus.textContent = `${pages.length} page(s) loaded`;
writeOutput({ message: `Loaded ${pages.length} page(s)`, sectionId, pages });
}
async function submitNoteToPrismNotes() {
const client = await ensurePocketBaseClient();
if (!client.authStore.isValid || !client.authStore.token) {
@@ -507,7 +619,23 @@
throw new Error('OneNote connection required');
}
const payload = buildNotePayload();
const syncMode = syncModeSelect.value === 'edit' ? 'edit' : 'add';
const sectionId = syncSectionSelect.value.trim();
const pageId = syncPageSelect.value.trim();
const pageTitle = syncPageTitle.value.trim();
const targetNoteId = syncEditNoteId.value.trim();
if (!sectionId) {
throw new Error('Load sync targets and select a section first');
}
if (syncMode === 'edit' && !pageId) {
throw new Error('Edit mode requires selecting an existing OneNote page');
}
if (syncMode === 'edit' && !targetNoteId) {
throw new Error('Edit mode requires Existing Note ID');
}
const payload = buildNotePayload({ noteId: syncMode === 'edit' ? targetNoteId : '' });
const resp = await fetch('/api/onenote/append', {
method: 'POST',
headers: {
@@ -515,46 +643,35 @@
Authorization: `Bearer ${delegatedAccessToken}`,
'x-pb-token': client.authStore.token,
},
body: JSON.stringify(payload),
body: JSON.stringify({
...payload,
syncMode,
sectionId,
pageId,
pageTitle,
targetNoteId,
}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
throw new Error(data?.message || 'OneNote sync failed');
}
writeOutput({ message: 'Synced to configured OneNote target', response: data, payload });
if (syncMode === 'add' && data?.pageId && !pageId) {
const createdOpt = document.createElement('option');
createdOpt.value = data.pageId;
createdOpt.textContent = `${pageTitle || payload.title}${data.pageId}`;
syncPageSelect.appendChild(createdOpt);
syncPageSelect.value = data.pageId;
}
syncEditNoteId.value = payload.noteId;
writeOutput({ message: `OneNote sync complete (${data?.mode || syncMode})`, response: data, payload });
}
async function resolveSiteSections() {
resolveStatus.textContent = 'Resolving…';
if (!hasUsableToken()) {
resolveStatus.textContent = '⚠ Connect OneNote first (section 2)';
throw new Error('OneNote connection required — click Connect OneNote in section 2 first');
}
try {
const resp = await fetch('/api/onenote/site-sections', {
headers: { Authorization: `Bearer ${delegatedAccessToken}` },
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
const msg = data?.message || JSON.stringify(data?.error || data);
resolveStatus.textContent = `${msg}`;
throw new Error(msg);
}
resolvedSiteId = data.siteId;
const sections = Array.isArray(data.sections) ? data.sections : [];
sectionSelect.innerHTML = '';
for (const s of sections) {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = `${s.displayName || s.id}${s.id}`;
sectionSelect.appendChild(opt);
}
resolveStatus.textContent = `${sections.length} section(s) loaded`;
writeOutput({ message: `Resolved ${sections.length} section(s)`, siteId: resolvedSiteId, sections });
} catch (e) {
if (!resolveStatus.textContent.startsWith('⚠')) resolveStatus.textContent = `${e.message}`;
throw e;
}
await loadSyncTargets();
}
async function createProofPage() {
@@ -599,11 +716,20 @@
bind('logoutBtn', logoutPocketBase);
bind('connectOneNoteBtn', connectOneNoteInteractive);
bind('refreshOneNoteTokenBtn', refreshOneNoteToken);
bind('loadSyncTargetsBtn', loadSyncTargets);
bind('submitNoteBtn', submitNoteToPrismNotes);
bind('syncOneNoteBtn', syncCurrentNoteToOneNote);
bind('resolveSectionsBtn', resolveSiteSections);
bind('createProofPageBtn', createProofPage);
syncSectionSelect.addEventListener('change', async () => {
try {
await loadSyncPagesForSelectedSection();
} catch (error) {
writeOutput({ error: error?.message || String(error) });
}
});
ensurePocketBaseClient()
.then(() => updatePbStatus())
.catch((error) => writeOutput({ error: error?.message || String(error) }));
+75 -4
View File
@@ -173,15 +173,43 @@ app.get('/api/onenote/site-sections', async (c) => {
}
});
// GET /api/onenote/section-pages?sectionId=...
// Requires: Authorization: Bearer <delegated token>
app.get('/api/onenote/section-pages', async (c) => {
try {
const delegatedToken = getBearerToken(c.req.header('authorization'));
if (!delegatedToken) return c.json({ success: false, message: 'Missing delegated token' }, 401);
const sectionId = String(c.req.query('sectionId') || '').trim();
if (!sectionId) return c.json({ success: false, message: 'Missing sectionId' }, 400);
const siteId = await resolveSiteId(delegatedToken);
const resp = await fetch(
`https://graph.microsoft.com/v1.0/sites/${siteId}/onenote/sections/${encodeURIComponent(sectionId)}/pages?$top=100&$select=id,title,createdDateTime,lastModifiedDateTime`,
{ headers: { Authorization: `Bearer ${delegatedToken}` } },
);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) return c.json({ success: false, siteId, sectionId, error: data }, resp.status as any);
return c.json({ success: true, siteId, sectionId, pages: data?.value || [] });
} catch (e) {
return c.json({ success: false, message: (e as Error)?.message }, 500);
}
});
// ---------------------------------------------------------------------------
// POST /api/onenote/append
// Requires: x-pb-token (PocketBase session) + Authorization: Bearer <delegated Graph token>
// Body: { noteId, title, bodyPlain, noteType?, userEmail?, jobNumber?, sectionId? }
// Body: {
// noteId, title, bodyPlain, noteType?, userEmail?, jobNumber?,
// sectionId?, pageId?, pageTitle?, syncMode?: 'add'|'edit', targetNoteId?
// }
//
// Strategy:
// 1. Resolve site ID via GET /sites/{host}:{path}
// 2. Try PATCH sites/{siteId}/onenote/pages/{pageId}/content → append to known page
// 3. If that fails, POST sites/{siteId}/onenote/sections/{sectionId}/pages → create new page
// 2. If syncMode=edit: PATCH replace note block in known page by noteId
// 3. If syncMode=add and pageId: PATCH append to known page
// 4. If syncMode=add and no pageId: POST create new page in known section
// ---------------------------------------------------------------------------
app.post('/api/onenote/append', async (c) => {
try {
@@ -206,6 +234,9 @@ app.post('/api/onenote/append', async (c) => {
const noteType = String(body?.noteType || 'personal').trim();
const userEmail = String(body?.userEmail || 'unknown').trim();
const jobNumber = String(body?.jobNumber || '').trim();
const pageTitle = String(body?.pageTitle || title || ONENOTE_TARGET.pageTitle || 'Prism Notes').trim();
const syncMode = String(body?.syncMode || 'add').trim().toLowerCase() === 'edit' ? 'edit' : 'add';
const targetNoteId = String(body?.targetNoteId || noteId || '').trim();
if (!noteId) return c.json({ success: false, message: 'Missing noteId' }, 400);
if (!bodyPlain) return c.json({ success: false, message: 'Missing bodyPlain' }, 400);
@@ -225,7 +256,7 @@ app.post('/api/onenote/append', async (c) => {
const pageHtml = [
'<!DOCTYPE html>',
'<html>',
`<head><title>${escapeHtml(title)}</title></head>`,
`<head><title>${escapeHtml(pageTitle)}</title></head>`,
`<body>${appendHtml}</body>`,
'</html>',
].join('');
@@ -233,6 +264,12 @@ app.post('/api/onenote/append', async (c) => {
const sectionId = String(body?.sectionId || ONENOTE_TARGET.sectionId || '').trim();
const pageId = String(body?.pageId || ONENOTE_TARGET.pageId || '').trim();
if (!sectionId) return c.json({ success: false, message: 'No sectionId — run GET /api/onenote/site-sections first to retrieve valid IDs' }, 400);
if (syncMode === 'edit' && !pageId) {
return c.json({ success: false, message: 'Edit mode requires pageId' }, 400);
}
if (syncMode === 'edit' && !targetNoteId) {
return c.json({ success: false, message: 'Edit mode requires targetNoteId or noteId' }, 400);
}
const authHeader = { Authorization: `Bearer ${delegatedToken}` };
@@ -240,6 +277,40 @@ app.post('/api/onenote/append', async (c) => {
const siteId = await resolveSiteId(delegatedToken);
const base = `https://graph.microsoft.com/v1.0/sites/${siteId}/onenote`;
if (syncMode === 'edit') {
const selectorNoteId = targetNoteId.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const replaceResp = await fetch(
`${base}/pages/${pageId}/content`,
{
method: 'PATCH',
headers: { ...authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify([
{
target: `div[data-prism-notes-note-id=\"${selectorNoteId}\"]`,
action: 'replace',
content: appendHtml,
},
]),
},
);
if (replaceResp.ok) {
console.log('onenote_edit_success', { mode: 'edit', noteId, targetNoteId, pageId, siteId });
return c.json({ success: true, mode: 'edited', pageId, noteId: targetNoteId, syncedAt: ts });
}
const replaceErr = await replaceResp.text();
console.warn('onenote_edit_failed', { status: replaceResp.status, error: replaceErr, pageId, targetNoteId });
return c.json(
{
success: false,
message: `OneNote edit failed (${replaceResp.status})`,
details: { edit: replaceErr, pageId, targetNoteId, siteId },
},
502,
);
}
/* ---- 1: try append to known page (if we have a valid pageId) ---- */
if (pageId) {
const patchResp = await fetch(