Add isolated legacy notes route and tasks view with resilient task APIs
This commit is contained in:
+171
-18
@@ -93,11 +93,17 @@
|
|||||||
<body class="min-h-screen bg-gradient-to-b from-slate-950 via-slate-950 to-slate-900 text-slate-100">
|
<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">
|
<main class="mx-auto max-w-6xl px-6 py-10">
|
||||||
<header class="panel mb-8">
|
<header class="panel mb-8">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex flex-wrap items-center justify-between 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 class="flex items-center gap-4">
|
||||||
<div>
|
<img src="/images/prism.png" alt="Prism Notes" class="h-14 w-14 rounded-xl object-cover ring-1 ring-slate-600/60" />
|
||||||
<p class="text-xs uppercase tracking-[0.3em] text-cyan-400">Prism Notes</p>
|
<div>
|
||||||
<h1 class="mt-1 text-3xl font-semibold tracking-tight">Fresh rebuild</h1>
|
<p class="text-xs uppercase tracking-[0.3em] text-cyan-400">Prism Notes</p>
|
||||||
|
<h1 class="mt-1 text-3xl font-semibold tracking-tight">Fresh rebuild</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<a href="/notes" class="btn btn-secondary">Open Legacy Notes</a>
|
||||||
|
<a href="/tasks" class="btn btn-secondary">Open Tasks View</a>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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>
|
||||||
@@ -167,13 +173,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-slate-300">Sync Mode</label>
|
<label class="text-sm font-medium text-slate-300">Sync Mode</label>
|
||||||
<select id="syncModeSelect" class="field mt-1">
|
<select id="syncModeSelect" class="field mt-1">
|
||||||
<option value="add">add new note block</option>
|
<option value="add_to" selected>add to same box (default)</option>
|
||||||
<option value="edit">edit existing note block</option>
|
<option value="add_separate">add as separate box</option>
|
||||||
|
<option value="edit">edit note</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-slate-300">Existing Note ID (edit mode)</label>
|
<label class="text-sm font-medium text-slate-300">Existing Note ID (add to/edit)</label>
|
||||||
<input id="syncEditNoteId" class="field mt-1" placeholder="Paste noteId to replace on selected page" />
|
<input id="syncEditNoteId" class="field mt-1" placeholder="Pick from page list or paste noteId" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<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>
|
<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>
|
<select id="syncPageSelect" class="field mt-1"><option value="">— create new page in selected section —</option></select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="text-sm font-medium text-slate-300">New Page Title (used when page is not selected)</label>
|
<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)" />
|
<input id="syncPageTitle" class="field mt-1" placeholder="Optional (defaults to note title)" />
|
||||||
@@ -253,8 +268,11 @@
|
|||||||
const syncEditNoteId = document.getElementById('syncEditNoteId');
|
const syncEditNoteId = document.getElementById('syncEditNoteId');
|
||||||
const syncSectionSelect = document.getElementById('syncSectionSelect');
|
const syncSectionSelect = document.getElementById('syncSectionSelect');
|
||||||
const syncPageSelect = document.getElementById('syncPageSelect');
|
const syncPageSelect = document.getElementById('syncPageSelect');
|
||||||
|
const syncObjectAnchor = document.getElementById('syncObjectAnchor');
|
||||||
|
const syncExistingNoteSelect = document.getElementById('syncExistingNoteSelect');
|
||||||
const syncPageTitle = document.getElementById('syncPageTitle');
|
const syncPageTitle = document.getElementById('syncPageTitle');
|
||||||
let resolvedSiteId = null;
|
let resolvedSiteId = null;
|
||||||
|
let syncPageNoteBlocks = [];
|
||||||
|
|
||||||
const noteTitle = document.getElementById('noteTitle');
|
const noteTitle = document.getElementById('noteTitle');
|
||||||
const noteJobNumber = document.getElementById('noteJobNumber');
|
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() {
|
async function loadSyncTargets() {
|
||||||
syncTargetsStatus.textContent = 'Loading sections…';
|
syncTargetsStatus.textContent = 'Loading sections…';
|
||||||
if (!hasUsableToken()) {
|
if (!hasUsableToken()) {
|
||||||
@@ -560,6 +618,8 @@
|
|||||||
async function loadSyncPagesForSelectedSection() {
|
async function loadSyncPagesForSelectedSection() {
|
||||||
const sectionId = syncSectionSelect.value.trim();
|
const sectionId = syncSectionSelect.value.trim();
|
||||||
syncPageSelect.innerHTML = '<option value="">— create new page in selected section —</option>';
|
syncPageSelect.innerHTML = '<option value="">— create new page in selected section —</option>';
|
||||||
|
syncExistingNoteSelect.innerHTML = '<option value="">— select a page first —</option>';
|
||||||
|
syncPageNoteBlocks = [];
|
||||||
|
|
||||||
if (!sectionId) {
|
if (!sectionId) {
|
||||||
syncTargetsStatus.textContent = 'Select a section first';
|
syncTargetsStatus.textContent = 'Select a section first';
|
||||||
@@ -589,6 +649,58 @@
|
|||||||
writeOutput({ message: `Loaded ${pages.length} page(s)`, sectionId, pages });
|
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() {
|
async function submitNoteToPrismNotes() {
|
||||||
const client = await ensurePocketBaseClient();
|
const client = await ensurePocketBaseClient();
|
||||||
if (!client.authStore.isValid || !client.authStore.token) {
|
if (!client.authStore.isValid || !client.authStore.token) {
|
||||||
@@ -619,23 +731,36 @@
|
|||||||
throw new Error('OneNote connection required');
|
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 sectionId = syncSectionSelect.value.trim();
|
||||||
const pageId = syncPageSelect.value.trim();
|
const pageId = syncPageSelect.value.trim();
|
||||||
const pageTitle = syncPageTitle.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) {
|
if (!sectionId) {
|
||||||
throw new Error('Load sync targets and select a section first');
|
throw new Error('Load sync targets and select a section first');
|
||||||
}
|
}
|
||||||
if (syncMode === 'edit' && !pageId) {
|
if ((syncMode === 'add_to' || syncMode === 'edit') && !pageId) {
|
||||||
throw new Error('Edit mode requires selecting an existing OneNote page');
|
throw new Error('Add/Edit mode requires selecting an existing OneNote page');
|
||||||
}
|
}
|
||||||
if (syncMode === 'edit' && !targetNoteId) {
|
if ((syncMode === 'add_to' || syncMode === 'edit') && !targetNoteId) {
|
||||||
throw new Error('Edit mode requires Existing Note ID');
|
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', {
|
const resp = await fetch('/api/onenote/append', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -650,6 +775,7 @@
|
|||||||
pageId,
|
pageId,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
targetNoteId,
|
targetNoteId,
|
||||||
|
targetObjectId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await resp.json().catch(() => ({}));
|
const data = await resp.json().catch(() => ({}));
|
||||||
@@ -657,14 +783,19 @@
|
|||||||
throw new Error(data?.message || 'OneNote sync failed');
|
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');
|
const createdOpt = document.createElement('option');
|
||||||
createdOpt.value = data.pageId;
|
createdOpt.value = data.pageId;
|
||||||
createdOpt.textContent = `${pageTitle || payload.title} — ${data.pageId}`;
|
createdOpt.textContent = `${pageTitle || payload.title} — ${data.pageId}`;
|
||||||
syncPageSelect.appendChild(createdOpt);
|
syncPageSelect.appendChild(createdOpt);
|
||||||
syncPageSelect.value = data.pageId;
|
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 });
|
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()
|
ensurePocketBaseClient()
|
||||||
.then(() => updatePbStatus())
|
.then(() => updatePbStatus())
|
||||||
.catch((error) => writeOutput({ error: error?.message || String(error) }));
|
.catch((error) => writeOutput({ error: error?.message || String(error) }));
|
||||||
updateOneNoteStatus();
|
updateOneNoteStatus();
|
||||||
|
applySyncModeUi();
|
||||||
loadHealth();
|
loadHealth();
|
||||||
loadGraphStatus();
|
loadGraphStatus();
|
||||||
handleMsalRedirect().catch((error) => writeOutput({ error: error?.message || String(error) }));
|
handleMsalRedirect().catch((error) => writeOutput({ error: error?.message || String(error) }));
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -24,6 +24,10 @@ const ONENOTE_TARGET = {
|
|||||||
notebookName: 'Billing Notes',
|
notebookName: 'Billing Notes',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WORKSPACE_ROOT = new URL('./', import.meta.url).pathname;
|
||||||
|
const LEGACY_NOTES_COMMIT = '4c6058c';
|
||||||
|
let legacyNotesHtmlCache: string | null = null;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App
|
// 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_BASE_URL = process.env.PB_URL || process.env.PB_DB || '';
|
||||||
const PB_AUTH_COLLECTION = process.env.PB_AUTH_COLLECTION || 'Users';
|
const PB_AUTH_COLLECTION = process.env.PB_AUTH_COLLECTION || 'Users';
|
||||||
const PB_OAUTH_PROVIDER = 'microsoft';
|
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);
|
const pb = new PocketBase(PB_BASE_URL);
|
||||||
|
|
||||||
@@ -56,6 +62,42 @@ function getBearerToken(header: string | undefined | null): string | null {
|
|||||||
return m?.[1] || 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 {
|
function escapeHtml(s: string): string {
|
||||||
return s
|
return s
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -65,6 +107,267 @@ function escapeHtml(s: string): string {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 || ' '}</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(/ /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 = /<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)
|
// 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 }));
|
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) => {
|
app.get('/api/auth/pocketbase-config', (c) => {
|
||||||
if (!PB_BASE_URL) {
|
if (!PB_BASE_URL) {
|
||||||
return c.json({ success: false, message: 'Missing PB_URL or PB_DB' }, 500);
|
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) => {
|
app.get('/api/graph/status', async (c) => {
|
||||||
try {
|
try {
|
||||||
const t = await getGraphToken();
|
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
|
// POST /api/onenote/append
|
||||||
// Requires: x-pb-token (PocketBase session) + Authorization: Bearer <delegated Graph token>
|
// Requires: x-pb-token (PocketBase session) + Authorization: Bearer <delegated Graph token>
|
||||||
// Body: {
|
// Body: {
|
||||||
// noteId, title, bodyPlain, noteType?, userEmail?, jobNumber?,
|
// 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:
|
// Strategy:
|
||||||
// 1. Resolve site ID via GET /sites/{host}:{path}
|
// 1. Resolve site ID via GET /sites/{host}:{path}
|
||||||
// 2. If syncMode=edit: PATCH replace note block in known page by noteId
|
// 2. If syncMode=edit: PATCH replace note block in known page by noteId
|
||||||
// 3. If syncMode=add and pageId: PATCH append to known page
|
// 3. If syncMode=add_to: PATCH append content to existing note block by noteId
|
||||||
// 4. If syncMode=add and no pageId: POST create new page in known section
|
// 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) => {
|
app.post('/api/onenote/append', async (c) => {
|
||||||
try {
|
try {
|
||||||
@@ -235,8 +725,11 @@ app.post('/api/onenote/append', async (c) => {
|
|||||||
const userEmail = String(body?.userEmail || 'unknown').trim();
|
const userEmail = String(body?.userEmail || 'unknown').trim();
|
||||||
const jobNumber = String(body?.jobNumber || '').trim();
|
const jobNumber = String(body?.jobNumber || '').trim();
|
||||||
const pageTitle = String(body?.pageTitle || title || ONENOTE_TARGET.pageTitle || 'Prism Notes').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 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 (!noteId) return c.json({ success: false, message: 'Missing noteId' }, 400);
|
||||||
if (!bodyPlain) return c.json({ success: false, message: 'Missing bodyPlain' }, 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)}`,
|
`type: ${escapeHtml(noteType)}`,
|
||||||
`user: ${escapeHtml(userEmail)}`,
|
`user: ${escapeHtml(userEmail)}`,
|
||||||
jobNumber ? `job: ${escapeHtml(jobNumber)}` : '',
|
jobNumber ? `job: ${escapeHtml(jobNumber)}` : '',
|
||||||
`note_id: ${escapeHtml(noteId)}`,
|
`note_id: ${escapeHtml(effectiveNoteId)}`,
|
||||||
].filter(Boolean).join(' | ');
|
].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 = [
|
const pageHtml = [
|
||||||
'<!DOCTYPE html>',
|
'<!DOCTYPE html>',
|
||||||
@@ -264,12 +773,70 @@ app.post('/api/onenote/append', async (c) => {
|
|||||||
const sectionId = String(body?.sectionId || ONENOTE_TARGET.sectionId || '').trim();
|
const sectionId = String(body?.sectionId || ONENOTE_TARGET.sectionId || '').trim();
|
||||||
const pageId = String(body?.pageId || ONENOTE_TARGET.pageId || '').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 (!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) {
|
if ((syncMode === 'edit' || syncMode === 'add_to') && !pageId) {
|
||||||
return c.json({ success: false, message: 'Edit mode requires pageId' }, 400);
|
return c.json({ success: false, message: 'Add/Edit mode requires pageId' }, 400);
|
||||||
}
|
}
|
||||||
if (syncMode === 'edit' && !targetNoteId) {
|
if ((syncMode === 'edit' || syncMode === 'add_to') && !targetNoteId) {
|
||||||
return c.json({ success: false, message: 'Edit mode requires targetNoteId or noteId' }, 400);
|
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}` };
|
const authHeader = { Authorization: `Bearer ${delegatedToken}` };
|
||||||
|
|
||||||
@@ -295,8 +862,19 @@ app.post('/api/onenote/append', async (c) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (replaceResp.ok) {
|
if (replaceResp.ok) {
|
||||||
|
const onenoteId = targetNoteId;
|
||||||
|
await upsertSyncFields(targetNoteId);
|
||||||
console.log('onenote_edit_success', { mode: 'edit', noteId, targetNoteId, pageId, siteId });
|
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();
|
const replaceErr = await replaceResp.text();
|
||||||
@@ -305,7 +883,52 @@ app.post('/api/onenote/append', async (c) => {
|
|||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
message: `OneNote edit failed (${replaceResp.status})`,
|
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,
|
502,
|
||||||
);
|
);
|
||||||
@@ -313,18 +936,81 @@ app.post('/api/onenote/append', async (c) => {
|
|||||||
|
|
||||||
/* ---- 1: try append to known page (if we have a valid pageId) ---- */
|
/* ---- 1: try append to known page (if we have a valid pageId) ---- */
|
||||||
if (pageId) {
|
if (pageId) {
|
||||||
const patchResp = await fetch(
|
const pageAppendHtml = syncMode === 'add_separate' ? separateNoteHtml : appendHtml;
|
||||||
`${base}/pages/${pageId}/content`,
|
const action = syncMode === 'add_separate' ? 'insert' : 'append';
|
||||||
{
|
|
||||||
method: 'PATCH',
|
let patchResp: Response | null = null;
|
||||||
headers: { ...authHeader, 'Content-Type': 'application/json' },
|
let targetUsed = '';
|
||||||
body: JSON.stringify([{ target: 'body', action: 'append', content: appendHtml }]),
|
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) {
|
if (patchResp.ok) {
|
||||||
console.log('onenote_append_success', { mode: 'patch', noteId, siteId });
|
const onenoteId = effectiveNoteId;
|
||||||
return c.json({ success: true, mode: 'appended', syncedAt: ts });
|
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();
|
const patchErr = await patchResp.text();
|
||||||
@@ -343,14 +1029,30 @@ app.post('/api/onenote/append', async (c) => {
|
|||||||
|
|
||||||
if (postResp.ok) {
|
if (postResp.ok) {
|
||||||
const created = await postResp.json().catch(() => ({}));
|
const created = await postResp.json().catch(() => ({}));
|
||||||
console.log('onenote_create_success', { mode: 'create', noteId, pageId: created?.id, siteId });
|
const createdPageId = created?.id ? String(created.id) : '';
|
||||||
return c.json({ success: true, mode: 'created', pageId: created?.id || null, syncedAt: ts });
|
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();
|
const postErr = await postResp.text();
|
||||||
console.error('onenote_create_failed', { status: postResp.status, postErr, siteId, sectionId });
|
console.error('onenote_create_failed', { status: postResp.status, postErr, siteId, sectionId });
|
||||||
return c.json(
|
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,
|
502,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -364,8 +1066,6 @@ app.post('/api/onenote/append', async (c) => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /api/submit — save a note record to PocketBase Notes collection
|
// 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) => {
|
app.post('/api/submit', async (c) => {
|
||||||
try {
|
try {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
@@ -382,29 +1082,17 @@ app.post('/api/submit', async (c) => {
|
|||||||
const title = String(body.title || 'Untitled').trim();
|
const title = String(body.title || 'Untitled').trim();
|
||||||
const noteType = String(body.noteType || 'personal').trim();
|
const noteType = String(body.noteType || 'personal').trim();
|
||||||
const jobNumber = String(body.jobNumber || '').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 payload = buildNotesPayload({
|
||||||
const bodyHtml = bodyPlain
|
user,
|
||||||
.split(/\r\n|\r|\n/)
|
bodyPlain,
|
||||||
.map((line) => `<p>${line || ' '}</p>`)
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
|
||||||
body_plain: bodyPlain,
|
|
||||||
body_html: bodyHtml,
|
|
||||||
title,
|
title,
|
||||||
type: noteType,
|
noteType,
|
||||||
email: user?.email || body.userEmail || '',
|
jobNumber,
|
||||||
Username: user?.name || user?.username || user?.email || '',
|
userEmail: String(body.userEmail || '').trim(),
|
||||||
userId: user?.id || '',
|
noteId,
|
||||||
job_note: noteType === 'job' || !!jobNumber,
|
});
|
||||||
shared: false,
|
|
||||||
shared_with: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (jobNumber) {
|
|
||||||
payload['Job_Number'] = jobNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = await pb.collection(PB_NOTES_COLLECTION).create(payload);
|
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('/images/*', serveStatic({ root: new URL('./', import.meta.url).pathname }));
|
||||||
app.use('/tools/*', 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) => {
|
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));
|
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);
|
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
|
// Start
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+421
@@ -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>
|
||||||
Reference in New Issue
Block a user