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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user