diff --git a/index.html b/index.html index 9c64121..e5af892 100644 --- a/index.html +++ b/index.html @@ -93,11 +93,17 @@
-
- Prism Notes -
-

Prism Notes

-

Fresh rebuild

+
+
+ Prism Notes +
+

Prism Notes

+

Fresh rebuild

+
+
+

Clean baseline UI with PocketBase auth, OneNote delegated auth, note submit, and OneNote proof actions.

@@ -167,13 +173,14 @@
- - + +
@@ -188,6 +195,14 @@
+
+ + +
+
+ + +
@@ -253,8 +268,11 @@ 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'); @@ -514,6 +532,46 @@ }; } + 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()) { @@ -560,6 +618,8 @@ async function loadSyncPagesForSelectedSection() { const sectionId = syncSectionSelect.value.trim(); syncPageSelect.innerHTML = ''; + syncExistingNoteSelect.innerHTML = ''; + syncPageNoteBlocks = []; if (!sectionId) { syncTargetsStatus.textContent = 'Select a section first'; @@ -589,6 +649,58 @@ 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) { @@ -619,23 +731,36 @@ throw new Error('OneNote connection required'); } - const syncMode = syncModeSelect.value === 'edit' ? 'edit' : 'add'; + 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 targetNoteId = syncEditNoteId.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 === 'edit' && !pageId) { - throw new Error('Edit mode requires selecting an existing OneNote page'); + if ((syncMode === 'add_to' || syncMode === 'edit') && !pageId) { + throw new Error('Add/Edit mode requires selecting an existing OneNote page'); } - if (syncMode === 'edit' && !targetNoteId) { - throw new Error('Edit mode requires Existing Note ID'); + if ((syncMode === 'add_to' || syncMode === 'edit') && !targetNoteId) { + throw new Error('Add/Edit mode requires Existing Note ID'); } - const payload = buildNotePayload({ noteId: syncMode === 'edit' ? targetNoteId : '' }); + 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: { @@ -650,6 +775,7 @@ pageId, pageTitle, targetNoteId, + targetObjectId, }), }); const data = await resp.json().catch(() => ({})); @@ -657,14 +783,19 @@ throw new Error(data?.message || 'OneNote sync failed'); } - if (syncMode === 'add' && data?.pageId && !pageId) { + 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; } - syncEditNoteId.value = payload.noteId; writeOutput({ message: `OneNote sync complete (${data?.mode || syncMode})`, response: data, payload }); } @@ -730,10 +861,32 @@ } }); + 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) })); diff --git a/legal/eula.html b/legal/eula.html new file mode 100644 index 0000000..c1e6acf --- /dev/null +++ b/legal/eula.html @@ -0,0 +1,74 @@ + + + + + + Prism Notes End User License Agreement + + + +
+

Prism Notes End User License Agreement (EULA)

+

Last updated: March 25, 2026

+ +

+ This EULA is a legal agreement between you and Cardoza Construction for use of the Prism Notes + application and related services. +

+ +

License Grant

+

+ Subject to your compliance with this agreement, Cardoza Construction grants you a limited, + non-exclusive, non-transferable, revocable license to use Prism Notes for authorized business use. +

+ +

Restrictions

+
    +
  • You may not reverse engineer, copy, resell, or sublicense the application.
  • +
  • You may not use the application for unlawful activity.
  • +
  • You must not interfere with system security, availability, or integrity.
  • +
+ +

Ownership

+

+ Prism Notes and all related intellectual property remain the exclusive property of Cardoza Construction + and its licensors. +

+ +

Data & Integrations

+

+ You are responsible for ensuring your use of third-party integrations complies with applicable + terms and permissions. Use of Prism Notes is also governed by the Privacy Policy. +

+ +

Disclaimer

+

+ Prism Notes is provided "as is" without warranties of any kind, to the extent permitted by law. +

+ +

Limitation of Liability

+

+ To the maximum extent permitted by law, Cardoza Construction is not liable for indirect, + incidental, special, consequential, or punitive damages arising from use of the application. +

+ +

Termination

+

+ This license may terminate if you violate this agreement. Upon termination, you must stop using + Prism Notes. +

+ +

Contact

+

+ For legal questions, contact: admin@ccllc.pro +

+
+ + diff --git a/legal/privacy.html b/legal/privacy.html new file mode 100644 index 0000000..d6e4e07 --- /dev/null +++ b/legal/privacy.html @@ -0,0 +1,66 @@ + + + + + + Prism Notes Privacy Policy + + + +
+

Prism Notes Privacy Policy

+

Last updated: March 25, 2026

+ +

+ Prism Notes is operated by Cardoza Construction. This policy describes how Prism Notes collects, + uses, and protects information when you use the application and connected integrations. +

+ +

Information We Collect

+
    +
  • Account profile details needed for sign-in and authorization.
  • +
  • Notes and metadata you submit within Prism Notes.
  • +
  • Integration data required for configured services (for example, OneNote sync).
  • +
  • Basic operational logs for security, troubleshooting, and auditing.
  • +
