From 8ca2c97e379ab83d23ecad215aa9217e5ea61f8e Mon Sep 17 00:00:00 2001 From: aewing Date: Tue, 24 Mar 2026 18:45:27 +0000 Subject: [PATCH] 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 --- index.html | 196 +++++++++++++++++++++++++++++++++++++++++++---------- server.ts | 79 +++++++++++++++++++-- 2 files changed, 236 insertions(+), 39 deletions(-) diff --git a/index.html b/index.html index 1a77321..9c64121 100644 --- a/index.html +++ b/index.html @@ -163,6 +163,35 @@ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
@@ -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 = ''; + + 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('submitNoteBtn', submitNoteToPrismNotes); + 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) })); diff --git a/server.ts b/server.ts index b15d688..9b6c511 100644 --- a/server.ts +++ b/server.ts @@ -173,15 +173,43 @@ app.get('/api/onenote/site-sections', async (c) => { } }); +// GET /api/onenote/section-pages?sectionId=... +// Requires: Authorization: Bearer +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 -// 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 = [ '', '', - `${escapeHtml(title)}`, + `${escapeHtml(pageTitle)}`, `${appendHtml}`, '', ].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(