Add isolated legacy notes route and tasks view with resilient task APIs
This commit is contained in:
@@ -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, '&')
|
||||
@@ -65,6 +107,267 @@ function escapeHtml(s: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function buildNotesPayload(input: {
|
||||
user: any;
|
||||
bodyPlain: string;
|
||||
title: string;
|
||||
noteType: string;
|
||||
jobNumber: string;
|
||||
userEmail?: string;
|
||||
noteId?: string;
|
||||
onenoteId?: string;
|
||||
}): Record<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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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(
|
||||
`${base}/pages/${pageId}/content`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { ...authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([{ target: 'body', action: 'append', content: appendHtml }]),
|
||||
},
|
||||
);
|
||||
const pageAppendHtml = syncMode === 'add_separate' ? separateNoteHtml : appendHtml;
|
||||
const action = syncMode === 'add_separate' ? 'insert' : 'append';
|
||||
|
||||
let patchResp: Response | null = null;
|
||||
let targetUsed = '';
|
||||
if (syncMode === 'add_separate') {
|
||||
const candidates = objectTargetCandidates(targetObjectId);
|
||||
if (!candidates.length) {
|
||||
return c.json({ success: false, message: 'Separate mode could not build target candidates from object anchor' }, 400);
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
for (const position of ['after', 'before'] as const) {
|
||||
const command = {
|
||||
target: candidate,
|
||||
action: 'insert',
|
||||
position,
|
||||
content: pageAppendHtml,
|
||||
};
|
||||
|
||||
const resp = await fetch(
|
||||
`${base}/pages/${pageId}/content`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { ...authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([command]),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
patchResp = resp;
|
||||
targetUsed = `${candidate} (${position})`;
|
||||
break;
|
||||
}
|
||||
patchResp = resp;
|
||||
}
|
||||
if (patchResp?.ok) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const command = {
|
||||
target: 'body',
|
||||
action,
|
||||
content: pageAppendHtml,
|
||||
};
|
||||
patchResp = await fetch(
|
||||
`${base}/pages/${pageId}/content`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { ...authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([command]),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!patchResp) {
|
||||
return c.json({ success: false, message: 'OneNote patch failed before request execution' }, 502);
|
||||
}
|
||||
|
||||
if (patchResp.ok) {
|
||||
console.log('onenote_append_success', { mode: 'patch', noteId, siteId });
|
||||
return c.json({ success: true, mode: 'appended', syncedAt: ts });
|
||||
const onenoteId = effectiveNoteId;
|
||||
await upsertSyncFields(effectiveNoteId);
|
||||
console.log('onenote_append_success', { mode: 'patch', noteId: effectiveNoteId, siteId });
|
||||
return c.json({
|
||||
success: true,
|
||||
mode: syncMode === 'add_separate' ? 'separate_inserted' : 'appended',
|
||||
operation: action,
|
||||
targetUsed: targetUsed || null,
|
||||
pageId,
|
||||
noteId: effectiveNoteId,
|
||||
onenoteId,
|
||||
notesRecordId: notesRecord.id,
|
||||
notesWarnings,
|
||||
syncedAt: ts,
|
||||
});
|
||||
}
|
||||
|
||||
const patchErr = await patchResp.text();
|
||||
@@ -343,14 +1029,30 @@ app.post('/api/onenote/append', async (c) => {
|
||||
|
||||
if (postResp.ok) {
|
||||
const created = await postResp.json().catch(() => ({}));
|
||||
console.log('onenote_create_success', { mode: 'create', noteId, pageId: created?.id, siteId });
|
||||
return c.json({ success: true, mode: 'created', pageId: created?.id || null, syncedAt: ts });
|
||||
const createdPageId = created?.id ? String(created.id) : '';
|
||||
const onenoteId = effectiveNoteId;
|
||||
await upsertSyncFields(effectiveNoteId);
|
||||
console.log('onenote_create_success', { mode: 'create', noteId: effectiveNoteId, pageId: created?.id, siteId });
|
||||
return c.json({
|
||||
success: true,
|
||||
mode: 'created',
|
||||
pageId: createdPageId || null,
|
||||
noteId: effectiveNoteId,
|
||||
onenoteId: onenoteId || null,
|
||||
notesRecordId: notesRecord.id,
|
||||
notesWarnings,
|
||||
syncedAt: ts,
|
||||
});
|
||||
}
|
||||
|
||||
const postErr = await postResp.text();
|
||||
console.error('onenote_create_failed', { status: postResp.status, postErr, siteId, sectionId });
|
||||
return c.json(
|
||||
{ success: false, message: `OneNote write failed (create ${postResp.status})`, details: { create: postErr, siteId, sectionId } },
|
||||
{
|
||||
success: false,
|
||||
message: `OneNote write failed (create ${postResp.status})`,
|
||||
details: { create: postErr, siteId, sectionId, notesRecordId: notesRecord.id, notesWarnings },
|
||||
},
|
||||
502,
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -364,8 +1066,6 @@ app.post('/api/onenote/append', async (c) => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/submit — save a note record to PocketBase Notes collection
|
||||
// ---------------------------------------------------------------------------
|
||||
const PB_NOTES_COLLECTION = process.env.PB_NOTES_COLLECTION || 'Notes';
|
||||
|
||||
app.post('/api/submit', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
@@ -382,29 +1082,17 @@ app.post('/api/submit', async (c) => {
|
||||
const title = String(body.title || 'Untitled').trim();
|
||||
const noteType = String(body.noteType || 'personal').trim();
|
||||
const jobNumber = String(body.jobNumber || '').trim();
|
||||
const noteId = String(body.noteId || '').trim();
|
||||
|
||||
// Convert plain text to simple HTML (matches what the main app stores)
|
||||
const bodyHtml = bodyPlain
|
||||
.split(/\r\n|\r|\n/)
|
||||
.map((line) => `<p>${line || ' '}</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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user