+ +

How We Use Information

+
    +
  • Provide core app functionality and note synchronization.
  • +
  • Authenticate users and secure access to data.
  • +
  • Maintain reliability, detect abuse, and troubleshoot issues.
  • +
  • Comply with legal and contractual obligations.
  • +
+ +

Data Sharing

+

+ We do not sell personal data. Data is shared only with service providers and integrations required to + deliver application features, or as required by law. +

+ +

Data Retention

+

+ Data is retained only as long as required to operate Prism Notes, meet legal obligations, and enforce + agreements. +

+ +

Security

+

+ We use reasonable administrative and technical safeguards to protect data. No method of transmission + or storage is guaranteed to be 100% secure. +

+ +

Contact

+

+ For privacy questions, contact: admin@ccllc.pro +

+
+ + diff --git a/server.ts b/server.ts index 9b6c511..a1e4c82 100644 --- a/server.ts +++ b/server.ts @@ -24,6 +24,10 @@ const ONENOTE_TARGET = { notebookName: 'Billing Notes', }; +const WORKSPACE_ROOT = new URL('./', import.meta.url).pathname; +const LEGACY_NOTES_COMMIT = '4c6058c'; +let legacyNotesHtmlCache: string | null = null; + // --------------------------------------------------------------------------- // App // --------------------------------------------------------------------------- @@ -37,6 +41,8 @@ app.use('/*', cors({ origin: '*', credentials: true })); const PB_BASE_URL = process.env.PB_URL || process.env.PB_DB || ''; const PB_AUTH_COLLECTION = process.env.PB_AUTH_COLLECTION || 'Users'; const PB_OAUTH_PROVIDER = 'microsoft'; +const PB_NOTES_COLLECTION = process.env.PB_NOTES_COLLECTION || 'Notes'; +const PB_TASKS_COLLECTION = 'Tasgird'; const pb = new PocketBase(PB_BASE_URL); @@ -56,6 +62,42 @@ function getBearerToken(header: string | undefined | null): string | null { return m?.[1] || null; } +function decorateLegacyNotesHtml(html: string): string { + const nav = ` +
+ Testing Harness + Tasks + Legacy Notes UI · restored from ${LEGACY_NOTES_COMMIT} +
`; + + return html + .replace('Prism Notes', 'Prism Notes Legacy') + .replace(/]*)>/i, `${nav}`); +} + +async function loadLegacyNotesHtml(): Promise { + if (legacyNotesHtmlCache) return legacyNotesHtmlCache; + + const proc = Bun.spawn(['git', '--no-pager', 'show', `${LEGACY_NOTES_COMMIT}:index.html`], { + cwd: WORKSPACE_ROOT, + stdout: 'pipe', + stderr: 'pipe', + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + if (exitCode !== 0) { + throw new Error(stderr || `git show failed for ${LEGACY_NOTES_COMMIT}:index.html`); + } + + legacyNotesHtmlCache = decorateLegacyNotesHtml(stdout); + return legacyNotesHtmlCache; +} + function escapeHtml(s: string): string { return s .replace(/&/g, '&') @@ -65,6 +107,267 @@ function escapeHtml(s: string): string { .replace(/'/g, '''); } +function buildNotesPayload(input: { + user: any; + bodyPlain: string; + title: string; + noteType: string; + jobNumber: string; + userEmail?: string; + noteId?: string; + onenoteId?: string; +}): Record { + const bodyHtml = input.bodyPlain + .split(/\r\n|\r|\n/) + .map((line) => `

${line || ' '}

