Add isolated legacy notes route and tasks view with resilient task APIs

This commit is contained in:
2026-03-28 19:17:56 +00:00
parent 8ca2c97e37
commit d6b0e3192a
5 changed files with 1516 additions and 66 deletions
+165 -12
View File
@@ -93,6 +93,7 @@
<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>
@@ -100,6 +101,11 @@
<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" 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>
@@ -167,13 +173,14 @@
<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>
<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 (edit mode)</label>
<input id="syncEditNoteId" class="field mt-1" placeholder="Paste noteId to replace on selected page" />
<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">
@@ -188,6 +195,14 @@
<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)" />
@@ -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 = '<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';
@@ -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;
}
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) }));
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prism Notes End User License Agreement</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
main { max-width: 860px; margin: 0 auto; padding: 32px 20px; }
h1, h2 { color: #f8fafc; }
a { color: #93c5fd; }
p, li { line-height: 1.6; }
.muted { color: #94a3b8; }
</style>
</head>
<body>
<main>
<h1>Prism Notes End User License Agreement (EULA)</h1>
<p class="muted">Last updated: March 25, 2026</p>
<p>
This EULA is a legal agreement between you and Cardoza Construction for use of the Prism Notes
application and related services.
</p>
<h2>License Grant</h2>
<p>
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.
</p>
<h2>Restrictions</h2>
<ul>
<li>You may not reverse engineer, copy, resell, or sublicense the application.</li>
<li>You may not use the application for unlawful activity.</li>
<li>You must not interfere with system security, availability, or integrity.</li>
</ul>
<h2>Ownership</h2>
<p>
Prism Notes and all related intellectual property remain the exclusive property of Cardoza Construction
and its licensors.
</p>
<h2>Data & Integrations</h2>
<p>
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.
</p>
<h2>Disclaimer</h2>
<p>
Prism Notes is provided "as is" without warranties of any kind, to the extent permitted by law.
</p>
<h2>Limitation of Liability</h2>
<p>
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.
</p>
<h2>Termination</h2>
<p>
This license may terminate if you violate this agreement. Upon termination, you must stop using
Prism Notes.
</p>
<h2>Contact</h2>
<p>
For legal questions, contact: <a href="mailto:admin@ccllc.pro">admin@ccllc.pro</a>
</p>
</main>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prism Notes Privacy Policy</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
main { max-width: 860px; margin: 0 auto; padding: 32px 20px; }
h1, h2 { color: #f8fafc; }
a { color: #93c5fd; }
p, li { line-height: 1.6; }
.muted { color: #94a3b8; }
</style>
</head>
<body>
<main>
<h1>Prism Notes Privacy Policy</h1>
<p class="muted">Last updated: March 25, 2026</p>
<p>
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.
</p>
<h2>Information We Collect</h2>
<ul>
<li>Account profile details needed for sign-in and authorization.</li>
<li>Notes and metadata you submit within Prism Notes.</li>
<li>Integration data required for configured services (for example, OneNote sync).</li>
<li>Basic operational logs for security, troubleshooting, and auditing.</li>
</ul>
<h2>How We Use Information</h2>
<ul>
<li>Provide core app functionality and note synchronization.</li>
<li>Authenticate users and secure access to data.</li>
<li>Maintain reliability, detect abuse, and troubleshoot issues.</li>
<li>Comply with legal and contractual obligations.</li>
</ul>
<h2>Data Sharing</h2>
<p>
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.
</p>
<h2>Data Retention</h2>
<p>
Data is retained only as long as required to operate Prism Notes, meet legal obligations, and enforce
agreements.
</p>
<h2>Security</h2>
<p>
We use reasonable administrative and technical safeguards to protect data. No method of transmission
or storage is guaranteed to be 100% secure.
</p>
<h2>Contact</h2>
<p>
For privacy questions, contact: <a href="mailto:admin@ccllc.pro">admin@ccllc.pro</a>
</p>
</main>
</body>
</html>
+778 -42
View File
@@ -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 = `
<div style="position:fixed;top:12px;left:12px;z-index:100000;display:flex;flex-wrap:wrap;gap:8px;align-items:center;max-width:min(90vw,960px);">
<a href="/" style="display:inline-flex;align-items:center;padding:8px 12px;border-radius:999px;background:#0f172a;color:#e2e8f0;text-decoration:none;font:600 13px/1.2 ui-sans-serif,system-ui,sans-serif;border:1px solid #334155;box-shadow:0 8px 20px rgba(15,23,42,.28);">Testing Harness</a>
<a href="/tasks" style="display:inline-flex;align-items:center;padding:8px 12px;border-radius:999px;background:#0f172a;color:#e2e8f0;text-decoration:none;font:600 13px/1.2 ui-sans-serif,system-ui,sans-serif;border:1px solid #334155;box-shadow:0 8px 20px rgba(15,23,42,.28);">Tasks</a>
<span style="display:inline-flex;align-items:center;padding:8px 12px;border-radius:999px;background:rgba(15,23,42,.92);color:#67e8f9;font:600 12px/1.2 ui-sans-serif,system-ui,sans-serif;border:1px solid rgba(103,232,249,.35);box-shadow:0 8px 20px rgba(15,23,42,.28);">Legacy Notes UI · restored from ${LEGACY_NOTES_COMMIT}</span>
</div>`;
return html
.replace('<title>Prism Notes</title>', '<title>Prism Notes Legacy</title>')
.replace(/<body([^>]*)>/i, `<body$1>${nav}`);
}
async function loadLegacyNotesHtml(): Promise<string> {
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, '&amp;')
@@ -65,6 +107,267 @@ function escapeHtml(s: string): string {
.replace(/'/g, '&#39;');
}
function buildNotesPayload(input: {
user: any;
bodyPlain: string;
title: string;
noteType: string;
jobNumber: string;
userEmail?: string;
noteId?: string;
onenoteId?: string;
}): Record<string, unknown> {
const bodyHtml = input.bodyPlain
.split(/\r\n|\r|\n/)
.map((line) => `<p>${line || '&nbsp;'}</p>`)
.join('');
const payload: Record<string, unknown> = {
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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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 = /<pre\b[^>]*>([\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 = /<div\b([^>]*)\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(/<p>\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<string, unknown>, 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<string> {
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=<id>
// 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 <delegated token>
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 <delegated Graph token>
// 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 = `<div data-prism-notes-note-id="${escapeHtml(noteId)}" data-prism-notes-synced-at="${ts}"><p>${headerLine}</p><p>${safeBody}</p><p>---</p></div>`;
const noteDataId = `pn-${escapeHtml(effectiveNoteId)}`;
const appendHtml = `<div data-id="${noteDataId}" data-prism-notes-note-id="${escapeHtml(effectiveNoteId)}" data-prism-notes-synced-at="${ts}"><p>${headerLine}</p><p>${safeBody}</p><p>---</p></div>`;
const separateNoteHtml = [
`<table data-id="pn-table-${escapeHtml(effectiveNoteId)}" data-prism-notes-note-id="${escapeHtml(effectiveNoteId)}" style="width:100%;border-collapse:separate;border-spacing:0;margin-top:16px;margin-bottom:8px;">`,
'<tr>',
'<td style="border:2px solid #7c3aed;padding:12px;background-color:#f6f0ff;">',
`<div data-id="${noteDataId}" data-prism-notes-note-id="${escapeHtml(effectiveNoteId)}" data-prism-notes-synced-at="${ts}">`,
'<p><strong>Prism Notes Entry</strong></p>',
`<p>${headerLine}</p>`,
`<p>${safeBody}</p>`,
'<p>---</p>',
'</div>',
'</td>',
'</tr>',
'</table>',
].join('');
const appendToExistingNoteHtml = `<p><strong>update_at:</strong> ${escapeHtml(ts)}</p><p>${safeBody}</p><p>---</p>`;
const pageHtml = [
'<!DOCTYPE html>',
@@ -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<string, unknown> = { ...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<string, unknown>, [/^note_?id$/i, /^noteid$/i]);
const oneNoteIdField = findRecordFieldName(notesRecord as Record<string, unknown>, [/^onenote_?id$/i, /^one_?note_?id$/i, /^onenoteid$/i]);
const upsertSyncFields = async (effectiveNoteId: string): Promise<void> => {
try {
const updatePayload: Record<string, unknown> = {};
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(
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([{ target: 'body', action: 'append', content: appendHtml }]),
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) => `<p>${line || '&nbsp;'}</p>`)
.join('');
const payload: Record<string, unknown> = {
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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prism Notes Legacy</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<main class="mx-auto max-w-3xl px-6 py-16">
<div class="rounded-2xl border border-slate-800 bg-slate-900/80 p-6 shadow-2xl">
<p class="text-xs uppercase tracking-[0.3em] text-cyan-400">Prism Notes</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Legacy notes UI unavailable</h1>
<p class="mt-4 text-sm text-slate-300">The current server could not load the historical notes interface from git commit <strong>${LEGACY_NOTES_COMMIT}</strong>.</p>
<pre class="mt-4 overflow-x-auto rounded-xl border border-slate-800 bg-slate-950 p-4 text-xs text-rose-200">${escapeHtml(String(error instanceof Error ? error.message : error || 'Unknown error'))}</pre>
<div class="mt-6 flex flex-wrap gap-3">
<a href="/" class="rounded-xl border border-slate-700 bg-slate-900 px-4 py-2 text-sm font-semibold text-slate-100">Back to Testing Harness</a>
<a href="/tasks" class="rounded-xl border border-slate-700 bg-slate-900 px-4 py-2 text-sm font-semibold text-slate-100">Open Tasks View</a>
</div>
</div>
</main>
</body>
</html>`, 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
// ---------------------------------------------------------------------------
+421
View File
@@ -0,0 +1,421 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prism Notes Tasks</title>
<link rel="icon" type="image/png" href="/images/prism.png" />
<script src="https://cdn.tailwindcss.com"></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-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); }
.mono-pill {
border-radius: 0.5rem;
border: 1px solid rgb(51 65 85 / 1);
background: rgb(2 6 23 / 1);
padding: 0.15rem 0.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.75rem;
color: rgb(191 219 254 / 1);
white-space: nowrap;
}
.task-cell {
border-top: 1px solid rgb(30 41 59 / 1);
padding: 0.65rem 0.5rem;
vertical-align: top;
font-size: 0.8rem;
}
</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-7xl px-6 py-10">
<header class="panel mb-6">
<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">Tasks View</h1>
</div>
</div>
<div class="flex flex-wrap gap-3">
<a href="/notes" class="btn btn-secondary">Open Legacy Notes</a>
<a href="/" class="btn btn-secondary">Back to Testing View</a>
</div>
</div>
<p class="mt-3 text-sm text-slate-300">Browse PocketBase <span class="mono-pill">Tasgird</span> tasks. Default filter is current user, with optional user switching.</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">PocketBase User</p>
<p id="pbStatus" class="mt-2 text-sm font-medium">Not logged in</p>
</article>
<article class="panel">
<p class="text-xs uppercase tracking-wider text-slate-400">Task Count</p>
<p id="taskCount" class="mt-2 text-sm font-medium">0</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) PocketBase Login</h2>
<p class="mt-2 text-sm text-slate-300">Uses your existing PocketBase Microsoft OAuth provider.</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) Task Filter</h2>
<p class="mt-2 text-sm text-slate-300">Defaults to your tasks, but you can select other users.</p>
<div class="mt-4 space-y-4">
<div>
<label class="text-sm font-medium text-slate-300">User</label>
<select id="taskUserSelect" class="field mt-1">
<option value="">— login to load users —</option>
</select>
</div>
<div class="flex flex-wrap gap-3">
<button id="loadTasksBtn" class="btn btn-accent-emerald">Load Tasks</button>
<button id="refreshUsersBtn" class="btn btn-secondary">Refresh Users</button>
</div>
</div>
</article>
</section>
<section class="panel p-6">
<h2 class="text-xl font-medium">Tasks (Tasgird)</h2>
<div class="mt-4 overflow-x-auto">
<table class="w-full min-w-[1300px] border-collapse">
<thead>
<tr class="text-left text-xs uppercase tracking-wider text-slate-400">
<th class="task-cell">id</th>
<th class="task-cell">user</th>
<th class="task-cell">title</th>
<th class="task-cell">startDate (UTC)</th>
<th class="task-cell">dueDate (UTC)</th>
<th class="task-cell">priority</th>
<th class="task-cell">completed</th>
<th class="task-cell">content</th>
<th class="task-cell">size</th>
<th class="task-cell">status</th>
</tr>
</thead>
<tbody id="tasksBody">
<tr><td class="task-cell text-slate-400" colspan="10">No tasks loaded.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="panel p-6 mt-6">
<h2 class="text-xl font-medium">Output</h2>
<pre id="output" class="mt-4 min-h-[180px] 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';
let pb = null;
let pbConfigPromise = null;
let usersCache = [];
const output = document.getElementById('output');
const healthStatus = document.getElementById('healthStatus');
const pbStatus = document.getElementById('pbStatus');
const taskCount = document.getElementById('taskCount');
const taskUserSelect = document.getElementById('taskUserSelect');
const tasksBody = document.getElementById('tasksBody');
function writeOutput(value) {
output.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
function toUtcText(value) {
if (!value) return '';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
return d.toISOString();
}
function stripHtml(value) {
if (!value) return '';
const div = document.createElement('div');
div.innerHTML = String(value);
return (div.textContent || div.innerText || '').trim();
}
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;
}
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';
}
}
async function loadHealth() {
try {
const resp = await fetch('/health');
const data = await resp.json().catch(() => ({}));
healthStatus.textContent = resp.ok ? `OK (${data.pbDB || 'configured'})` : 'Unavailable';
} catch {
healthStatus.textContent = 'Unavailable';
}
}
async function loginPocketBase() {
writeOutput('Opening Microsoft login...');
const client = await ensurePocketBaseClient();
const config = await getPocketBaseConfig();
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();
await loadUsers();
writeOutput({ message: 'PocketBase login completed.', user: authData?.record?.email || client.authStore.model?.email || null });
}
async function logoutPocketBase() {
const client = await ensurePocketBaseClient();
client.authStore.clear();
usersCache = [];
taskUserSelect.innerHTML = '<option value="">— login to load users —</option>';
tasksBody.innerHTML = '<tr><td class="task-cell text-slate-400" colspan="10">No tasks loaded.</td></tr>';
taskCount.textContent = '0';
await updatePbStatus();
writeOutput({ message: 'PocketBase session cleared.' });
}
function getCurrentToken() {
if (!pb?.authStore?.isValid || !pb?.authStore?.token) {
throw new Error('PocketBase login required');
}
return pb.authStore.token;
}
async function loadUsers() {
await ensurePocketBaseClient();
const token = getCurrentToken();
const resp = await fetch('/api/tasks/users', {
headers: { 'x-pb-token': token },
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
throw new Error(data?.message || 'Failed to load users');
}
usersCache = Array.isArray(data.users) ? data.users : [];
const meId = String(data?.me?.id || '').trim();
const meName = data?.me?.name || data?.me?.email || 'Me';
taskUserSelect.innerHTML = '';
for (const user of usersCache) {
const opt = document.createElement('option');
opt.value = user.id;
opt.textContent = `${user.name || user.email || user.id}${user.id === meId ? ' (Me)' : ''}`;
taskUserSelect.appendChild(opt);
}
if (meId) {
taskUserSelect.value = meId;
}
writeOutput({ message: `Loaded ${usersCache.length} users`, me: { id: meId, name: meName } });
if (data?.warning) {
writeOutput({ message: `Loaded ${usersCache.length} users`, warning: data.warning, me: { id: meId, name: meName } });
}
}
function renderTasks(tasks) {
const rows = Array.isArray(tasks) ? tasks : [];
taskCount.textContent = String(rows.length);
if (!rows.length) {
tasksBody.innerHTML = '<tr><td class="task-cell text-slate-400" colspan="10">No tasks found for selected user.</td></tr>';
return;
}
tasksBody.innerHTML = '';
for (const task of rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="task-cell"><span class="mono-pill">${task.id || ''}</span></td>
<td class="task-cell">${task.userDisplay || task.user || ''}</td>
<td class="task-cell">${task.title || ''}</td>
<td class="task-cell">${toUtcText(task.startDate) || ''}</td>
<td class="task-cell">${toUtcText(task.dueDate) || ''}</td>
<td class="task-cell">${task.priority ?? ''}</td>
<td class="task-cell">${task.completed ? 'true' : 'false'}</td>
<td class="task-cell">${stripHtml(task.content || '').slice(0, 240)}</td>
<td class="task-cell">${task.size ?? ''}</td>
<td class="task-cell">${task.status ?? ''}</td>
`;
tasksBody.appendChild(tr);
}
}
async function loadTasks() {
await ensurePocketBaseClient();
const token = getCurrentToken();
const userId = String(taskUserSelect.value || '').trim();
const url = userId
? `/api/tasks/list?userId=${encodeURIComponent(userId)}`
: '/api/tasks/list';
const resp = await fetch(url, {
headers: { 'x-pb-token': token },
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
throw new Error(data?.message || 'Failed to load tasks');
}
if (data?.userId && taskUserSelect.value !== data.userId) {
taskUserSelect.value = data.userId;
}
renderTasks(data.tasks || []);
writeOutput({
message: `Loaded ${data?.count ?? 0} task(s)`,
userId: data?.userId || userId,
warning: data?.warning || undefined,
tasks: data.tasks || [],
});
}
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('refreshUsersBtn', loadUsers);
bind('loadTasksBtn', loadTasks);
taskUserSelect.addEventListener('change', async () => {
try {
await loadTasks();
} catch (error) {
writeOutput({ error: error?.message || String(error) });
}
});
ensurePocketBaseClient()
.then(() => updatePbStatus())
.catch((error) => writeOutput({ error: error?.message || String(error) }));
loadHealth();
</script>
</body>
</html>