Files

897 lines
34 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prism Notes</title>
<link rel="icon" type="image/png" href="/images/prism.png" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="/vendor/msal-browser.js"></script>
<style>
.panel {
border-radius: 1rem;
border: 1px solid rgb(30 41 59 / 1);
background: rgb(15 23 42 / 0.78);
backdrop-filter: blur(8px);
padding: 1.25rem;
}
.btn {
border-radius: 0.75rem;
padding: 0.55rem 1rem;
font-weight: 600;
transition: all 160ms ease;
position: relative;
overflow: hidden;
}
.btn:focus-visible,
.field:focus-visible {
outline: 2px solid rgb(34 211 238 / 0.8);
outline-offset: 1px;
}
.btn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(120deg, transparent 0%, rgb(255 255 255 / 0.2) 48%, transparent 100%);
transform: translateX(-130%);
transition: transform 260ms ease;
pointer-events: none;
}
.btn:hover {
transform: translateY(-1px) scale(1.01);
box-shadow: 0 10px 22px rgb(15 23 42 / 0.28);
}
.btn:hover::before { transform: translateX(130%); }
.btn-accent-cyan {
color: rgb(8 47 73 / 1);
background: rgb(6 182 212 / 1);
}
.btn-accent-emerald {
color: rgb(6 44 32 / 1);
background: rgb(34 197 94 / 1);
}
.btn-accent-indigo {
color: rgb(255 255 255 / 0.98);
background: rgb(99 102 241 / 1);
}
.btn-accent-violet {
color: rgb(255 255 255 / 0.98);
background: rgb(139 92 246 / 1);
}
.btn-secondary {
border: 1px solid rgb(51 65 85 / 1);
background: rgb(15 23 42 / 1);
color: rgb(226 232 240 / 1);
}
.btn-secondary:hover { border-color: rgb(100 116 139 / 1); }
.field {
width: 100%;
border-radius: 0.75rem;
border: 1px solid rgb(51 65 85 / 1);
background: rgb(2 6 23 / 1);
padding: 0.55rem 0.75rem;
font-size: 0.875rem;
color: rgb(248 250 252 / 1);
}
.field option {
background: rgb(15 23 42 / 0.96);
color: rgb(248 250 252 / 1);
}
.field::placeholder { color: rgb(100 116 139 / 1); }
#output {
border: 1px solid rgb(30 41 59 / 1);
background: rgb(2 6 23 / 1);
}
code {
border: 1px solid rgb(51 65 85 / 1);
background: rgb(15 23 42 / 1);
padding: 0.12rem 0.35rem;
border-radius: 0.45rem;
}
</style>
</head>
<body class="min-h-screen bg-gradient-to-b from-slate-950 via-slate-950 to-slate-900 text-slate-100">
<main class="mx-auto max-w-6xl px-6 py-10">
<header class="panel mb-8">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-4">
<img src="/images/prism.png" alt="Prism Notes" class="h-14 w-14 rounded-xl object-cover ring-1 ring-slate-600/60" />
<div>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-400">Prism Notes</p>
<h1 class="mt-1 text-3xl font-semibold tracking-tight">Fresh rebuild</h1>
</div>
</div>
<div class="flex flex-wrap gap-3">
<a href="/notes-workspace" class="btn btn-secondary">Open Notes Workspace</a>
<a href="/notes" class="btn btn-secondary">Open Legacy Notes</a>
<a href="/tasks" class="btn btn-secondary">Open Tasks View</a>
</div>
</div>
<p class="mt-3 text-sm text-slate-300">Clean baseline UI with PocketBase auth, OneNote delegated auth, note submit, and OneNote proof actions.</p>
</header>
<section class="mb-6 grid gap-4 md:grid-cols-3">
<article class="panel">
<p class="text-xs uppercase tracking-wider text-slate-400">Server</p>
<p id="healthStatus" class="mt-2 text-sm font-medium">Checking...</p>
</article>
<article class="panel">
<p class="text-xs uppercase tracking-wider text-slate-400">Graph App Token</p>
<p id="graphStatus" class="mt-2 text-sm font-medium">Checking...</p>
</article>
<article class="panel">
<p class="text-xs uppercase tracking-wider text-slate-400">PocketBase User</p>
<p id="pbStatus" class="mt-2 text-sm font-medium">Not logged in</p>
</article>
</section>
<section class="mb-6 grid gap-6 lg:grid-cols-2">
<article class="panel p-6">
<h2 class="text-xl font-medium">1) Prism Notes Login</h2>
<p class="mt-2 text-sm text-slate-300">Uses PocketBase OAuth with Microsoft to validate the Prism Notes user session.</p>
<div class="mt-4 flex flex-wrap gap-3">
<button id="loginBtn" class="btn btn-accent-cyan">Login with Microsoft</button>
<button id="logoutBtn" class="btn btn-secondary">Logout</button>
</div>
</article>
<article class="panel p-6">
<h2 class="text-xl font-medium">2) OneNote Connect</h2>
<p class="mt-2 text-sm text-slate-300">Gets delegated Graph token used for OneNote APIs.</p>
<p id="oneNoteAuthStatus" class="mt-3 text-sm font-medium">Disconnected</p>
<div class="mt-4 flex flex-wrap gap-3">
<button id="connectOneNoteBtn" class="btn btn-accent-emerald">Connect OneNote</button>
<button id="refreshOneNoteTokenBtn" class="btn btn-secondary">Refresh token</button>
</div>
</article>
</section>
<section class="mb-6 grid gap-6 lg:grid-cols-2">
<article class="panel p-6">
<h2 class="text-xl font-medium">3) Note + Sync</h2>
<div class="mt-4 space-y-4">
<div>
<label class="text-sm font-medium text-slate-300">Title</label>
<input id="noteTitle" class="field mt-1" placeholder="Example: Billing update" />
</div>
<div>
<label class="text-sm font-medium text-slate-300">Job Number</label>
<input id="noteJobNumber" class="field mt-1" placeholder="Optional" />
</div>
<div>
<label class="text-sm font-medium text-slate-300">Type</label>
<select id="noteType" class="field mt-1">
<option value="personal">personal</option>
<option value="job">job</option>
<option value="billing">billing</option>
</select>
</div>
<div>
<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_to" selected>add to same box (default)</option>
<option value="add_separate">add as separate box</option>
<option value="edit">edit note</option>
</select>
</div>
<div>
<label class="text-sm font-medium text-slate-300">Existing Note ID (add to/edit)</label>
<input id="syncEditNoteId" class="field mt-1" placeholder="Pick from page list or paste noteId" />
</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">Separate Insert Anchor (optional)</label>
<input id="syncObjectAnchor" class="field mt-1" placeholder="Paste OneNote paragraph/object link or object-id token" />
</div>
<div>
<label class="text-sm font-medium text-slate-300">Notes on Selected Page</label>
<select id="syncExistingNoteSelect" class="field mt-1"><option value="">— select a page first —</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>
<button id="syncOneNoteBtn" class="btn btn-accent-violet">Sync to OneNote</button>
</div>
</article>
<article class="panel p-6">
<h2 class="text-xl font-medium">4) OneNote Proof (Direct Graph)</h2>
<p class="mt-2 text-sm text-slate-300">Creates a page via site-scoped endpoints — bypasses the <code>me/onenote</code> 5,000-item tenant limit.</p>
<div class="mt-4 space-y-4">
<div class="flex flex-wrap items-center gap-3">
<button id="resolveSectionsBtn" class="btn btn-secondary">Resolve site sections</button>
<span id="resolveStatus" class="text-xs text-slate-400"></span>
</div>
<p class="text-xs text-slate-500">Fetches sections from <code>sites/czflex.sharepoint.com:/sites/Team/onenote/sections</code> — site-scoped only, no tenant scan.</p>
<div>
<label class="text-sm font-medium text-slate-300">Section</label>
<select id="sectionSelect" class="field mt-1"><option value="">— click Resolve —</option></select>
</div>
<div>
<label class="text-sm font-medium text-slate-300">Proof Page Title</label>
<input id="proofTitle" class="field mt-1" />
</div>
<div>
<label class="text-sm font-medium text-slate-300">Proof Message</label>
<textarea id="proofBody" rows="6" class="field mt-1"></textarea>
</div>
<button id="createProofPageBtn" class="btn btn-accent-emerald">Create OneNote Proof Page</button>
</div>
</article>
</section>
<section class="panel p-6">
<h2 class="text-xl font-medium">Output</h2>
<pre id="output" class="mt-4 min-h-[220px] overflow-auto rounded-xl border border-slate-800 bg-slate-950 p-4 text-xs leading-5 text-slate-200"></pre>
</section>
</main>
<script type="module">
import PocketBase from 'https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/+esm';
const PB_DEFAULT_AUTH_COLLECTION = 'Users';
const PB_DEFAULT_OAUTH_PROVIDER = 'microsoft';
const SCOPES = ['User.Read', 'Notes.ReadWrite.All', 'Sites.Read.All', 'offline_access'];
let pb = null;
let pbConfigPromise = null;
const output = document.getElementById('output');
const healthStatus = document.getElementById('healthStatus');
const graphStatus = document.getElementById('graphStatus');
const pbStatus = document.getElementById('pbStatus');
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 syncObjectAnchor = document.getElementById('syncObjectAnchor');
const syncExistingNoteSelect = document.getElementById('syncExistingNoteSelect');
const syncPageTitle = document.getElementById('syncPageTitle');
let resolvedSiteId = null;
let syncPageNoteBlocks = [];
const noteTitle = document.getElementById('noteTitle');
const noteJobNumber = document.getElementById('noteJobNumber');
const noteType = document.getElementById('noteType');
const noteBody = document.getElementById('noteBody');
const proofTitle = document.getElementById('proofTitle');
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()}`,
`Origin: ${location.origin}`,
].join('\n');
let msalClient = null;
let delegatedAccessToken = '';
let delegatedExpiresAt = 0;
async function getPocketBaseConfig() {
if (!pbConfigPromise) {
pbConfigPromise = fetch('/api/auth/pocketbase-config')
.then(async (resp) => {
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success || !data?.pbUrl) {
throw new Error(data?.message || 'PocketBase config unavailable');
}
return {
pbUrl: data.pbUrl,
collection: data.collection || PB_DEFAULT_AUTH_COLLECTION,
provider: data.provider || PB_DEFAULT_OAUTH_PROVIDER,
};
})
.catch((error) => {
pbConfigPromise = null;
throw error;
});
}
return pbConfigPromise;
}
async function ensurePocketBaseClient() {
if (pb) return pb;
const config = await getPocketBaseConfig();
pb = new PocketBase(config.pbUrl);
return pb;
}
function writeOutput(value) {
output.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function updatePbStatus() {
const client = await ensurePocketBaseClient();
if (client.authStore?.isValid) {
const email = client.authStore.model?.email || '(unknown user)';
pbStatus.textContent = `Logged in: ${email}`;
} else {
pbStatus.textContent = 'Not logged in';
}
}
function hasUsableToken() {
if (!delegatedAccessToken) return false;
if (!delegatedExpiresAt) return true;
return delegatedExpiresAt - 60000 > Date.now();
}
function updateOneNoteStatus() {
oneNoteAuthStatus.textContent = hasUsableToken()
? `Connected (expires ${new Date(delegatedExpiresAt).toLocaleTimeString()})`
: 'Disconnected';
}
async function loadHealth() {
try {
const resp = await fetch('/health');
const data = await resp.json();
healthStatus.textContent = resp.ok ? `OK (${data.pbDB || 'pb configured'})` : 'Unavailable';
} catch {
healthStatus.textContent = 'Unavailable';
}
}
async function loadGraphStatus() {
try {
const resp = await fetch('/api/graph/status');
const data = await resp.json().catch(() => ({}));
graphStatus.textContent = resp.ok && data?.active
? `Active (exp ${new Date(data.expiresOnISO).toLocaleTimeString()})`
: `Inactive (${data?.error || 'no token'})`;
} catch {
graphStatus.textContent = 'Inactive';
}
}
async function getMsalConfig() {
const resp = await fetch('/api/auth/msal-config');
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
throw new Error(data?.message || 'MSAL config unavailable');
}
return data;
}
async function ensureMsalClient() {
if (msalClient) return msalClient;
if (!window.msal?.PublicClientApplication) {
throw new Error('MSAL browser library not loaded');
}
const config = await getMsalConfig();
msalClient = new window.msal.PublicClientApplication({
auth: {
clientId: config.clientId,
authority: config.authority,
redirectUri: config.redirectUri || window.location.origin,
},
cache: {
cacheLocation: 'localStorage',
storeAuthStateInCookie: false,
},
});
await msalClient.initialize();
return msalClient;
}
function captureToken(result) {
delegatedAccessToken = result?.accessToken || '';
delegatedExpiresAt = result?.expiresOn?.getTime?.() || 0;
updateOneNoteStatus();
}
async function handleMsalRedirect() {
const client = await ensureMsalClient();
const result = await client.handleRedirectPromise();
if (result?.accessToken) {
captureToken(result);
writeOutput({ message: 'OneNote connected successfully.' });
}
const accounts = client.getAllAccounts();
if (accounts.length && !hasUsableToken()) {
try {
const silent = await client.acquireTokenSilent({ scopes: SCOPES, account: accounts[0] });
captureToken(silent);
} catch {
updateOneNoteStatus();
}
}
}
async function connectOneNoteInteractive() {
const client = await ensureMsalClient();
const accounts = client.getAllAccounts();
await client.acquireTokenRedirect({
scopes: SCOPES,
account: accounts[0] || undefined,
prompt: 'select_account',
redirectStartPage: window.location.href,
});
}
async function refreshOneNoteToken() {
const client = await ensureMsalClient();
const accounts = client.getAllAccounts();
if (!accounts.length) {
throw new Error('Connect OneNote first');
}
const result = await client.acquireTokenSilent({ scopes: SCOPES, account: accounts[0] });
captureToken(result);
writeOutput({ message: 'OneNote token refreshed.' });
}
async function graphJson(url, options = {}) {
if (!hasUsableToken()) {
throw new Error('Connect OneNote first');
}
const resp = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${delegatedAccessToken}`,
Accept: 'application/json',
...(options.headers || {}),
},
});
const text = await resp.text();
let data = {};
try {
data = text ? JSON.parse(text) : {};
} catch {
data = { raw: text };
}
if (!resp.ok) {
throw new Error(JSON.stringify({ status: resp.status, data }, null, 2));
}
return data;
}
async function loginPocketBase() {
writeOutput('Opening Microsoft login...');
const client = await ensurePocketBaseClient();
const config = await getPocketBaseConfig();
// Use the SDK's authWithOAuth2 realtime channel.
// Auth result is delivered back to this window via SSE from PocketBase,
// so it works regardless of how/where the login popup opens (including
// Edge app mode where external URLs open outside the app window).
const authData = await client.collection(config.collection).authWithOAuth2({
provider: config.provider,
urlCallback(url) {
const w = Math.min(900, window.screen.availWidth || 900);
const h = Math.min(680, window.screen.availHeight || 680);
const left = Math.floor(((window.screen.availWidth || 1280) - w) / 2);
const top = Math.floor(((window.screen.availHeight || 800) - h) / 2);
window.open(url, 'pb_oauth', `width=${w},height=${h},top=${top},left=${left},resizable=yes,menubar=no`);
},
});
await updatePbStatus();
writeOutput({
message: 'PocketBase login completed.',
user: authData?.record?.email || client.authStore.model?.email || null,
});
}
async function logoutPocketBase() {
const client = await ensurePocketBaseClient();
client.authStore.clear();
await updatePbStatus();
writeOutput({ message: 'PocketBase session cleared.' });
}
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: requestedNoteId || globalThis.crypto?.randomUUID?.() || `note-${Date.now()}`,
title,
bodyPlain,
noteType: noteType.value || 'personal',
userEmail: pb?.authStore.model?.email || '',
jobNumber: noteJobNumber.value.trim(),
};
}
function isExistingNoteMode() {
const mode = String(syncModeSelect.value || '').trim();
return mode === 'add_to' || mode === 'edit';
}
function parseObjectAnchor(rawValue) {
const raw = String(rawValue || '').trim();
if (!raw) return '';
try {
const decoded = decodeURIComponent(raw);
const m = decoded.match(/object-id=\{([0-9A-Fa-f-]{36})\}&([0-9A-Fa-f]+)/i);
if (m) {
return `{${m[1]}}&${m[2]}`;
}
} catch {
}
const direct = raw.match(/\{([0-9A-Fa-f-]{36})\}(?:&([0-9A-Fa-f]+))?/);
if (direct) {
return direct[2] ? `{${direct[1]}}&${direct[2]}` : `{${direct[1]}}`;
}
return raw;
}
function getSelectedPageNote() {
const noteId = syncExistingNoteSelect.value.trim();
return syncPageNoteBlocks.find((block) => block.noteId === noteId) || null;
}
function applySyncModeUi() {
const needsExisting = isExistingNoteMode();
syncEditNoteId.disabled = !needsExisting;
syncExistingNoteSelect.disabled = !needsExisting;
if (!needsExisting) {
syncEditNoteId.value = '';
}
}
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>';
syncExistingNoteSelect.innerHTML = '<option value="">— select a page first —</option>';
syncPageNoteBlocks = [];
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 loadPageNoteBlocksForSelectedPage() {
const pageId = syncPageSelect.value.trim();
syncPageNoteBlocks = [];
syncExistingNoteSelect.innerHTML = '';
if (!pageId) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = '— select a page first —';
syncExistingNoteSelect.appendChild(opt);
return;
}
syncTargetsStatus.textContent = 'Loading page note blocks…';
const resp = await fetch(`/api/onenote/page-note-blocks?pageId=${encodeURIComponent(pageId)}`, {
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);
}
syncPageNoteBlocks = Array.isArray(data.noteBlocks) ? data.noteBlocks : [];
if (!syncPageNoteBlocks.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = '— no notes found on this page —';
syncExistingNoteSelect.appendChild(opt);
syncTargetsStatus.textContent = '✓ 0 notes found';
return;
}
for (const block of syncPageNoteBlocks) {
const opt = document.createElement('option');
opt.value = block.noteId;
const preview = String(block.preview || '').replace(/\s+/g, ' ').trim().slice(0, 70);
const syncedLabel = block.syncedAt ? ` @ ${block.syncedAt}` : '';
opt.textContent = `${block.noteId}${syncedLabel}${preview || '(empty)'}`;
syncExistingNoteSelect.appendChild(opt);
}
syncExistingNoteSelect.value = syncPageNoteBlocks[0].noteId || '';
if (isExistingNoteMode()) {
syncEditNoteId.value = syncExistingNoteSelect.value;
}
syncTargetsStatus.textContent = `${syncPageNoteBlocks.length} note(s) loaded`;
writeOutput({ message: `Loaded ${syncPageNoteBlocks.length} note(s)`, pageId, notes: syncPageNoteBlocks });
}
async function submitNoteToPrismNotes() {
const client = await ensurePocketBaseClient();
if (!client.authStore.isValid || !client.authStore.token) {
throw new Error('PocketBase login required');
}
const payload = buildNotePayload();
const resp = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
pbToken: client.authStore.token,
}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data?.message || 'Submit failed');
}
writeOutput({ message: 'Submitted to Prism Notes', response: data, payload });
}
async function syncCurrentNoteToOneNote() {
const client = await ensurePocketBaseClient();
if (!client.authStore.isValid || !client.authStore.token) {
throw new Error('PocketBase login required');
}
if (!hasUsableToken()) {
throw new Error('OneNote connection required');
}
const requestedMode = String(syncModeSelect.value || '').trim();
const syncMode = requestedMode === 'add_to' ? 'add_to' : requestedMode === 'edit' ? 'edit' : 'add_separate';
const sectionId = syncSectionSelect.value.trim();
const pageId = syncPageSelect.value.trim();
const pageTitle = syncPageTitle.value.trim();
const selectedNoteId = syncExistingNoteSelect.value.trim();
const selectedPageNote = getSelectedPageNote();
const manualAnchor = parseObjectAnchor(syncObjectAnchor.value);
const targetNoteId = syncMode === 'add_to' || syncMode === 'edit'
? (syncEditNoteId.value.trim() || selectedNoteId)
: '';
const targetObjectId = syncMode === 'add_separate'
? (manualAnchor || syncPageNoteBlocks.at(-1)?.targetId || '')
: (selectedPageNote?.targetId || '');
if (!sectionId) {
throw new Error('Load sync targets and select a section first');
}
if ((syncMode === 'add_to' || syncMode === 'edit') && !pageId) {
throw new Error('Add/Edit mode requires selecting an existing OneNote page');
}
if ((syncMode === 'add_to' || syncMode === 'edit') && !targetNoteId) {
throw new Error('Add/Edit mode requires Existing Note ID');
}
if (syncMode === 'add_to' || syncMode === 'edit') {
syncEditNoteId.value = targetNoteId;
}
const payload = buildNotePayload({ noteId: syncMode === 'add_separate' ? '' : targetNoteId });
const resp = await fetch('/api/onenote/append', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${delegatedAccessToken}`,
'x-pb-token': client.authStore.token,
},
body: JSON.stringify({
...payload,
syncMode,
sectionId,
pageId,
pageTitle,
targetNoteId,
targetObjectId,
}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
throw new Error(data?.message || 'OneNote sync failed');
}
if (syncMode === 'add_separate' && 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;
await loadPageNoteBlocksForSelectedPage();
} else if (pageId) {
await loadPageNoteBlocksForSelectedPage();
}
if (syncMode === 'add_to' || syncMode === 'edit') {
syncEditNoteId.value = payload.noteId;
}
writeOutput({ message: `OneNote sync complete (${data?.mode || syncMode})`, response: data, payload });
}
async function resolveSiteSections() {
resolveStatus.textContent = 'Resolving…';
await loadSyncTargets();
}
async function createProofPage() {
if (!hasUsableToken()) throw new Error('OneNote connection required');
const sectionId = sectionSelect.value.trim();
if (!sectionId) throw new Error('Click "Resolve site sections" first and select a section');
if (!resolvedSiteId) throw new Error('Site ID not resolved — click "Resolve site sections" first');
const title = proofTitle.value.trim() || `Prism Notes Proof ${new Date().toISOString()}`;
const message = proofBody.value.trim() || 'Proof page';
const bodyHtml = escapeHtml(message).replace(/\r\n|\r|\n/g, '<br/>');
const html = [
'<!DOCTYPE html>',
'<html>',
' <head>',
` <title>${escapeHtml(title)}</title>`,
' </head>',
' <body>',
` <p><strong>${escapeHtml(title)}</strong></p>`,
` <p>${bodyHtml}</p>`,
' </body>',
'</html>',
].join('');
const created = await graphJson(
`https://graph.microsoft.com/v1.0/sites/${resolvedSiteId}/onenote/sections/${sectionId}/pages`,
{ method: 'POST', headers: { 'Content-Type': 'text/html' }, body: html },
);
writeOutput({ message: 'Proof page created', siteId: resolvedSiteId, sectionId, created });
}
function bind(id, handler) {
document.getElementById(id).addEventListener('click', async () => {
try {
await handler();
} catch (error) {
writeOutput({ error: error?.message || String(error) });
}
});
}
bind('loginBtn', loginPocketBase);
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) });
}
});
syncPageSelect.addEventListener('change', async () => {
try {
await loadPageNoteBlocksForSelectedPage();
} catch (error) {
writeOutput({ error: error?.message || String(error) });
}
});
syncExistingNoteSelect.addEventListener('change', () => {
if (isExistingNoteMode()) {
syncEditNoteId.value = syncExistingNoteSelect.value.trim();
}
});
syncModeSelect.addEventListener('change', () => {
applySyncModeUi();
if (isExistingNoteMode() && syncExistingNoteSelect.value.trim()) {
syncEditNoteId.value = syncExistingNoteSelect.value.trim();
}
});
ensurePocketBaseClient()
.then(() => updatePbStatus())
.catch((error) => writeOutput({ error: error?.message || String(error) }));
updateOneNoteStatus();
applySyncModeUi();
loadHealth();
loadGraphStatus();
handleMsalRedirect().catch((error) => writeOutput({ error: error?.message || String(error) }));
</script>
</body>
</html>