`) + .join(''); + + const payload: Record = { + body_plain: input.bodyPlain, + body_html: bodyHtml, + title: input.title, + type: input.noteType, + email: input.user?.email || input.userEmail || '', + Username: input.user?.name || input.user?.username || input.user?.email || '', + userId: input.user?.id || '', + job_note: input.noteType === 'job' || !!input.jobNumber, + shared: false, + shared_with: [], + }; + + if (input.jobNumber) { + payload['Job_Number'] = input.jobNumber; + } + if (input.noteId) { + payload['note_id'] = input.noteId; + } + if (input.onenoteId) { + payload['onenote_id'] = input.onenoteId; + } + + return payload; +} + +function decodeHtmlEntities(value: string): string { + return value + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +function toPlainText(html: string): string { + return decodeHtmlEntities(html.replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim(); +} + +function toStructuredPlainText(html: string): string { + return decodeHtmlEntities( + html + .replace(/<\s*br\s*\/?>/gi, '\n') + .replace(/<\/(p|div|pre|li|tr|table|section|article|h[1-6])>/gi, '\n') + .replace(/<[^>]+>/g, ' '), + ) + .replace(/\r\n|\r/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .replace(/[ \t]+/g, ' ') + .trim(); +} + +function findNearestGeneratedId(source: string, pivot: number): string { + if (pivot < 0) return ''; + const windowStart = Math.max(0, pivot - 4000); + const slice = source.slice(windowStart, pivot); + const idRegex = /id="([a-z]+:\{[^}]+\}\{\d+\})"/gi; + let match: RegExpExecArray | null = null; + let lastId = ''; + while ((match = idRegex.exec(slice))) { + lastId = decodeHtmlEntities(match[1] || '').trim(); + } + return lastId; +} + +function parseObjectAnchor(raw: string): { guid: string; suffix?: string } | null { + const value = String(raw || '').trim(); + if (!value) return null; + + const linkMatch = value.match(/object-id=\{([0-9A-Fa-f-]{36})\}&([0-9A-Fa-f]+)/i); + if (linkMatch) { + return { guid: linkMatch[1], suffix: linkMatch[2] }; + } + + const braceMatch = value.match(/\{([0-9A-Fa-f-]{36})\}(?:&([0-9A-Fa-f]+))?/); + if (braceMatch) { + return { guid: braceMatch[1], suffix: braceMatch[2] }; + } + + return null; +} + +function objectTargetCandidates(rawTarget: string): string[] { + const raw = String(rawTarget || '').trim(); + if (!raw) return []; + + if (/^[a-z]+:\{[0-9A-Fa-f-]{36}\}\{\d+\}$/i.test(raw)) { + return [raw, `#${raw}`]; + } + + const parsed = parseObjectAnchor(raw); + if (!parsed) { + return [raw, raw.startsWith('#') ? raw.slice(1) : `#${raw}`]; + } + + const guid = parsed.guid; + const candidates: string[] = []; + + // Data-id style candidates (must use # for data-id targets) + candidates.push(`{${guid}}`); + candidates.push(`#${`{${guid}}`}`); + if (parsed.suffix) { + const dataId = `{${guid}}&${parsed.suffix}`; + candidates.push(dataId); + candidates.push(`#${dataId}`); + } + + if (parsed.suffix) { + const asNum = Number.parseInt(parsed.suffix, 16); + if (Number.isFinite(asNum)) { + candidates.push(`div:{${guid}}{${asNum}}`); + candidates.push(`#${`div:{${guid}}{${asNum}}`}`); + candidates.push(`p:{${guid}}{${asNum}}`); + candidates.push(`#${`p:{${guid}}{${asNum}}`}`); + candidates.push(`table:{${guid}}{${asNum}}`); + candidates.push(`#${`table:{${guid}}{${asNum}}`}`); + candidates.push(`outline:{${guid}}{${asNum}}`); + candidates.push(`#${`outline:{${guid}}{${asNum}}`}`); + candidates.push(`object:{${guid}}{${asNum}}`); + candidates.push(`#${`object:{${guid}}{${asNum}}`}`); + } + } + + candidates.push(`div:{${guid}}`); + candidates.push(`#${`div:{${guid}}`}`); + candidates.push(`p:{${guid}}`); + candidates.push(`#${`p:{${guid}}`}`); + candidates.push(`table:{${guid}}`); + candidates.push(`#${`table:{${guid}}`}`); + candidates.push(`object:{${guid}}`); + candidates.push(`#${`object:{${guid}}`}`); + + return Array.from(new Set(candidates)); +} + +function extractCodeBlocks(blockHtml: string): string[] { + const codeBlocks: string[] = []; + + const preRegex = /]*>([\s\S]*?)<\/pre>/gi; + let preMatch: RegExpExecArray | null = null; + while ((preMatch = preRegex.exec(blockHtml))) { + const code = toPlainText(preMatch[1] || '').trim(); + if (code) codeBlocks.push(code.slice(0, 400)); + } + + const plain = toPlainText(blockHtml); + const fencedRegex = /```[\w-]*\n([\s\S]*?)```/g; + let fencedMatch: RegExpExecArray | null = null; + while ((fencedMatch = fencedRegex.exec(plain))) { + const code = String(fencedMatch[1] || '').trim(); + if (code) codeBlocks.push(code.slice(0, 400)); + } + + return codeBlocks; +} + +function extractPageNoteBlocks(pageHtml: string): Array<{ noteId: string; syncedAt: string; preview: string; codeBlocks: string[]; targetId: string }> { + const noteBlocks: Array<{ noteId: string; syncedAt: string; preview: string; codeBlocks: string[]; targetId: string }> = []; + const divRegex = /]*)\bdata-prism-notes-note-id\s*=\s*(["'])([^"']+)\2([^>]*)>([\s\S]*?)<\/div>/gi; + let match: RegExpExecArray | null = null; + + while ((match = divRegex.exec(pageHtml))) { + const attrs = `${match[1] || ''} ${match[4] || ''}`; + const blockHtml = match[5] || ''; + const generatedIdMatch = attrs.match(/\bid="([^"]+)"/i); + const dataIdMatch = attrs.match(/\bdata-id="([^"]+)"/i); + const syncedAtMatch = attrs.match(/data-prism-notes-synced-at="([^"]*)"/i); + + noteBlocks.push({ + noteId: decodeHtmlEntities(match[3] || '').trim(), + syncedAt: decodeHtmlEntities(syncedAtMatch?.[1] || '').trim(), + preview: toPlainText(blockHtml).slice(0, 220), + codeBlocks: extractCodeBlocks(blockHtml), + targetId: decodeHtmlEntities(generatedIdMatch?.[1] || dataIdMatch?.[1] || '').trim(), + }); + } + + if (noteBlocks.length > 0) { + return noteBlocks; + } + + const structuredPlain = toStructuredPlainText(pageHtml); + const noteIdRegex = /note_id\s*:\s*([a-zA-Z0-9._:-]+)/gi; + const matches = Array.from(structuredPlain.matchAll(noteIdRegex)); + + if (matches.length > 0) { + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + const noteId = decodeHtmlEntities(match[1] || '').trim(); + if (!noteId) continue; + + const start = match.index ?? 0; + const end = i + 1 < matches.length ? (matches[i + 1].index ?? structuredPlain.length) : structuredPlain.length; + const segment = structuredPlain.slice(start, end).trim(); + + const codeBlocks: string[] = []; + const fencedRegex = /```[\w-]*\n([\s\S]*?)```/g; + let fencedMatch: RegExpExecArray | null = null; + while ((fencedMatch = fencedRegex.exec(segment))) { + const code = String(fencedMatch[1] || '').trim(); + if (code) codeBlocks.push(code.slice(0, 400)); + } + + noteBlocks.push({ + noteId, + syncedAt: '', + preview: segment.slice(0, 220), + codeBlocks, + targetId: findNearestGeneratedId(pageHtml, pageHtml.search(new RegExp(`note_id\\s*:\\s*${noteId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i'))), + }); + } + return noteBlocks; + } + + const chunks = pageHtml.split(/

