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:
+161
-35
@@ -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('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) }));
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user