feat: Phase 4 - Notes + Graph + Wikilinks

- Wikilink parser: [[Title]], [[Title|Display]], [[entity_type:Title]] patterns
- Notes REST API: CRUD under /api/domains/[domainId]/notes/ with wikilink sync
- Note link service: idempotent wikilink resolution, backlinks, outgoing links
- Notes list page: search, filter (all/pinned/archived), domain selector
- Note editor: TipTap with backlinks panel and outgoing links display
- Graph data API: /api/domains/[domainId]/graph and /api/graph
- Graph view page: D3 force-directed graph with entity type filters, search, zoom
- Keyboard shortcuts: g g → graph, c n → new note
- 18 passing wikilink parser tests
- All API routes follow AGENTS.md contract (activity + pg_notify)
This commit is contained in:
2026-07-29 06:56:23 -04:00
parent 064a46f97d
commit 40a26d2672
14 changed files with 1827 additions and 135 deletions
+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect } from '@jest/globals';
import { parseWikilinks, extractLinkTargets, hasWikilinks, formatWikilinkDisplay } from '@/lib/wikilink-parser';
describe('wikilink-parser', () => {
describe('parseWikilinks', () => {
it('parses simple [[Title]] links', () => {
const result = parseWikilinks('Check out [[Meeting Notes]] for details');
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
raw: '[[Meeting Notes]]',
entityType: '',
title: 'Meeting Notes',
displayText: null,
});
});
it('parses [[Title|Display]] links', () => {
const result = parseWikilinks('See [[Long Note Title|this note]]');
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
raw: '[[Long Note Title|this note]]',
entityType: '',
title: 'Long Note Title',
displayText: 'this note',
});
});
it('parses [[entity_type:Title]] cross-entity links', () => {
const result = parseWikilinks('Complete [[task:Buy milk]] and [[habit:Exercise]]');
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
raw: '[[task:Buy milk]]',
entityType: 'task',
title: 'Buy milk',
displayText: null,
});
expect(result[1]).toEqual({
raw: '[[habit:Exercise]]',
entityType: 'habit',
title: 'Exercise',
displayText: null,
});
});
it('parses [[entity_type:Title|Display]] links', () => {
const result = parseWikilinks('See [[project:Stratos|the Stratos project]]');
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
raw: '[[project:Stratos|the Stratos project]]',
entityType: 'project',
title: 'Stratos',
displayText: 'the Stratos project',
});
});
it('handles multiple wikilinks in one string', () => {
const result = parseWikilinks('[[Note A]] and [[task:Task B]] and [[Note C|display]]');
expect(result).toHaveLength(3);
});
it('returns empty array for content with no wikilinks', () => {
const result = parseWikilinks('Plain text with no links');
expect(result).toHaveLength(0);
});
it('returns empty array for empty content', () => {
expect(parseWikilinks('')).toHaveLength(0);
});
it('handles titles with special characters', () => {
const result = parseWikilinks('[[Task:Buy milk & eggs!]]');
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Buy milk & eggs!');
});
it('trims whitespace from titles', () => {
const result = parseWikilinks('[[ Spaced Title ]]');
expect(result[0].title).toBe('Spaced Title');
});
it('handles entity types with underscores', () => {
const result = parseWikilinks('[[note:My Note]]');
expect(result[0].entityType).toBe('note');
expect(result[0].title).toBe('My Note');
});
});
describe('extractLinkTargets', () => {
it('extracts unique link targets', () => {
const result = extractLinkTargets('[[Note A]] and [[Note A]] and [[task:Task B]]');
expect(result).toHaveLength(2);
expect(result).toContainEqual({ entityType: '', title: 'Note A' });
expect(result).toContainEqual({ entityType: 'task', title: 'Task B' });
});
it('deduplicates identical targets', () => {
const result = extractLinkTargets('[[Note A]] and [[Note A|display]]');
expect(result).toHaveLength(1);
});
});
describe('hasWikilinks', () => {
it('returns true when wikilinks exist', () => {
expect(hasWikilinks('Text with [[a link]]')).toBe(true);
});
it('returns false when no wikilinks exist', () => {
expect(hasWikilinks('Plain text')).toBe(false);
});
it('returns false for empty content', () => {
expect(hasWikilinks('')).toBe(false);
});
});
describe('formatWikilinkDisplay', () => {
it('uses display text when available', () => {
const match = parseWikilinks('[[Title|Display]]')[0];
expect(formatWikilinkDisplay(match)).toBe('Display');
});
it('formats entity links without display text', () => {
const match = parseWikilinks('[[task:Buy milk]]')[0];
expect(formatWikilinkDisplay(match)).toBe('task: Buy milk');
});
it('returns title for simple note links', () => {
const match = parseWikilinks('[[Meeting Notes]]')[0];
expect(formatWikilinkDisplay(match)).toBe('Meeting Notes');
});
});
});
+352
View File
@@ -0,0 +1,352 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter } from 'lucide-react';
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Loading graph...</p>
</div>
),
});
interface GraphNode {
id: string;
label: string;
type: string;
color: string;
}
interface GraphLink {
source: string;
target: string;
type: string;
}
interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
const ENTITY_TYPE_COLORS: Record<string, string> = {
task: '#3b82f6',
habit: '#10b981',
project: '#8b5cf6',
note: '#f59e0b',
section: '#ec4899',
tag: '#6b7280',
domain: '#6366f1',
};
const ENTITY_TYPE_LABELS: Record<string, string> = {
task: 'Tasks',
habit: 'Habits',
project: 'Projects',
note: 'Notes',
section: 'Sections',
tag: 'Tags',
domain: 'Domains',
};
export default function GraphPage() {
const router = useRouter();
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], links: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [domainId, setDomainId] = useState<string | null>(null);
const [domains, setDomains] = useState<{ id: string; name: string }[]>([]);
const [filterTypes, setFilterTypes] = useState<Set<string>>(new Set(['task', 'habit', 'project', 'note', 'section', 'tag', 'domain']));
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const graphRef = useRef<any>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
// Fetch domains
useEffect(() => {
fetch('/api/domains?sort=sort_order')
.then((res) => res.json())
.then((data) => {
const items = data.items || [];
setDomains(items);
if (items.length > 0 && !domainId) {
setDomainId(items[0].id);
}
})
.catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch graph data
useEffect(() => {
if (domainId) {
fetchGraphData();
}
}, [domainId]); // eslint-disable-line react-hooks/exhaustive-deps
// Update dimensions on resize
useEffect(() => {
function handleResize() {
if (containerRef.current) {
const { width, height } = containerRef.current.getBoundingClientRect();
setDimensions({ width: Math.floor(width) || 800, height: Math.floor(height) || 600 });
}
}
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
async function fetchGraphData() {
if (!domainId) return;
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/domains/${domainId}/graph`);
if (!response.ok) throw new Error('Unable to load graph data.');
const data = await response.json();
// Convert edges to links for react-force-graph-2d
setGraphData({
nodes: data.nodes || [],
links: (data.edges || []).map((e: any) => ({
source: e.source,
target: e.target,
type: e.type,
})),
});
} catch (error) {
console.error('Failed to fetch graph data:', error);
setError('Unable to load graph data.');
} finally {
setLoading(false);
}
}
const toggleFilterType = useCallback((type: string) => {
setFilterTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
}, []);
// Filter nodes and links
const filteredData = {
nodes: graphData.nodes.filter(
(n) => filterTypes.has(n.type) && (!searchQuery || n.label.toLowerCase().includes(searchQuery.toLowerCase()))
),
links: graphData.links.filter(
(l) => {
const sourceNode = graphData.nodes.find((n) => n.id === l.source);
const targetNode = graphData.nodes.find((n) => n.id === l.target);
return sourceNode && targetNode && filterTypes.has(sourceNode.type) && filterTypes.has(targetNode.type);
}
),
};
function handleNodeClick(node: any) {
const n = node as GraphNode;
switch (n.type) {
case 'task':
router.push(`/tasks?taskId=${n.id}`);
break;
case 'habit':
router.push(`/habits?habitId=${n.id}`);
break;
case 'project':
router.push(`/projects/${n.id}`);
break;
case 'note':
router.push(`/notes?noteId=${n.id}`);
break;
case 'domain':
router.push(`/dashboard?domainId=${n.id}`);
break;
default:
break;
}
}
function handleZoomIn() {
if (graphRef.current) {
const current = graphRef.current.zoom();
graphRef.current.zoom(current * 1.3, 400);
}
}
function handleZoomOut() {
if (graphRef.current) {
const current = graphRef.current.zoom();
graphRef.current.zoom(current / 1.3, 400);
}
}
function handleReset() {
if (graphRef.current) {
graphRef.current.zoomToFit(400);
}
}
if (loading && graphData.nodes.length === 0) {
return <p className="text-muted-foreground" role="status">Loading graph...</p>;
}
if (error) {
return <div className="space-y-3"><p className="text-muted-foreground" role="alert">{error}</p><Button onClick={fetchGraphData}>Retry</Button></div>;
}
return (
<div className="flex h-[calc(100vh-120px)] flex-col">
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Graph</h1>
<p className="mt-1 text-muted-foreground">
Visualize connections between all your entities
</p>
</div>
<div className="flex items-center gap-2">
{domains.length > 1 && (
<select
value={domainId || ''}
onChange={(e) => setDomainId(e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Select domain"
>
{domains.map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
)}
<Button variant="outline" size="icon" onClick={handleZoomIn} aria-label="Zoom in">
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleZoomOut} aria-label="Zoom out">
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleReset} aria-label="Reset view">
<RotateCcw className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-1 gap-4">
{/* Filter sidebar */}
<Card className="w-56 shrink-0 p-4">
<div className="mb-3 flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Filters</span>
</div>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search nodes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-7 text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
{Object.entries(ENTITY_TYPE_LABELS).map(([type, label]) => (
<button
key={type}
onClick={() => toggleFilterType(type)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors ${
filterTypes.has(type) ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: ENTITY_TYPE_COLORS[type] }}
/>
<span className="flex-1 text-left">{label}</span>
<Badge variant="outline" className="text-[10px] px-1">
{graphData.nodes.filter((n) => n.type === type).length}
</Badge>
</button>
))}
</div>
<div className="mt-4 border-t pt-3">
<p className="text-xs text-muted-foreground">
{filteredData.nodes.length} nodes · {filteredData.links.length} edges
</p>
</div>
</Card>
{/* Graph canvas */}
<Card className="flex-1 overflow-hidden" ref={containerRef}>
{filteredData.nodes.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{searchQuery ? 'No matching nodes found' : 'No graph data available'}
</p>
</div>
) : (
<div className="relative h-full w-full">
<ForceGraph2D
ref={graphRef}
graphData={filteredData}
nodeLabel="label"
nodeColor="color"
nodeVal={(node: any) => {
const edgeCount = filteredData.links.filter(
(e) => e.source === node.id || e.target === node.id
).length;
return Math.max(2, Math.min(edgeCount + 2, 20));
}}
linkColor={() => '#374151'}
linkWidth={0.5}
linkDirectionalArrowLength={4}
linkDirectionalArrowRelPos={0.99}
onNodeClick={(node: any) => handleNodeClick(node)}
onNodeHover={(node: any | null) => {
if (node) {
setHoveredNode(node as GraphNode);
} else {
setHoveredNode(null);
}
}}
width={dimensions.width}
height={dimensions.height}
d3AlphaDecay={0.02}
d3VelocityDecay={0.3}
cooldownTicks={100}
warmupTicks={40}
/>
{/* Tooltip */}
{hoveredNode && (
<div
className="pointer-events-none absolute left-4 top-4 z-10 rounded-lg border bg-background p-3 shadow-lg"
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: hoveredNode.color }}
/>
<span className="text-sm font-medium">{hoveredNode.label}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Type: {ENTITY_TYPE_LABELS[hoveredNode.type] || hoveredNode.type}
</p>
<p className="text-xs text-muted-foreground">
ID: {hoveredNode.id.slice(0, 8)}...
</p>
</div>
)}
</div>
)}
</Card>
</div>
</div>
);
}
+267 -121
View File
@@ -1,14 +1,14 @@
'use client';
import { useEffect, useRef, useState, Suspense } from 'react';
import { Plus, FileText, Link2, GitBranch, Trash2 } from 'lucide-react';
import { useState, useEffect, useRef, useCallback, Suspense } from 'react';
import { Plus, FileText, Link2, GitBranch, Trash2, Pin, Archive, Search, PinOff, ArchiveRestore } from 'lucide-react';
import dynamic from 'next/dynamic';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { DailyNoteButton } from '@/components/notes/daily-note-button';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
AlertDialog,
@@ -21,8 +21,10 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
// Lazy load TipTap editor (~80KB TipTap + extensions)
// Lazy load TipTap editor
const NoteEditor = dynamic(
() => import('@/components/notes/note-editor').then((m) => m.NoteEditor),
{
@@ -35,93 +37,97 @@ const NoteEditor = dynamic(
}
);
// Lazy load react-force-graph-2d (~120KB + three.js)
const NoteGraph = dynamic(
() => import('@/components/notes/note-graph').then((m) => m.NoteGraph),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">Loading graph...</div>
</div>
),
}
);
interface Note {
id: string;
title: string;
content: string;
domain: string;
created: string;
updated: string;
content: string | null;
domainId: string;
isPinned: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
tags?: { id: string; name: string; color: string | null }[];
}
interface Backlink {
interface BacklinkItem {
id: string;
title: string;
excerpt: string;
}
interface OutgoingLink {
noteLinks: { id: string; title: string }[];
entityLinks: { entityType: string; entityId: string; title: string | null }[];
}
export default function NotesPage() {
const [notes, setNotes] = useState<Note[]>([]);
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [domainForCreate, setDomainForCreate] = useState('');
const [domainOptions, setDomainOptions] = useState<{id: string; name: string}[]>([]);
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
const [domainId, setDomainId] = useState<string | null>(null);
const [domains, setDomains] = useState<{ id: string; name: string; color: string | null }[]>([]);
const [backlinks, setBacklinks] = useState<BacklinkItem[]>([]);
const [outgoingLinks, setOutgoingLinks] = useState<OutgoingLink | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [backlinksError, setBacklinksError] = useState<string | null>(null);
const [backlinksLoading, setBacklinksLoading] = useState(false);
const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved');
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const [deleting, setDeleting] = useState(false);
const createdFromQuery = useRef(false);
const pendingSave = useRef<{ id: string; updates: Partial<Note> } | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<'all' | 'pinned' | 'archived'>('all');
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveVersion = useRef(0);
const pendingSave = useRef<{ id: string; updates: Partial<Note> } | null>(null);
useEffect(() => { fetchDomains();
// Fetch domains on mount
useEffect(() => {
fetch('/api/domains?sort=sort_order')
.then((res) => res.json())
.then((data) => {
const items = data.items || [];
setDomains(items);
if (items.length > 0 && !domainId) {
setDomainId(items[0].id);
}
})
.catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch notes when domain or filter changes
useEffect(() => {
if (domainId) {
fetchNotes();
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
}
}, [domainId, filterMode]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { fetchDomains();
if (new URLSearchParams(window.location.search).get('new') !== 'true' || createdFromQuery.current) return;
createdFromQuery.current = true;
createNote().finally(() => window.history.replaceState(null, '', '/notes'));
// Route-triggered creation should only run once per page visit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { fetchDomains();
// Fetch backlinks when selected note changes
useEffect(() => {
if (selectedNote) {
fetchBacklinks(selectedNote.id);
fetchOutgoingLinks(selectedNote.id);
}
}, [selectedNote]);
async function fetchDomains() {
try {
const res = await fetch('/api/domains?sort=sort_order');
if (res.ok) {
const data = await res.json();
setDomainOptions(data.items || []);
if (data.items?.length > 0) setDomainForCreate(data.items[0].id);
}
} catch {}
}
}, [selectedNote]); // eslint-disable-line react-hooks/exhaustive-deps
async function fetchNotes() {
if (!domainId) return;
setLoading(true);
setError(null);
try {
const response = await fetch('/api/notes?sort=-updated');
const params = new URLSearchParams();
if (filterMode === 'pinned') params.set('pinned', 'true');
else if (filterMode === 'archived') params.set('archived', 'true');
else params.set('archived', 'false');
if (searchQuery) params.set('search', searchQuery);
params.set('sort', 'updated_at');
params.set('order', 'desc');
const response = await fetch(`/api/domains/${domainId}/notes?${params}`);
if (!response.ok) throw new Error('Unable to load notes.');
const data = await response.json();
const notesList = data.items || [];
setNotes(notesList);
setSelectedNote((current) => current || notesList[0] || null);
if (!selectedNote && notesList.length > 0) {
setSelectedNote(notesList[0]);
}
} catch (error) {
console.error('Failed to fetch notes:', error);
setError('Unable to load notes. Please try again.');
@@ -131,30 +137,40 @@ export default function NotesPage() {
}
async function fetchBacklinks(noteId: string) {
setBacklinksLoading(true);
setBacklinksError(null);
if (!domainId) return;
try {
const response = await fetch(`/api/notes/${noteId}/backlinks`);
const response = await fetch(`/api/domains/${domainId}/notes/${noteId}/backlinks`);
if (!response.ok) throw new Error('Unable to load backlinks.');
const data = await response.json();
setBacklinks(data.items || []);
} catch (error) {
console.error('Failed to fetch backlinks:', error);
setBacklinksError('Unable to load backlinks.');
} finally {
setBacklinksLoading(false);
setBacklinks([]);
}
}
async function fetchOutgoingLinks(noteId: string) {
if (!domainId) return;
try {
const response = await fetch(`/api/domains/${domainId}/notes/${noteId}`);
if (!response.ok) throw new Error('Unable to load note.');
const data = await response.json();
setOutgoingLinks(data.outgoingLinks || null);
} catch (error) {
console.error('Failed to fetch outgoing links:', error);
setOutgoingLinks(null);
}
}
async function createNote() {
if (!domainId) return;
try {
const response = await fetch('/api/notes', {
const response = await fetch(`/api/domains/${domainId}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Untitled note',
content: '',
domain: domainForCreate,
}),
});
if (!response.ok) throw new Error('Unable to create note.');
@@ -168,19 +184,6 @@ export default function NotesPage() {
}
}
function handleDailyNoteReady(raw: Record<string, unknown>) {
const note = raw as unknown as Note;
// If the note already appears in the list, just select it
const exists = notes.find((n) => n.id === note.id);
if (exists) {
setSelectedNote(exists);
return;
}
// Otherwise prepend it and select
setNotes((current) => [note, ...current]);
setSelectedNote(note);
}
function scheduleSave(noteId: string, updates: Partial<Note>) {
const version = ++saveVersion.current;
pendingSave.current = {
@@ -197,15 +200,16 @@ export default function NotesPage() {
}
async function updateNote(noteId: string, updates: Partial<Note>, version: number) {
if (!domainId) return;
try {
const response = await fetch(`/api/notes/${noteId}`, {
const response = await fetch(`/api/domains/${domainId}/notes/${noteId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error('Unable to save note.');
const updated = await response.json();
setNotes((current) => current.map((note) => (note.id === noteId ? updated : note)));
setNotes((current) => current.map((note) => (note.id === noteId ? { ...note, ...updated } : note)));
if (saveVersion.current === version) setSaveStatus('Saved');
} catch (error) {
console.error('Failed to update note:', error);
@@ -215,7 +219,7 @@ export default function NotesPage() {
}
async function deleteNote() {
if (!noteToDelete) return;
if (!noteToDelete || !domainId) return;
if (pendingSave.current?.id === noteToDelete.id && saveTimer.current) {
clearTimeout(saveTimer.current);
pendingSave.current = null;
@@ -223,7 +227,7 @@ export default function NotesPage() {
++saveVersion.current;
setDeleting(true);
try {
const response = await fetch(`/api/notes/${noteToDelete.id}`, { method: 'DELETE' });
const response = await fetch(`/api/domains/${domainId}/notes/${noteToDelete.id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Unable to delete note.');
setNotes((current) => current.filter((note) => note.id !== noteToDelete.id));
setSelectedNote((selected) =>
@@ -239,11 +243,28 @@ export default function NotesPage() {
}
}
async function openBacklink(link: Backlink) {
async function togglePinned(note: Note) {
const newPinned = !note.isPinned;
setSelectedNote((prev) => prev?.id === note.id ? { ...prev, isPinned: newPinned } : prev);
setNotes((current) => current.map((n) => n.id === note.id ? { ...n, isPinned: newPinned } : n));
await updateNote(note.id, { isPinned: newPinned }, ++saveVersion.current);
toast.success(newPinned ? 'Note pinned' : 'Note unpinned');
}
async function toggleArchived(note: Note) {
const newArchived = !note.isArchived;
setSelectedNote((prev) => prev?.id === note.id ? { ...prev, isArchived: newArchived } : prev);
setNotes((current) => current.map((n) => n.id === note.id ? { ...n, isArchived: newArchived } : n));
await updateNote(note.id, { isArchived: newArchived }, ++saveVersion.current);
toast.success(newArchived ? 'Note archived' : 'Note restored');
}
async function openBacklink(link: BacklinkItem) {
const existing = notes.find((note) => note.id === link.id);
if (existing) return setSelectedNote(existing);
if (!domainId) return;
try {
const response = await fetch(`/api/notes/${link.id}`);
const response = await fetch(`/api/domains/${domainId}/notes/${link.id}`);
if (!response.ok) throw new Error('Unable to load linked note.');
const note = await response.json() as Note;
setNotes((current) => [note, ...current]);
@@ -254,11 +275,21 @@ export default function NotesPage() {
}
}
if (loading) {
const handleRefresh = useCallback(() => {
fetchNotes();
}, [domainId, filterMode, searchQuery]); // eslint-disable-line react-hooks/exhaustive-deps
// Filter notes locally for search
const filteredNotes = notes.filter((note) => {
if (searchQuery && !note.title.toLowerCase().includes(searchQuery.toLowerCase())) return false;
return true;
});
if (loading && notes.length === 0) {
return <p className="text-muted-foreground" role="status">Loading notes...</p>;
}
if (error) {
if (error && notes.length === 0) {
return <div className="space-y-3"><p className="text-muted-foreground" role="alert">{error}</p><Button onClick={fetchNotes}>Retry</Button></div>;
}
@@ -272,7 +303,18 @@ export default function NotesPage() {
</p>
</div>
<div className="flex items-center gap-3">
<DailyNoteButton onNoteReady={handleDailyNoteReady} />
{domains.length > 1 && (
<select
value={domainId || ''}
onChange={(e) => setDomainId(e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Select domain"
>
{domains.map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
)}
<Button onClick={createNote}>
<Plus className="mr-2 h-4 w-4" />
New note
@@ -280,14 +322,53 @@ export default function NotesPage() {
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr_300px]">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr_320px]">
{/* Notes list */}
<Card className="max-h-80 lg:h-[calc(100vh-200px)] lg:max-h-none">
<ScrollArea className="max-h-80 lg:h-full lg:max-h-none">
<div className="border-b p-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search notes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<div className="mt-2 flex gap-1">
<Button
variant={filterMode === 'all' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode('all')}
>
All
</Button>
<Button
variant={filterMode === 'pinned' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode('pinned')}
>
<Pin className="mr-1 h-3 w-3" />
Pinned
</Button>
<Button
variant={filterMode === 'archived' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode('archived')}
>
<Archive className="mr-1 h-3 w-3" />
Archived
</Button>
</div>
</div>
<ScrollArea className="max-h-80 lg:h-[calc(100vh-260px)] lg:max-h-none">
<div className="p-2">
{notes.length === 0 ? (
{filteredNotes.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">No notes yet</p>
<p className="text-sm text-muted-foreground">No notes found</p>
<Button className="mt-3" size="sm" onClick={createNote}>
<Plus className="mr-2 h-4 w-4" />
Create your first note
@@ -295,7 +376,7 @@ export default function NotesPage() {
</div>
) : (
<div className="space-y-1">
{notes.map((note) => (
{filteredNotes.map((note) => (
<button
key={note.id}
onClick={() => setSelectedNote(note)}
@@ -310,15 +391,25 @@ export default function NotesPage() {
<div className="flex items-start gap-2">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-medium">
{note.title}
</p>
{note.isPinned && <Pin className="h-3 w-3 shrink-0 text-amber-500" />}
{note.isArchived && <Archive className="h-3 w-3 shrink-0 text-muted-foreground" />}
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{new Date(note.updated).toLocaleDateString()}
{new Date(note.updatedAt).toLocaleDateString()}
</p>
<Badge variant="outline" className="mt-1 text-xs">
{domainOptions.find(d => d.id === note.domain)?.name || note.domain}
{note.tags && note.tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{note.tags.slice(0, 3).map((tag) => (
<Badge key={tag.id} variant="outline" className="text-[10px] px-1.5 py-0">
{tag.name}
</Badge>
))}
</div>
)}
</div>
</div>
</button>
@@ -344,16 +435,31 @@ export default function NotesPage() {
value={selectedNote.title}
onChange={(e) => {
const title = e.target.value;
setSelectedNote({
...selectedNote,
title,
});
setSelectedNote({ ...selectedNote, title });
scheduleSave(selectedNote.id, { title });
}}
className="min-w-0 flex-1 text-xl font-semibold outline-none"
placeholder="Note title"
/>
<span className="text-xs text-muted-foreground" role="status">{saveStatus}</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => togglePinned(selectedNote)}
aria-label={selectedNote.isPinned ? 'Unpin note' : 'Pin note'}
>
<Pin className={`h-4 w-4 ${selectedNote.isPinned ? 'fill-amber-500 text-amber-500' : ''}`} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => toggleArchived(selectedNote)}
aria-label={selectedNote.isArchived ? 'Restore note' : 'Archive note'}
>
{selectedNote.isArchived ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
</Button>
<AlertDialog open={noteToDelete?.id === selectedNote.id} onOpenChange={(open) => !open && setNoteToDelete(null)}>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" aria-label={`Delete ${selectedNote.title}`} onClick={() => setNoteToDelete(selectedNote)}>
@@ -378,8 +484,11 @@ export default function NotesPage() {
}
>
<NoteEditor
content={selectedNote.content}
onChange={(content) => { setSelectedNote({ ...selectedNote, content }); scheduleSave(selectedNote.id, { content }); }}
content={selectedNote.content || ''}
onChange={(content) => {
setSelectedNote({ ...selectedNote, content });
scheduleSave(selectedNote.id, { content });
}}
/>
</Suspense>
</div>
@@ -393,26 +502,26 @@ export default function NotesPage() {
)}
</Card>
{/* Backlinks and graph */}
{/* Backlinks and outgoing links panel */}
<Card className="min-h-[360px] lg:h-[calc(100vh-200px)]">
<Tabs defaultValue="backlinks" className="h-full">
<div className="border-b p-2">
<TabsList className="w-full">
<TabsTrigger value="backlinks" className="flex-1 gap-2">
<Link2 className="h-3 w-3" aria-hidden="true" />
Backlinks
Backlinks ({backlinks.length})
</TabsTrigger>
<TabsTrigger value="graph" className="flex-1 gap-2">
<TabsTrigger value="outgoing" className="flex-1 gap-2">
<GitBranch className="h-3 w-3" aria-hidden="true" />
Graph
Links
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="backlinks" className="h-full overflow-auto p-4">
{backlinksLoading ? <p className="py-8 text-center text-sm text-muted-foreground" role="status">Loading backlinks...</p> : backlinksError ? <div className="py-8 text-center"><p className="text-sm text-muted-foreground" role="alert">{backlinksError}</p><Button className="mt-3" size="sm" onClick={() => selectedNote && fetchBacklinks(selectedNote.id)}>Retry</Button></div> : backlinks.length === 0 ? (
{backlinks.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No backlinks
No backlinks no other notes link to this one
</p>
) : (
<div className="space-y-2">
@@ -425,28 +534,65 @@ export default function NotesPage() {
>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<span className="text-sm font-medium">
{link.title}
</span>
<span className="text-sm font-medium">{link.title}</span>
</div>
{link.excerpt && (
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{link.excerpt}</p>
)}
</button>
))}
</div>
)}
</TabsContent>
<TabsContent value="graph" className="h-full p-4">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">
Loading graph...
</div>
</div>
}
<TabsContent value="outgoing" className="h-full overflow-auto p-4">
{!outgoingLinks ? (
<p className="py-8 text-center text-sm text-muted-foreground">Loading...</p>
) : outgoingLinks.noteLinks.length === 0 && outgoingLinks.entityLinks.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No outgoing links use [[Title]] to link to other notes
</p>
) : (
<div className="space-y-3">
{outgoingLinks.noteLinks.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">Notes</p>
{outgoingLinks.noteLinks.map((link) => (
<button
key={link.id}
onClick={() => openBacklink({ id: link.id, title: link.title, excerpt: '' })}
className="w-full rounded-lg border p-2 text-left text-sm transition-colors hover:bg-accent"
>
<NoteGraph notes={notes} />
</Suspense>
<FileText className="mr-2 inline h-3 w-3 text-muted-foreground" />
{link.title}
</button>
))}
</div>
)}
{outgoingLinks.entityLinks.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">Entities</p>
{outgoingLinks.entityLinks.map((link, i) => (
<div key={`${link.entityType}-${link.entityId}-${i}`} className="rounded-lg border p-2 text-sm">
<span
className="mr-1.5 inline-block h-2 w-2 rounded-full"
style={{
backgroundColor:
link.entityType === 'task' ? '#3b82f6' :
link.entityType === 'habit' ? '#10b981' :
link.entityType === 'project' ? '#8b5cf6' :
link.entityType === 'section' ? '#ec4899' :
link.entityType === 'tag' ? '#6b7280' : '#f59e0b',
}}
/>
<span className="text-xs text-muted-foreground">{link.entityType}:</span>{' '}
{link.title || link.entityId.slice(0, 8)}
</div>
))}
</div>
)}
</div>
)}
</TabsContent>
</Tabs>
</Card>
@@ -0,0 +1,24 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
import { getGraphData } from '@/lib/graph-service';
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/graph — Get graph data for one domain
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const graphData = await getGraphData(domainId);
return NextResponse.json(graphData, {
headers: {
'Cache-Control': 'private, max-age=30, stale-while-revalidate=120',
},
});
});
@@ -0,0 +1,23 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
import { getBacklinks } from '@/lib/note-link-service';
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// GET /api/domains/[domainId]/notes/[id]/backlinks — List notes that link to this one
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const backlinks = await getBacklinks(id);
return NextResponse.json({
items: backlinks,
totalItems: backlinks.length,
});
});
@@ -0,0 +1,147 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, notes, noteTags, tags as tagsTable } from '@project-e/db';
import { and, eq, inArray, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { syncNoteLinks, getBacklinks, getOutgoingLinks } from '@/lib/note-link-service';
const updateNoteSchema = z.object({
title: z.string().min(1).optional(),
content: z.string().optional().nullable(),
isPinned: z.boolean().optional(),
isArchived: z.boolean().optional(),
});
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// GET /api/domains/[domainId]/notes/[id] — Get a single note with computed links
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [note] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
.limit(1);
if (!note) {
return createErrorResponse('NOT_FOUND', 'Note not found', 404);
}
// Fetch tags
const tagRows = await db.select({
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
})
.from(noteTags)
.innerJoin(tagsTable, eq(noteTags.tagId, tagsTable.id))
.where(eq(noteTags.noteId, id));
// Fetch backlinks and outgoing links
const [backlinks, outgoingLinks] = await Promise.all([
getBacklinks(id),
getOutgoingLinks(id),
]);
return NextResponse.json({
...note,
tags: tagRows,
backlinks,
outgoingLinks,
});
});
// PATCH /api/domains/[domainId]/notes/[id] — Update a note, re-parse wikilinks
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = updateNoteSchema.parse(body);
const [existing] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Note not found', 404);
}
const updateValues: Record<string, unknown> = {};
if (data.title !== undefined) updateValues.title = data.title;
if (data.content !== undefined) updateValues.content = data.content;
if (data.isPinned !== undefined) updateValues.isPinned = data.isPinned;
if (data.isArchived !== undefined) updateValues.isArchived = data.isArchived;
updateValues.updatedAt = new Date();
const [updated] = await db.update(notes)
.set(updateValues)
.where(eq(notes.id, id))
.returning();
// Re-sync wikilinks if content changed
const content = data.content ?? existing.content;
if (content) {
await syncNoteLinks(id, content);
}
await recordActivity({
actor: user.name,
action: 'updated',
entityType: 'note',
entityId: id,
changes: { ...data, previousTitle: existing.title },
workspaceId: domainId,
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[notes PATCH] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to update note', 500);
}
});
// DELETE /api/domains/[domainId]/notes/[id] — Soft delete a note
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [existing] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Note not found', 404);
}
await db.update(notes)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(notes.id, id));
await recordActivity({
actor: user.name,
action: 'deleted',
entityType: 'note',
entityId: id,
changes: { title: existing.title },
workspaceId: domainId,
});
return new NextResponse(null, { status: 204 });
});
@@ -0,0 +1,123 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, notes, noteTags, tags as tagsTable } from '@project-e/db';
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
const tagActionSchema = z.object({
tagId: z.string().uuid(),
});
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// POST /api/domains/[domainId]/notes/[id]/tags — Add a tag to a note
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = tagActionSchema.parse(body);
// Verify note exists
const [note] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
.limit(1);
if (!note) {
return createErrorResponse('NOT_FOUND', 'Note not found', 404);
}
// Verify tag exists
const [tag] = await db.select()
.from(tagsTable)
.where(eq(tagsTable.id, data.tagId))
.limit(1);
if (!tag) {
return createErrorResponse('NOT_FOUND', 'Tag not found', 404);
}
// Check if already tagged
const [existing] = await db.select()
.from(noteTags)
.where(and(eq(noteTags.noteId, id), eq(noteTags.tagId, data.tagId)))
.limit(1);
if (existing) {
return createErrorResponse('CONFLICT', 'Tag already added to this note', 409);
}
await db.insert(noteTags).values({ noteId: id, tagId: data.tagId });
await recordActivity({
actor: user.name,
action: 'tag_added',
entityType: 'note',
entityId: id,
changes: { tagId: data.tagId, tagName: tag.name },
workspaceId: domainId,
});
return NextResponse.json({ success: true }, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[note tags POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to add tag', 500);
}
});
// DELETE /api/domains/[domainId]/notes/[id]/tags — Remove a tag from a note
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = tagActionSchema.parse(body);
const [existing] = await db.select()
.from(noteTags)
.where(and(eq(noteTags.noteId, id), eq(noteTags.tagId, data.tagId)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Tag not found on this note', 404);
}
await db.delete(noteTags)
.where(and(eq(noteTags.noteId, id), eq(noteTags.tagId, data.tagId)));
await recordActivity({
actor: user.name,
action: 'tag_removed',
entityType: 'note',
entityId: id,
changes: { tagId: data.tagId },
workspaceId: domainId,
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[note tags DELETE] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to remove tag', 500);
}
});
@@ -0,0 +1,154 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, notes, noteTags, tags as tagsTable } from '@project-e/db';
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
import { z } from 'zod';
import { syncNoteLinks } from '@/lib/note-link-service';
const createNoteSchema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().optional().nullable(),
isPinned: z.boolean().optional().default(false),
isArchived: z.boolean().optional().default(false),
tagIds: z.array(z.string().uuid()).optional(),
});
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/notes — List notes with filtering
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const { searchParams } = new URL(request.url);
const pinned = searchParams.get('pinned');
const archived = searchParams.get('archived');
const search = searchParams.get('search');
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
const offset = parseInt(searchParams.get('offset') || '0');
const sort = searchParams.get('sort') || 'updated_at';
const order = searchParams.get('order') || 'desc';
const conditions: any[] = [
eq(notes.domainId, domainId),
isNull(notes.deletedAt),
];
if (pinned === 'true') conditions.push(eq(notes.isPinned, true));
if (archived === 'true') conditions.push(eq(notes.isArchived, true));
else if (archived !== 'all') conditions.push(eq(notes.isArchived, false));
if (search) conditions.push(ilike(notes.title, `%${search}%`));
const orderFn = order === 'desc' ? desc : asc;
let orderColumn;
switch (sort) {
case 'title': orderColumn = orderFn(notes.title); break;
case 'created_at': orderColumn = orderFn(notes.createdAt); break;
case 'is_pinned': orderColumn = orderFn(notes.isPinned); break;
default: orderColumn = orderFn(notes.updatedAt); break;
}
const [items, countResult] = await Promise.all([
db.select()
.from(notes)
.where(and(...conditions))
.orderBy(orderColumn)
.limit(limit)
.offset(offset),
db.select({ count: sql<number>`count(*)` })
.from(notes)
.where(and(...conditions)),
]);
const totalItems = Number(countResult[0]?.count || 0);
// Fetch tags for all notes
let noteTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
if (items.length > 0) {
const noteIds = items.map(n => n.id);
const tagRows = await db.select({
noteId: noteTags.noteId,
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
})
.from(noteTags)
.innerJoin(tagsTable, eq(noteTags.tagId, tagsTable.id))
.where(inArray(noteTags.noteId, noteIds));
for (const row of tagRows) {
if (!noteTagMap.has(row.noteId)) noteTagMap.set(row.noteId, []);
noteTagMap.get(row.noteId)!.push({ id: row.id, name: row.name, color: row.color });
}
}
const itemsWithTags = items.map(n => ({
...n,
tags: noteTagMap.get(n.id) || [],
}));
return NextResponse.json({
items: itemsWithTags,
totalItems,
limit,
offset,
});
});
// POST /api/domains/[domainId]/notes — Create a note
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = createNoteSchema.parse(body);
const [note] = await db.insert(notes).values({
title: data.title,
content: data.content ?? null,
domainId,
isPinned: data.isPinned,
isArchived: data.isArchived,
}).returning();
// Insert tags if provided
if (data.tagIds && data.tagIds.length > 0) {
await db.insert(noteTags).values(
data.tagIds.map(tagId => ({ noteId: note.id, tagId }))
);
}
// Sync wikilinks from content
if (data.content) {
await syncNoteLinks(note.id, data.content);
}
// Record activity
await recordActivity({
actor: user.name,
action: 'created',
entityType: 'note',
entityId: note.id,
changes: { title: note.title },
workspaceId: domainId,
});
return NextResponse.json(note, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[notes POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to create note', 500);
}
});
+19
View File
@@ -0,0 +1,19 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/auth';
import { getGlobalGraphData } from '@/lib/graph-service';
// GET /api/graph — Get global graph data (all domains the user has access to)
export const GET = withAuth(async (request: NextRequest, user) => {
const graphData = await getGlobalGraphData();
return NextResponse.json(graphData, {
headers: {
'Cache-Control': 'private, max-age=30, stale-while-revalidate=120',
},
});
});
+6
View File
@@ -43,6 +43,7 @@ export function useKeyboardShortcuts() {
if (key === 'h') { router.push('/habits'); e.preventDefault(); return; }
if (key === 'p') { router.push('/projects'); e.preventDefault(); return; }
if (key === 'n') { router.push('/notes'); e.preventDefault(); return; }
if (key === 'g') { router.push('/graph'); e.preventDefault(); return; }
if (key === 'r') { router.push('/reports'); e.preventDefault(); return; }
if (key === 'c') { router.push('/calendar'); e.preventDefault(); return; }
if (key === 'a') { router.push('/analytics'); e.preventDefault(); return; }
@@ -83,6 +84,11 @@ export function useKeyboardShortcuts() {
document.dispatchEvent(new CustomEvent('open-create-project'));
e.preventDefault();
}
// c n — new note
if (window.location.pathname.startsWith('/notes')) {
document.dispatchEvent(new CustomEvent('open-create-note'));
e.preventDefault();
}
// c s — new section (on project detail page)
if (window.location.pathname.match(/^\/projects\/[^/]+$/)) {
document.dispatchEvent(new CustomEvent('open-create-section'));
+200
View File
@@ -0,0 +1,200 @@
/**
* Graph Service
*
* Builds graph data (nodes + edges) for the D3 force-directed graph view.
* Includes all entity types: task, habit, project, note, section, tag.
* Edges come from: note_links, note_entity_links, task dependencies,
* task→project, task→section, task→domain, habit→domain, project→domain.
*/
import { db, notes, noteLinks, noteEntityLinks, tasks, taskDependencies, habits, projects, sections, tags as tagsTable, domains } from '@project-e/db';
import { and, eq, inArray, isNull, or } from 'drizzle-orm';
export interface GraphNode {
id: string;
label: string;
type: 'task' | 'habit' | 'project' | 'note' | 'section' | 'tag' | 'domain';
color: string;
}
export interface GraphEdge {
source: string;
target: string;
type: string;
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}
const ENTITY_COLORS: Record<string, string> = {
task: '#3b82f6', // blue
habit: '#10b981', // green
project: '#8b5cf6', // purple
note: '#f59e0b', // amber
section: '#ec4899', // pink
tag: '#6b7280', // gray
domain: '#6366f1', // indigo
};
/**
* Get graph data for a single domain.
*/
export async function getGraphData(domainId: string): Promise<GraphData> {
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const nodeIds = new Set<string>();
function addNode(id: string, label: string, type: GraphNode['type']) {
if (!nodeIds.has(id)) {
nodeIds.add(id);
nodes.push({ id, label, type, color: ENTITY_COLORS[type] || '#6b7280' });
}
}
function addEdge(source: string, target: string, type: string) {
if (source !== target) {
edges.push({ source, target, type });
}
}
// Fetch all entities in this domain
const [noteRows, taskRows, habitRows, projectRows, sectionRows, tagRows, domainRows] = await Promise.all([
db.select({ id: notes.id, title: notes.title }).from(notes).where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt))),
db.select({ id: tasks.id, title: tasks.title, projectId: tasks.projectId }).from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt))),
db.select({ id: habits.id, name: habits.name }).from(habits).where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))),
db.select({ id: projects.id, name: projects.name }).from(projects).where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt))),
db.select({ id: sections.id, name: sections.name, projectId: sections.projectId }).from(sections).where(eq(sections.projectId, inArray(sections.projectId, (await db.select({ id: projects.id }).from(projects).where(eq(projects.domainId, domainId))).map(p => p.id)))),
db.select({ id: tagsTable.id, name: tagsTable.name }).from(tagsTable),
db.select({ id: domains.id, name: domains.name }).from(domains).where(eq(domains.id, domainId)),
]);
// Add domain node
for (const d of domainRows) {
addNode(d.id, d.name, 'domain');
}
// Add note nodes
for (const n of noteRows) {
addNode(n.id, n.title, 'note');
}
// Add task nodes
for (const t of taskRows) {
addNode(t.id, t.title, 'task');
}
// Add habit nodes
for (const h of habitRows) {
addNode(h.id, h.name, 'habit');
}
// Add project nodes
for (const p of projectRows) {
addNode(p.id, p.name, 'project');
}
// Add section nodes
for (const s of sectionRows) {
addNode(s.id, s.name, 'section');
}
// Add tag nodes
for (const t of tagRows) {
addNode(t.id, t.name, 'tag');
}
// --- Edges ---
// Note-to-note links
const noteIds = noteRows.map(n => n.id);
if (noteIds.length > 0) {
const linkRows = await db.select()
.from(noteLinks)
.where(inArray(noteLinks.sourceNoteId, noteIds));
for (const l of linkRows) {
addEdge(l.sourceNoteId, l.targetNoteId, 'note_link');
}
}
// Note-to-entity links
if (noteIds.length > 0) {
const entityLinkRows = await db.select()
.from(noteEntityLinks)
.where(inArray(noteEntityLinks.noteId, noteIds));
for (const l of entityLinkRows) {
addEdge(l.noteId, l.entityId, `note_${l.entityType}`);
}
}
// Task dependencies
const taskIds = taskRows.map(t => t.id);
if (taskIds.length > 0) {
const depRows = await db.select()
.from(taskDependencies)
.where(inArray(taskDependencies.taskId, taskIds));
for (const d of depRows) {
addEdge(d.taskId, d.dependsOnTaskId, 'depends_on');
}
}
// Task → project
for (const t of taskRows) {
if (t.projectId) {
addEdge(t.id, t.projectId, 'task_project');
}
}
// Task → domain
for (const t of taskRows) {
addEdge(t.id, domainId, 'task_domain');
}
// Habit → domain
for (const h of habitRows) {
addEdge(h.id, domainId, 'habit_domain');
}
// Project → domain
for (const p of projectRows) {
addEdge(p.id, domainId, 'project_domain');
}
// Note → domain
for (const n of noteRows) {
addEdge(n.id, domainId, 'note_domain');
}
// Section → project
for (const s of sectionRows) {
if (s.projectId) {
addEdge(s.id, s.projectId, 'section_project');
}
}
return { nodes, edges };
}
/**
* Get global graph data (all domains).
*/
export async function getGlobalGraphData(): Promise<GraphData> {
const allDomains = await db.select({ id: domains.id }).from(domains);
const allNodes: GraphNode[] = [];
const allEdges: GraphEdge[] = [];
const seenNodeIds = new Set<string>();
for (const d of allDomains) {
const domainGraph = await getGraphData(d.id);
for (const node of domainGraph.nodes) {
if (!seenNodeIds.has(node.id)) {
seenNodeIds.add(node.id);
allNodes.push(node);
}
}
allEdges.push(...domainGraph.edges);
}
return { nodes: allNodes, edges: allEdges };
}
+274
View File
@@ -0,0 +1,274 @@
/**
* Note Link Service
*
* Handles wikilink resolution and note_links / note_entity_links management.
* On note save, parses content for [[wikilinks]], resolves each to a note_id or entity_id,
* and diffs the existing links to produce idempotent deletes+inserts.
*/
import { db, noteLinks, noteEntityLinks, notes, tasks, habits, projects, sections, tags as tagsTable } from '@project-e/db';
import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm';
import { extractLinkTargets } from './wikilink-parser';
/**
* Resolve a single link target to its entity ID.
* Returns null if no match found.
*/
async function resolveTarget(entityType: string, title: string): Promise<{ entityId: string; entityType: string } | null> {
const trimmedTitle = title.trim();
if (!entityType) {
// Resolve as a note
const [note] = await db
.select({ id: notes.id })
.from(notes)
.where(and(eq(notes.title, trimmedTitle), isNull(notes.deletedAt)))
.limit(1);
if (note) return { entityId: note.id, entityType: 'note' };
return null;
}
switch (entityType) {
case 'note': {
const [note] = await db
.select({ id: notes.id })
.from(notes)
.where(and(eq(notes.title, trimmedTitle), isNull(notes.deletedAt)))
.limit(1);
if (note) return { entityId: note.id, entityType: 'note' };
return null;
}
case 'task': {
const [task] = await db
.select({ id: tasks.id })
.from(tasks)
.where(and(eq(tasks.title, trimmedTitle), isNull(tasks.deletedAt)))
.limit(1);
if (task) return { entityId: task.id, entityType: 'task' };
return null;
}
case 'habit': {
const [habit] = await db
.select({ id: habits.id })
.from(habits)
.where(and(eq(habits.name, trimmedTitle), isNull(habits.deletedAt)))
.limit(1);
if (habit) return { entityId: habit.id, entityType: 'habit' };
return null;
}
case 'project': {
const [project] = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.name, trimmedTitle), isNull(projects.deletedAt)))
.limit(1);
if (project) return { entityId: project.id, entityType: 'project' };
return null;
}
case 'section': {
const [section] = await db
.select({ id: sections.id })
.from(sections)
.where(eq(sections.name, trimmedTitle))
.limit(1);
if (section) return { entityId: section.id, entityType: 'section' };
return null;
}
case 'tag': {
const [tag] = await db
.select({ id: tagsTable.id })
.from(tagsTable)
.where(eq(tagsTable.name, trimmedTitle))
.limit(1);
if (tag) return { entityId: tag.id, entityType: 'tag' };
return null;
}
default:
return null;
}
}
/**
* Sync wikilinks for a note: parse content, resolve targets, diff existing links.
* Idempotent — deletes stale links, inserts new ones.
*/
export async function syncNoteLinks(noteId: string, content: string): Promise<void> {
const targets = extractLinkTargets(content);
// Resolve all targets to entity IDs
const resolvedTargets: { entityType: string; entityId: string }[] = [];
for (const target of targets) {
const resolved = await resolveTarget(target.entityType, target.title);
if (resolved) {
resolvedTargets.push(resolved);
}
}
// Separate into note-to-note links and entity links
const noteToNoteLinks = resolvedTargets.filter(t => t.entityType === 'note');
const entityLinks = resolvedTargets.filter(t => t.entityType !== 'note');
// --- Sync note_links ---
const existingNoteLinks = await db
.select({ targetNoteId: noteLinks.targetNoteId })
.from(noteLinks)
.where(eq(noteLinks.sourceNoteId, noteId));
const existingTargetIds = new Set(existingNoteLinks.map(l => l.targetNoteId));
const newTargetIds = new Set(noteToNoteLinks.map(l => l.entityId));
// Delete stale links
const staleTargetIds = [...existingTargetIds].filter(id => !newTargetIds.has(id));
if (staleTargetIds.length > 0) {
await db
.delete(noteLinks)
.where(and(
eq(noteLinks.sourceNoteId, noteId),
inArray(noteLinks.targetNoteId, staleTargetIds),
));
}
// Insert new links
const missingTargetIds = [...newTargetIds].filter(id => !existingTargetIds.has(id));
if (missingTargetIds.length > 0) {
await db.insert(noteLinks).values(
missingTargetIds.map(targetNoteId => ({ sourceNoteId: noteId, targetNoteId }))
);
}
// --- Sync note_entity_links ---
const existingEntityLinks = await db
.select({ entityType: noteEntityLinks.entityType, entityId: noteEntityLinks.entityId })
.from(noteEntityLinks)
.where(eq(noteEntityLinks.noteId, noteId));
const existingEntityKeySet = new Set(existingEntityLinks.map(l => `${l.entityType}:${l.entityId}`));
const newEntityKeySet = new Set(entityLinks.map(l => `${l.entityType}:${l.entityId}`));
// Delete stale entity links
const staleEntityLinks = existingEntityLinks.filter(l => !newEntityKeySet.has(`${l.entityType}:${l.entityId}`));
for (const link of staleEntityLinks) {
await db
.delete(noteEntityLinks)
.where(and(
eq(noteEntityLinks.noteId, noteId),
eq(noteEntityLinks.entityType, link.entityType),
eq(noteEntityLinks.entityId, link.entityId),
));
}
// Insert new entity links
const missingEntityLinks = entityLinks.filter(l => !existingEntityKeySet.has(`${l.entityType}:${l.entityId}`));
if (missingEntityLinks.length > 0) {
await db.insert(noteEntityLinks).values(
missingEntityLinks.map(l => ({ noteId, entityType: l.entityType, entityId: l.entityId }))
);
}
}
/**
* Get backlinks for a note — notes that link to this note.
*/
export async function getBacklinks(noteId: string): Promise<{ id: string; title: string; excerpt: string }[]> {
const rows = await db
.select({
id: notes.id,
title: notes.title,
content: notes.content,
})
.from(noteLinks)
.innerJoin(notes, eq(noteLinks.sourceNoteId, notes.id))
.where(and(
eq(noteLinks.targetNoteId, noteId),
isNull(notes.deletedAt),
));
return rows.map(row => ({
id: row.id,
title: row.title,
excerpt: extractExcerpt(row.content || '', noteId),
}));
}
/**
* Extract a short excerpt around the first mention of a note title in content.
*/
function extractExcerpt(content: string, noteId: string): string {
// Try to find [[Title]] pattern
const linkMatch = content.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/);
if (linkMatch) {
const idx = content.indexOf(linkMatch[0]);
const start = Math.max(0, idx - 40);
const end = Math.min(content.length, idx + linkMatch[0].length + 40);
let excerpt = content.slice(start, end).replace(/\n/g, ' ');
if (start > 0) excerpt = '...' + excerpt;
if (end < content.length) excerpt = excerpt + '...';
return excerpt;
}
// Fallback: first 100 chars
return content.slice(0, 100).replace(/\n/g, ' ') + (content.length > 100 ? '...' : '');
}
/**
* Get all outgoing links for a note.
*/
export async function getOutgoingLinks(noteId: string): Promise<{
noteLinks: { id: string; title: string }[];
entityLinks: { entityType: string; entityId: string; title: string | null }[];
}> {
// Note-to-note links
const noteLinkRows = await db
.select({ id: notes.id, title: notes.title })
.from(noteLinks)
.innerJoin(notes, eq(noteLinks.targetNoteId, notes.id))
.where(and(
eq(noteLinks.sourceNoteId, noteId),
isNull(notes.deletedAt),
));
// Entity links
const entityLinkRows = await db
.select({ entityType: noteEntityLinks.entityType, entityId: noteEntityLinks.entityId })
.from(noteEntityLinks)
.where(eq(noteEntityLinks.noteId, noteId));
// Resolve entity titles
const entityLinksWithTitles: { entityType: string; entityId: string; title: string | null }[] = [];
for (const link of entityLinkRows) {
let title: string | null = null;
switch (link.entityType) {
case 'task': {
const [t] = await db.select({ title: tasks.title }).from(tasks).where(eq(tasks.id, link.entityId)).limit(1);
title = t?.title ?? null;
break;
}
case 'habit': {
const [h] = await db.select({ name: habits.name }).from(habits).where(eq(habits.id, link.entityId)).limit(1);
title = h?.name ?? null;
break;
}
case 'project': {
const [p] = await db.select({ name: projects.name }).from(projects).where(eq(projects.id, link.entityId)).limit(1);
title = p?.name ?? null;
break;
}
case 'section': {
const [s] = await db.select({ name: sections.name }).from(sections).where(eq(sections.id, link.entityId)).limit(1);
title = s?.name ?? null;
break;
}
case 'tag': {
const [t] = await db.select({ name: tagsTable.name }).from(tagsTable).where(eq(tagsTable.id, link.entityId)).limit(1);
title = t?.name ?? null;
break;
}
}
entityLinksWithTitles.push({ entityType: link.entityType, entityId: link.entityId, title });
}
return {
noteLinks: noteLinkRows,
entityLinks: entityLinksWithTitles,
};
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Wikilink Parser
*
* Parses note content for [[wikilink]] patterns:
* - [[Title]] — links to a note by title
* - [[Title|Display]] — links to a note with custom display text
* - [[entity_type:Title]] — cross-entity links (e.g. [[task:Buy milk]], [[habit:Exercise]])
*
* Supported entity types: task, habit, project, note, section, tag
*/
export interface WikilinkMatch {
/** The full matched text including brackets, e.g. "[[Buy milk]]" */
raw: string;
/** Entity type prefix (empty for note links), e.g. "task", "habit" */
entityType: string;
/** The target title (after entity type prefix), e.g. "Buy milk" */
title: string;
/** Optional display text (after | separator), e.g. "Buy milk" */
displayText: string | null;
}
/**
* Regex for matching wikilink patterns:
* [[Title]] or [[Title|Display]] or [[entity_type:Title]] or [[entity_type:Title|Display]]
*
* Group 1: optional entity_type + colon (e.g. "task:")
* Group 2: the title portion
* Group 3: optional |display text
*/
const WIKILINK_REGEX = /\[\[(?:([a-zA-Z_]+):)?([^\]|]+)(?:\|([^\]]+))?\]\]/g;
/**
* Parse wikilinks from note content.
* Returns an array of all wikilink matches found.
*/
export function parseWikilinks(content: string): WikilinkMatch[] {
const matches: WikilinkMatch[] = [];
let match: RegExpExecArray | null;
while ((match = WIKILINK_REGEX.exec(content)) !== null) {
const entityType = (match[1] || '').toLowerCase();
const title = match[2].trim();
const displayText = match[3]?.trim() || null;
matches.push({
raw: match[0],
entityType,
title,
displayText,
});
}
return matches;
}
/**
* Extract unique link targets from content.
* Returns deduplicated list of { entityType, title } pairs.
*/
export function extractLinkTargets(content: string): { entityType: string; title: string }[] {
const seen = new Set<string>();
const targets: { entityType: string; title: string }[] = [];
for (const match of parseWikilinks(content)) {
const key = `${match.entityType}:${match.title}`;
if (!seen.has(key)) {
seen.add(key);
targets.push({ entityType: match.entityType, title: match.title });
}
}
return targets;
}
/**
* Resolve a wikilink target to a display-friendly string.
* For note links: returns the title.
* For entity links: returns "entity_type: Title".
*/
export function formatWikilinkDisplay(match: WikilinkMatch): string {
if (match.displayText) return match.displayText;
if (match.entityType) return `${match.entityType}: ${match.title}`;
return match.title;
}
/**
* Check if content contains any wikilinks.
*/
export function hasWikilinks(content: string): boolean {
return WIKILINK_REGEX.test(content);
}
File diff suppressed because one or more lines are too long