\s*---\s*<\/p>/gi); + for (const chunk of chunks) { + const plain = toPlainText(chunk); + const noteIdMatch = plain.match(/note_id\s*:\s*([a-zA-Z0-9._:-]+)/i); + if (!noteIdMatch) continue; + + const noteId = decodeHtmlEntities(noteIdMatch[1] || '').trim(); + if (!noteId) continue; + + const rawIndex = pageHtml.indexOf(chunk); + noteBlocks.push({ + noteId, + syncedAt: '', + preview: plain.slice(0, 220), + codeBlocks: extractCodeBlocks(chunk), + targetId: findNearestGeneratedId(pageHtml, rawIndex), + }); + } + + return noteBlocks; +} + +function findRecordFieldName(record: Record, patterns: RegExp[]): string | null { + const keys = Object.keys(record || {}); + for (const pattern of patterns) { + const match = keys.find((key) => pattern.test(key)); + if (match) return match; + } + return null; +} + // --------------------------------------------------------------------------- // MSAL app-only (Graph status check) // --------------------------------------------------------------------------- @@ -116,6 +419,32 @@ async function resolveSiteId(delegatedToken: string): Promise { app.get('/health', (c) => c.json({ ok: true, pbDB: PB_BASE_URL })); +// --------------------------------------------------------------------------- +// Intuit app setup URLs (for QuickBooks Time / Intuit app configuration) +// --------------------------------------------------------------------------- +app.get('/integrations/intuit/launch', async (c) => { + const html = await Bun.file(new URL('./index.html', import.meta.url)).text(); + return c.html(html); +}); + +app.get('/integrations/intuit/connect', (c) => { + return c.json({ + success: true, + message: 'Intuit connect endpoint is reachable. OAuth flow wiring can be added here.', + }); +}); + +app.get('/integrations/intuit/reconnect', (c) => { + return c.redirect('/integrations/intuit/connect', 302); +}); + +app.all('/integrations/intuit/disconnect', (c) => { + return c.json({ + success: true, + message: 'Intuit disconnect endpoint is reachable. Token revocation/disconnect flow can be added here.', + }); +}); + app.get('/api/auth/pocketbase-config', (c) => { if (!PB_BASE_URL) { return c.json({ success: false, message: 'Missing PB_URL or PB_DB' }, 500); @@ -144,6 +473,133 @@ app.get('/api/auth/msal-config', (c) => { }); }); +// GET /api/tasks/users +// Requires: x-pb-token +app.get('/api/tasks/users', async (c) => { + const pbToken = c.req.header('x-pb-token'); + if (!pbToken) return c.json({ success: false, message: 'Missing x-pb-token' }, 401); + + try { + await validatePbToken(pbToken); + } catch { + return c.json({ success: false, message: 'Invalid PocketBase token' }, 401); + } + + try { + const me = pb.authStore.model; + let users: any[] = []; + let warning = ''; + try { + users = await pb.collection(PB_AUTH_COLLECTION).getFullList({ + sort: 'email', + fields: 'id,email,name,username', + }); + } catch (listErr) { + warning = 'Users list is restricted by PocketBase rules; showing current user only.'; + users = me ? [me] : []; + } + + return c.json({ + success: true, + me: { + id: me?.id || '', + email: me?.email || '', + name: (me?.name || me?.username || me?.email || 'Me') as string, + }, + users: users.map((user: any) => ({ + id: user.id, + email: user.email || '', + name: user.name || user.username || user.email || user.id, + })), + warning: warning || undefined, + }); + } catch (e) { + return c.json({ success: false, message: (e as Error)?.message || 'Failed to load users' }, 500); + } finally { + clearPbToken(); + } +}); + +// GET /api/tasks/list?userId= +// Requires: x-pb-token +app.get('/api/tasks/list', async (c) => { + const pbToken = c.req.header('x-pb-token'); + if (!pbToken) return c.json({ success: false, message: 'Missing x-pb-token' }, 401); + + try { + await validatePbToken(pbToken); + } catch { + return c.json({ success: false, message: 'Invalid PocketBase token' }, 401); + } + + try { + const me = pb.authStore.model; + const meId = String(me?.id || '').trim(); + const requestedUserId = String(c.req.query('userId') || '').trim(); + const targetUserId = requestedUserId || meId; + if (!targetUserId) return c.json({ success: false, message: 'No target user available' }, 400); + + const fetchTasks = async (userId: string) => { + const filter = `user = "${userId.replace(/"/g, '\\"')}"`; + return pb.collection(PB_TASKS_COLLECTION).getFullList({ + filter, + sort: '-startDate,-created', + expand: 'user', + fields: 'id,user,title,startDate,dueDate,priority,completed,content,size,status,expand.user.id,expand.user.email,expand.user.name,expand.user.username', + }); + }; + + let records: any[] = []; + let warning = ''; + let effectiveUserId = targetUserId; + try { + records = await fetchTasks(targetUserId); + } catch (listErr) { + if (targetUserId !== meId && meId) { + try { + records = await fetchTasks(meId); + effectiveUserId = meId; + warning = 'Requested user tasks are restricted by PocketBase rules; showing your tasks instead.'; + } catch { + records = []; + effectiveUserId = meId; + warning = 'Task list is restricted by PocketBase rules; no accessible tasks for current user.'; + } + } else { + records = []; + effectiveUserId = meId || targetUserId; + warning = 'Task list is restricted by PocketBase rules; no accessible tasks for current user.'; + } + } + + return c.json({ + success: true, + userId: effectiveUserId, + count: records.length, + warning: warning || undefined, + tasks: records.map((task: any) => ({ + id: task.id, + user: task.user || task.expand?.user?.id || null, + userDisplay: task.expand?.user + ? (task.expand.user.name || task.expand.user.username || task.expand.user.email || task.expand.user.id) + : null, + title: task.title ?? '', + startDate: task.startDate ?? null, + dueDate: task.dueDate ?? null, + priority: task.priority ?? null, + completed: !!task.completed, + content: task.content ?? null, + size: task.size ?? null, + status: task.status ?? null, + })), + }); + } catch (e) { + return c.json({ success: false, message: (e as Error)?.message || 'Failed to load tasks' }, 500); + } finally { + clearPbToken(); + } +}); + app.get('/api/graph/status', async (c) => { try { const t = await getGraphToken(); @@ -197,19 +653,53 @@ app.get('/api/onenote/section-pages', async (c) => { } }); +// GET /api/onenote/page-note-blocks?pageId=... +// Requires: Authorization: Bearer +app.get('/api/onenote/page-note-blocks', async (c) => { + try { + const delegatedToken = getBearerToken(c.req.header('authorization')); + if (!delegatedToken) return c.json({ success: false, message: 'Missing delegated token' }, 401); + + const pageId = String(c.req.query('pageId') || '').trim(); + if (!pageId) return c.json({ success: false, message: 'Missing pageId' }, 400); + + const siteId = await resolveSiteId(delegatedToken); + const resp = await fetch( + `https://graph.microsoft.com/v1.0/sites/${siteId}/onenote/pages/${encodeURIComponent(pageId)}/content?includeIDs=true`, + { + headers: { + Authorization: `Bearer ${delegatedToken}`, + Accept: 'text/html', + }, + }, + ); + + const html = await resp.text(); + if (!resp.ok) { + return c.json({ success: false, siteId, pageId, error: html }, resp.status as any); + } + + const noteBlocks = extractPageNoteBlocks(html); + return c.json({ success: true, siteId, pageId, noteBlocks }); + } 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?, pageId?, pageTitle?, syncMode?: 'add'|'edit', targetNoteId? +// sectionId?, pageId?, pageTitle?, syncMode?: 'add_separate'|'add_to'|'edit', targetNoteId?, targetObjectId? // } // // Strategy: // 1. Resolve site ID via GET /sites/{host}:{path} // 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 +// 3. If syncMode=add_to: PATCH append content to existing note block by noteId +// 4. If syncMode=add_separate and pageId: PATCH append a new note block to page +// 5. If syncMode=add_separate and no pageId: POST create new page in section // --------------------------------------------------------------------------- app.post('/api/onenote/append', async (c) => { try { @@ -235,8 +725,11 @@ app.post('/api/onenote/append', async (c) => { 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 requestedMode = String(body?.syncMode || 'add_to').trim().toLowerCase(); + const syncMode = requestedMode === 'add_to' ? 'add_to' : requestedMode === 'edit' ? 'edit' : 'add_separate'; const targetNoteId = String(body?.targetNoteId || noteId || '').trim(); + const targetObjectId = String(body?.targetObjectId || '').trim(); + const effectiveNoteId = syncMode === 'add_separate' ? noteId : targetNoteId; if (!noteId) return c.json({ success: false, message: 'Missing noteId' }, 400); if (!bodyPlain) return c.json({ success: false, message: 'Missing bodyPlain' }, 400); @@ -248,10 +741,26 @@ app.post('/api/onenote/append', async (c) => { `type: ${escapeHtml(noteType)}`, `user: ${escapeHtml(userEmail)}`, jobNumber ? `job: ${escapeHtml(jobNumber)}` : '', - `note_id: ${escapeHtml(noteId)}`, + `note_id: ${escapeHtml(effectiveNoteId)}`, ].filter(Boolean).join(' | '); - const appendHtml = `

