Files
Hermes fca56ab77e T1/Phase 1: scaffold Vite SPA + Hono API + Bun worker
- apps/web: Vite + React 19 + TanStack Router/Query + shadcn/ui
   - apps/api: Hono + Bun on :3001 with /api/health, /api/auth/*, /mcp stubs
   - apps/worker: Bun worker stub, DB connection, graceful SIGTERM
   - apps/web-legacy/: old Next.js code moved aside (preserved for T2-T8 reference)
   - Dockerfiles: api (Bun), worker (Bun), spa (multi-stage Caddy)
   - Caddyfile: serves dist + reverse-proxies /api/* + /mcp to api
   - docker-compose.yml: 4-service target (api, spa, db, worker)
   - packages/db/src/client.ts: shared Drizzle client for api + worker
   - db/client.ts: root-level alias for convenience

   Parent: t_e1cbd87d -> t_24c9c3fd (T0)
2026-08-01 01:15:31 +00:00

308 lines
8.4 KiB
TypeScript

import { createPocketBaseClient, createAdminClient } from '../pocketbase';
import type { Note, NoteLink, NoteTaskLink, Task } from '@project-e/shared';
/** Cast a PocketBase RecordModel to a typed domain model */
function asNote(record: Record<string, unknown>): Note {
return record as unknown as Note;
}
/** Cast a PocketBase RecordModel to a typed domain model */
function asNoteLink(record: Record<string, unknown>): NoteLink {
return record as unknown as NoteLink;
}
/** Cast a PocketBase RecordModel to a typed domain model */
function asNoteTaskLink(record: Record<string, unknown>): NoteTaskLink {
return record as unknown as NoteTaskLink;
}
/** Cast a PocketBase RecordModel to a typed domain model */
function asTask(record: Record<string, unknown>): Task {
return record as unknown as Task;
}
/**
* Extract wikilinks from note content
* Matches [[Note Title]] syntax
*/
export function extractWikilinks(content: string): string[] {
const regex = /\[\[([^\]]+)\]\]/g;
const links: string[] = [];
let match;
while ((match = regex.exec(content)) !== null) {
links.push(match[1].trim());
}
return [...new Set(links)]; // Deduplicate
}
/**
* Update note_links when a note is saved
* Re-syncs all outbound wikilinks from the note content
*/
export async function syncNoteLinks(
noteId: string,
content: string,
token?: string
): Promise<void> {
const pb = token ? createPocketBaseClient(token) : createAdminClient();
// Extract wikilinks from content
const linkTitles = extractWikilinks(content);
// Get existing links from this note
const existingResults = await pb.collection('note_links').getFullList({
filter: `source_note_id = "${noteId}"`,
});
const existingLinks = existingResults.map((r) =>
asNoteLink(r as unknown as Record<string, unknown>)
);
// Delete existing links
for (const link of existingLinks) {
await pb.collection('note_links').delete(link.id);
}
// Create new links
for (const title of linkTitles) {
// Find target note by title
const targetNotes = await pb.collection('notes').getFullList({
filter: `title = "${title}"`,
});
if (targetNotes.length > 0) {
await pb.collection('note_links').create({
source_note_id: noteId,
target_note_id: targetNotes[0].id,
label: title,
});
}
}
}
/**
* Get backlinks for a note (notes that link TO this note)
*/
export async function getBacklinks(
noteId: string,
token?: string
): Promise<Note[]> {
const pb = token ? createPocketBaseClient(token) : createAdminClient();
// Find all note_links where this note is the target
const linkResults = await pb.collection('note_links').getFullList({
filter: `target_note_id = "${noteId}"`,
});
const links = linkResults.map((r) =>
asNoteLink(r as unknown as Record<string, unknown>)
);
// Get the source notes
const backlinks: Note[] = [];
for (const link of links) {
const note = asNote(
await pb
.collection('notes')
.getOne(link.source_note_id) as unknown as Record<string, unknown>
);
backlinks.push(note);
}
return backlinks;
}
/**
* Sync checkboxes in note content with tasks
* Maps checkboxes to tasks via note_task_links
*/
export async function syncNoteTasks(
noteId: string,
content: string,
token?: string
): Promise<void> {
const pb = token ? createPocketBaseClient(token) : createAdminClient();
// Extract checkbox patterns: - [ ] Task title or - [x] Task title
const checkboxRegex = /^- \[([ x])\] (.+)$/gm;
const checkboxes: Array<{
done: boolean;
title: string;
position: number;
}> = [];
let match;
let position = 0;
while ((match = checkboxRegex.exec(content)) !== null) {
checkboxes.push({
done: match[1] === 'x',
title: match[2].trim(),
position: position++,
});
}
// Get existing note_task_links
const existingResults = await pb.collection('note_task_links').getFullList({
filter: `note_id = "${noteId}"`,
});
const existingLinks = existingResults.map((r) =>
asNoteTaskLink(r as unknown as Record<string, unknown>)
);
// Match checkboxes to existing links by index position
for (let i = 0; i < checkboxes.length; i++) {
const checkbox = checkboxes[i];
const existingLink = existingLinks.find(
(l) => l.label === `checkbox_${i}` || (!l.label && existingLinks.indexOf(l) === i)
);
if (existingLink) {
// Update existing task
const task = asTask(
await pb
.collection('tasks')
.getOne(existingLink.task_id) as unknown as Record<string, unknown>
);
// Update title if changed
if (task.title !== checkbox.title) {
await pb.collection('tasks').update(existingLink.task_id, {
title: checkbox.title,
});
}
// Update done status if changed
const taskDone = task.status === 'done';
if (taskDone !== checkbox.done) {
await pb.collection('tasks').update(existingLink.task_id, {
status: checkbox.done ? 'done' : 'todo',
...(checkbox.done && { completed_at: new Date().toISOString() }),
});
}
} else {
// Create new task for this checkbox
const note = asNote(
await pb
.collection('notes')
.getOne(noteId) as unknown as Record<string, unknown>
);
const newTask = await pb.collection('tasks').create({
title: checkbox.title,
description: '',
status: checkbox.done ? 'done' : 'todo',
priority: 'medium',
domain: note.domain,
tags: [],
...(checkbox.done && { completed_at: new Date().toISOString() }),
});
// Create mapping
await pb.collection('note_task_links').create({
note_id: noteId,
task_id: newTask.id,
label: `checkbox_${i}`,
});
}
}
// Remove links for checkboxes that no longer exist
for (const link of existingLinks) {
const matchIndex = parseInt(
(link.label || '').replace('checkbox_', ''),
10
);
if (isNaN(matchIndex) || matchIndex >= checkboxes.length) {
await pb.collection('note_task_links').delete(link.id);
}
}
}
/**
* Parse frontmatter from note content
* Frontmatter is YAML between --- delimiters at the top
*/
export function parseFrontmatter(content: string): {
frontmatter: Record<string, unknown>;
contentWithoutFrontmatter: string;
} {
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
const match = content.match(frontmatterRegex);
if (!match) {
return { frontmatter: {}, contentWithoutFrontmatter: content };
}
// Simple YAML parsing (key: value pairs)
const yamlText = match[1];
const frontmatter: Record<string, unknown> = {};
for (const line of yamlText.split('\n')) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
// Try to parse as JSON for arrays/objects
try {
frontmatter[key] = JSON.parse(value);
} catch {
frontmatter[key] = value;
}
}
}
return {
frontmatter,
contentWithoutFrontmatter: match[2],
};
}
/**
* Get graph data for note visualization
*/
export async function getNoteGraph(
token?: string
): Promise<{
nodes: Array<{
id: string;
title: string;
domain: string;
connectionCount: number;
}>;
edges: Array<{ source: string; target: string }>;
}> {
const pb = token ? createPocketBaseClient(token) : createAdminClient();
const noteResults = await pb.collection('notes').getFullList();
const notes = noteResults.map((r) => asNote(r as unknown as Record<string, unknown>));
const linkResults = await pb.collection('note_links').getFullList();
const links = linkResults.map((r) =>
asNoteLink(r as unknown as Record<string, unknown>)
);
// Count connections per note
const connectionCounts: Record<string, number> = {};
for (const link of links) {
connectionCounts[link.source_note_id] =
(connectionCounts[link.source_note_id] || 0) + 1;
connectionCounts[link.target_note_id] =
(connectionCounts[link.target_note_id] || 0) + 1;
}
const nodes = notes.map((n) => ({
id: n.id,
title: n.title,
domain: n.domain,
connectionCount: connectionCounts[n.id] || 0,
}));
const edges = links.map((l) => ({
source: l.source_note_id,
target: l.target_note_id,
}));
return { nodes, edges };
}