${headerLine}

${safeBody}

---

`; + const noteDataId = `pn-${escapeHtml(effectiveNoteId)}`; + const appendHtml = `

${headerLine}

${safeBody}

---

`; + const separateNoteHtml = [ + ``, + '', + '', + '', + '
', + `
`, + '

Prism Notes Entry

', + `

${headerLine}

`, + `

${safeBody}

`, + '

---

', + '
', + '
', + ].join(''); + const appendToExistingNoteHtml = `

update_at: ${escapeHtml(ts)}

${safeBody}

---

`; const pageHtml = [ '', @@ -264,12 +773,70 @@ 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' || syncMode === 'add_to') && !pageId) { + return c.json({ success: false, message: 'Add/Edit mode requires pageId' }, 400); } - if (syncMode === 'edit' && !targetNoteId) { - return c.json({ success: false, message: 'Edit mode requires targetNoteId or noteId' }, 400); + if ((syncMode === 'edit' || syncMode === 'add_to') && !targetNoteId) { + return c.json({ success: false, message: 'Add/Edit mode requires targetNoteId or noteId' }, 400); } + if (syncMode === 'add_separate' && pageId && !targetObjectId) { + return c.json({ success: false, message: 'Separate mode requires a discovered OneNote target object on the selected page' }, 400); + } + + const user = pb.authStore.model; + const notesWarnings: string[] = []; + const notesPayload = buildNotesPayload({ + user, + bodyPlain, + title, + noteType, + jobNumber, + userEmail, + noteId: effectiveNoteId, + }); + + let notesRecord: any; + try { + notesRecord = await pb.collection(PB_NOTES_COLLECTION).create(notesPayload); + } catch (createErr) { + const createMessage = (createErr as Error)?.message || String(createErr); + notesWarnings.push(`Notes create retry without note_id: ${createMessage}`); + + const fallbackPayload: Record = { ...notesPayload }; + delete fallbackPayload.note_id; + delete fallbackPayload.onenote_id; + + try { + notesRecord = await pb.collection(PB_NOTES_COLLECTION).create(fallbackPayload); + } catch (fallbackErr) { + const fallbackMessage = (fallbackErr as Error)?.message || String(fallbackErr); + return c.json( + { success: false, message: 'Failed to create Notes record before OneNote sync', details: { create: fallbackMessage } }, + 500, + ); + } + } + + const notesRecordKeys = Object.keys(notesRecord || {}); + const noteIdField = findRecordFieldName(notesRecord as Record, [/^note_?id$/i, /^noteid$/i]); + const oneNoteIdField = findRecordFieldName(notesRecord as Record, [/^onenote_?id$/i, /^one_?note_?id$/i, /^onenoteid$/i]); + + const upsertSyncFields = async (effectiveNoteId: string): Promise => { + try { + const updatePayload: Record = {}; + if (noteIdField) updatePayload[noteIdField] = effectiveNoteId; + if (oneNoteIdField) updatePayload[oneNoteIdField] = effectiveNoteId; + + if (!noteIdField && !oneNoteIdField) { + notesWarnings.push(`No note_id/onenote_id fields found on Notes record. Fields: ${notesRecordKeys.join(', ')}`); + return; + } + + await pb.collection(PB_NOTES_COLLECTION).update(notesRecord.id, updatePayload); + } catch (updateErr) { + notesWarnings.push(`Notes sync field update failed: ${(updateErr as Error)?.message || String(updateErr)}`); + } + }; const authHeader = { Authorization: `Bearer ${delegatedToken}` }; @@ -295,8 +862,19 @@ app.post('/api/onenote/append', async (c) => { ); if (replaceResp.ok) { + const onenoteId = targetNoteId; + await upsertSyncFields(targetNoteId); console.log('onenote_edit_success', { mode: 'edit', noteId, targetNoteId, pageId, siteId }); - return c.json({ success: true, mode: 'edited', pageId, noteId: targetNoteId, syncedAt: ts }); + return c.json({ + success: true, + mode: 'edited', + pageId, + noteId: targetNoteId, + onenoteId, + notesRecordId: notesRecord.id, + notesWarnings, + syncedAt: ts, + }); } const replaceErr = await replaceResp.text(); @@ -305,7 +883,52 @@ app.post('/api/onenote/append', async (c) => { { success: false, message: `OneNote edit failed (${replaceResp.status})`, - details: { edit: replaceErr, pageId, targetNoteId, siteId }, + details: { edit: replaceErr, pageId, targetNoteId, siteId, notesRecordId: notesRecord.id, notesWarnings }, + }, + 502, + ); + } + + if (syncMode === 'add_to') { + const selectorNoteId = targetNoteId.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const appendToNoteResp = 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: 'append', + content: appendToExistingNoteHtml, + }, + ]), + }, + ); + + if (appendToNoteResp.ok) { + const onenoteId = targetNoteId; + await upsertSyncFields(targetNoteId); + console.log('onenote_add_to_note_success', { mode: 'add_to', noteId, targetNoteId, pageId, siteId }); + return c.json({ + success: true, + mode: 'added_to_note', + pageId, + noteId: targetNoteId, + onenoteId, + notesRecordId: notesRecord.id, + notesWarnings, + syncedAt: ts, + }); + } + + const appendToNoteErr = await appendToNoteResp.text(); + console.warn('onenote_add_to_note_failed', { status: appendToNoteResp.status, error: appendToNoteErr, pageId, targetNoteId }); + return c.json( + { + success: false, + message: `OneNote add-to-note failed (${appendToNoteResp.status})`, + details: { addToNote: appendToNoteErr, pageId, targetNoteId, siteId, notesRecordId: notesRecord.id, notesWarnings }, }, 502, ); @@ -313,18 +936,81 @@ app.post('/api/onenote/append', async (c) => { /* ---- 1: try append to known page (if we have a valid pageId) ---- */ if (pageId) { - const patchResp = await fetch( - `${base}/pages/${pageId}/content`, - { - method: 'PATCH', - headers: { ...authHeader, 'Content-Type': 'application/json' }, - body: JSON.stringify([{ target: 'body', action: 'append', content: appendHtml }]), - }, - ); + const pageAppendHtml = syncMode === 'add_separate' ? separateNoteHtml : appendHtml; + const action = syncMode === 'add_separate' ? 'insert' : 'append'; + + let patchResp: Response | null = null; + let targetUsed = ''; + if (syncMode === 'add_separate') { + const candidates = objectTargetCandidates(targetObjectId); + if (!candidates.length) { + return c.json({ success: false, message: 'Separate mode could not build target candidates from object anchor' }, 400); + } + + for (const candidate of candidates) { + for (const position of ['after', 'before'] as const) { + const command = { + target: candidate, + action: 'insert', + position, + content: pageAppendHtml, + }; + + const resp = await fetch( + `${base}/pages/${pageId}/content`, + { + method: 'PATCH', + headers: { ...authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify([command]), + }, + ); + if (resp.ok) { + patchResp = resp; + targetUsed = `${candidate} (${position})`; + break; + } + patchResp = resp; + } + if (patchResp?.ok) { + break; + } + } + } else { + const command = { + target: 'body', + action, + content: pageAppendHtml, + }; + patchResp = await fetch( + `${base}/pages/${pageId}/content`, + { + method: 'PATCH', + headers: { ...authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify([command]), + }, + ); + } + + if (!patchResp) { + return c.json({ success: false, message: 'OneNote patch failed before request execution' }, 502); + } if (patchResp.ok) { - console.log('onenote_append_success', { mode: 'patch', noteId, siteId }); - return c.json({ success: true, mode: 'appended', syncedAt: ts }); + const onenoteId = effectiveNoteId; + await upsertSyncFields(effectiveNoteId); + console.log('onenote_append_success', { mode: 'patch', noteId: effectiveNoteId, siteId }); + return c.json({ + success: true, + mode: syncMode === 'add_separate' ? 'separate_inserted' : 'appended', + operation: action, + targetUsed: targetUsed || null, + pageId, + noteId: effectiveNoteId, + onenoteId, + notesRecordId: notesRecord.id, + notesWarnings, + syncedAt: ts, + }); } const patchErr = await patchResp.text(); @@ -343,14 +1029,30 @@ app.post('/api/onenote/append', async (c) => { if (postResp.ok) { const created = await postResp.json().catch(() => ({})); - console.log('onenote_create_success', { mode: 'create', noteId, pageId: created?.id, siteId }); - return c.json({ success: true, mode: 'created', pageId: created?.id || null, syncedAt: ts }); + const createdPageId = created?.id ? String(created.id) : ''; + const onenoteId = effectiveNoteId; + await upsertSyncFields(effectiveNoteId); + console.log('onenote_create_success', { mode: 'create', noteId: effectiveNoteId, pageId: created?.id, siteId }); + return c.json({ + success: true, + mode: 'created', + pageId: createdPageId || null, + noteId: effectiveNoteId, + onenoteId: onenoteId || null, + notesRecordId: notesRecord.id, + notesWarnings, + syncedAt: ts, + }); } const postErr = await postResp.text(); console.error('onenote_create_failed', { status: postResp.status, postErr, siteId, sectionId }); return c.json( - { success: false, message: `OneNote write failed (create ${postResp.status})`, details: { create: postErr, siteId, sectionId } }, + { + success: false, + message: `OneNote write failed (create ${postResp.status})`, + details: { create: postErr, siteId, sectionId, notesRecordId: notesRecord.id, notesWarnings }, + }, 502, ); } catch (e) { @@ -364,8 +1066,6 @@ app.post('/api/onenote/append', async (c) => { // --------------------------------------------------------------------------- // POST /api/submit — save a note record to PocketBase Notes collection // --------------------------------------------------------------------------- -const PB_NOTES_COLLECTION = process.env.PB_NOTES_COLLECTION || 'Notes'; - app.post('/api/submit', async (c) => { try { const body = await c.req.json(); @@ -382,29 +1082,17 @@ app.post('/api/submit', async (c) => { const title = String(body.title || 'Untitled').trim(); const noteType = String(body.noteType || 'personal').trim(); const jobNumber = String(body.jobNumber || '').trim(); + const noteId = String(body.noteId || '').trim(); - // Convert plain text to simple HTML (matches what the main app stores) - const bodyHtml = bodyPlain - .split(/\r\n|\r|\n/) - .map((line) => `

${line || ' '}

`) - .join(''); - - const payload: Record = { - body_plain: bodyPlain, - body_html: bodyHtml, + const payload = buildNotesPayload({ + user, + bodyPlain, title, - type: noteType, - email: user?.email || body.userEmail || '', - Username: user?.name || user?.username || user?.email || '', - userId: user?.id || '', - job_note: noteType === 'job' || !!jobNumber, - shared: false, - shared_with: [], - }; - - if (jobNumber) { - payload['Job_Number'] = jobNumber; - } + noteType, + jobNumber, + userEmail: String(body.userEmail || '').trim(), + noteId, + }); const record = await pb.collection(PB_NOTES_COLLECTION).create(payload); @@ -423,6 +1111,7 @@ app.post('/api/submit', async (c) => { // --------------------------------------------------------------------------- app.use('/images/*', serveStatic({ root: new URL('./', import.meta.url).pathname })); app.use('/tools/*', serveStatic({ root: new URL('./', import.meta.url).pathname })); +app.use('/legal/*', serveStatic({ root: new URL('./', import.meta.url).pathname })); app.get('/vendor/msal-browser.js', async (c) => { const file = Bun.file(new URL('./node_modules/@azure/msal-browser/lib/msal-browser.min.js', import.meta.url)); @@ -436,6 +1125,53 @@ app.get('/', async (c) => { return c.html(html); }); +app.get('/notes', async (c) => { + try { + const html = await loadLegacyNotesHtml(); + return c.html(html); + } catch (error) { + console.error('Failed to load legacy notes UI', error); + return c.html(` + + + + + Prism Notes Legacy + + + +
+
+

Prism Notes

+

Legacy notes UI unavailable

+

The current server could not load the historical notes interface from git commit ${LEGACY_NOTES_COMMIT}.

+
${escapeHtml(String(error instanceof Error ? error.message : error || 'Unknown error'))}
+ +
+
+ +`, 500); + } +}); + +app.get('/tasks', async (c) => { + const html = await Bun.file(new URL('./tasks.html', import.meta.url)).text(); + return c.html(html); +}); + +app.get('/legal/privacy', async (c) => { + const html = await Bun.file(new URL('./legal/privacy.html', import.meta.url)).text(); + return c.html(html); +}); + +app.get('/legal/eula', async (c) => { + const html = await Bun.file(new URL('./legal/eula.html', import.meta.url)).text(); + return c.html(html); +}); + // --------------------------------------------------------------------------- // Start // --------------------------------------------------------------------------- diff --git a/tasks.html b/tasks.html new file mode 100644 index 0000000..74921e3 --- /dev/null +++ b/tasks.html @@ -0,0 +1,421 @@ + + + + + + Prism Notes Tasks + + + + + +
+
+
+
+ Prism Notes +
+

Prism Notes

+

Tasks View

+
+
+ +
+

Browse PocketBase Tasgird tasks. Default filter is current user, with optional user switching.

+
+ +
+
+

Server

+

Checking...

+
+
+

PocketBase User

+

Not logged in

+
+
+

Task Count

+

0

+
+
+ +
+
+

1) PocketBase Login

+

Uses your existing PocketBase Microsoft OAuth provider.

+
+ + +
+
+ +
+

2) Task Filter

+

Defaults to your tasks, but you can select other users.

+
+
+ + +
+
+ + +
+
+
+
+ +
+

Tasks (Tasgird)

+
+ + + + + + + + + + + + + + + + + + +
idusertitlestartDate (UTC)dueDate (UTC)prioritycompletedcontentsizestatus
No tasks loaded.
+
+
+ +
+

Output

+

+    
+
+ + + +