diff --git a/apps/api/src/routes/notes.ts b/apps/api/src/routes/notes.ts index ac481fb..221b7f5 100644 --- a/apps/api/src/routes/notes.ts +++ b/apps/api/src/routes/notes.ts @@ -6,6 +6,7 @@ import { ProviderFactory } from 'llm'; import type { TokenTrackingService } from '../services/token-tracking-service.js'; import { asyncHandler } from '../utils/async-handler.js'; import { validateBody, ApiError } from '../middleware/index.js'; +import { slateJsonToPlainText } from '../utils/slateUtils.js'; import { NotesService } from '../services/notes-service.js'; import { createLogger } from '../utils/logger.js'; @@ -48,9 +49,10 @@ export function createNotesRouter(db: Database, notesRepo: OrganizedNotesReposit validateBody(createOrganizedNoteSchema), asyncHandler(async (req, res) => { const userId = 'test-user-1'; // TODO: Get from auth context - const { title, content, tags, date } = req.body; + const { title, content, contentFormat, tags, date } = req.body; + const contentPlain = contentFormat === 'slate_json' ? slateJsonToPlainText(content) : null; - const note = await notesRepo.create({ userId, title, content, tags, date }); + const note = await notesRepo.create({ userId, title, content, contentFormat, contentPlain, tags, date }); res.status(201).json(note); }) ); @@ -91,9 +93,15 @@ export function createNotesRouter(db: Database, notesRepo: OrganizedNotesReposit validateBody(updateOrganizedNoteSchema), asyncHandler(async (req, res) => { const { id } = req.params; - const { title, content, tags } = req.body; - - const note = await notesRepo.update(id, { title, content, tags }); + const { title, content, contentFormat, tags } = req.body; + const contentPlain = + content !== undefined + ? contentFormat === 'slate_json' + ? slateJsonToPlainText(content) + : null + : undefined; + + const note = await notesRepo.update(id, { title, content, contentFormat, contentPlain, tags }); if (!note) { throw new ApiError(404, 'Note not found'); } diff --git a/apps/api/src/utils/slateUtils.ts b/apps/api/src/utils/slateUtils.ts new file mode 100644 index 0000000..a66ae59 --- /dev/null +++ b/apps/api/src/utils/slateUtils.ts @@ -0,0 +1,27 @@ +/** + * Extracts plain text from a serialized Slate JSON document string. + * Used to populate content_plain for full-text search indexing, + * avoiding noise from JSON structural keywords in the tsvector. + */ +export function slateJsonToPlainText(content: string): string { + try { + const doc = JSON.parse(content); + if (!Array.isArray(doc)) return ''; + return extractText(doc); + } catch { + return ''; + } +} + +function extractText(nodes: unknown[]): string { + return nodes + .map((node) => { + if (typeof node !== 'object' || node === null) return ''; + const n = node as Record; + if (typeof n.text === 'string') return n.text; + if (Array.isArray(n.children)) return extractText(n.children); + return ''; + }) + .filter(Boolean) + .join(' '); +} diff --git a/apps/web/package.json b/apps/web/package.json index c2552c7..f813515 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,9 @@ "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", "remark-gfm": "^4.0.1", + "slate": "^0.123.0", + "slate-history": "^0.113.1", + "slate-react": "^0.123.0", "types": "workspace:*" }, "devDependencies": { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index e96e51a..7b10cbc 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -62,11 +62,11 @@ export const notesAPI = { list: (tag?: string) => fetchAPI(`/notes${tag ? `?tag=${tag}` : ''}`), get: (id: string) => fetchAPI(`/notes/${id}`), - create: (data: { title: string; content: string; tags?: string[] }) => + create: (data: { title: string; content: string; contentFormat?: 'markdown' | 'slate_json'; tags?: string[] }) => fetchAPI('/notes', { method: 'POST', body: JSON.stringify(data) }), append: (data: {title: string, contentToAppend: string}) => fetchAPI('/notes/append', { method: 'POST', body: JSON.stringify(data) }), - update: (id: string, data: { title?: string; content?: string; tags?: string[] }) => + update: (id: string, data: { title?: string; content?: string; contentFormat?: 'markdown' | 'slate_json'; tags?: string[] }) => fetchAPI(`/notes/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), delete: (id: string) => fetchAPI(`/notes/${id}`, { method: 'DELETE' }), refine: (id: string, prompt: string) => diff --git a/apps/web/src/components/NoteForm.tsx b/apps/web/src/components/NoteForm.tsx index d9c2459..087b715 100644 --- a/apps/web/src/components/NoteForm.tsx +++ b/apps/web/src/components/NoteForm.tsx @@ -1,11 +1,26 @@ import { useState } from 'react'; +import type { Descendant } from 'slate'; import TagEditor from './TagEditor'; +import { SlateEditor } from './editor/SlateEditor'; +import { + EMPTY_SLATE_DOCUMENT, + deserializeFromString, + serializeToString, + slateToPlainText, +} from './editor/slateSerializer'; interface NoteFormProps { initialTitle?: string; initialContent?: string; + initialSlateValue?: Descendant[]; + initialContentFormat?: 'markdown' | 'slate_json'; initialTags?: string[]; - onSubmit: (data: { title: string; content: string; tags?: string[] }) => Promise; + onSubmit: (data: { + title: string; + content: string; + contentFormat: 'markdown' | 'slate_json'; + tags?: string[]; + }) => Promise; onCancel: () => void; submitLabel?: string; } @@ -13,20 +28,39 @@ interface NoteFormProps { export default function NoteForm({ initialTitle = '', initialContent = '', + initialSlateValue, + initialContentFormat = 'slate_json', initialTags = [], onSubmit, onCancel, submitLabel = 'Save Note', }: NoteFormProps) { const [title, setTitle] = useState(initialTitle); - const [content, setContent] = useState(initialContent); const [tags, setTags] = useState(initialTags); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + // Resolve initial editor value: + // 1. If a parsed Slate document was provided, use it directly + // 2. If existing content is slate_json, deserialize it + // 3. If existing content is markdown (or plain text), wrap in a paragraph + // 4. Otherwise, start empty + const [editorValue, setEditorValue] = useState(() => { + if (initialSlateValue) return initialSlateValue; + if (initialContentFormat === 'slate_json' && initialContent) { + return deserializeFromString(initialContent); + } + if (initialContent) { + // Markdown note being opened for editing — load as plain paragraph + return [{ type: 'paragraph', children: [{ text: initialContent }] }]; + } + return EMPTY_SLATE_DOCUMENT; + }); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!title.trim() || !content.trim()) return; + const plainText = slateToPlainText(editorValue).trim(); + if (!title.trim() || !plainText) return; setIsSubmitting(true); setError(null); @@ -34,7 +68,8 @@ export default function NoteForm({ try { await onSubmit({ title: title.trim(), - content: content.trim(), + content: serializeToString(editorValue), + contentFormat: 'slate_json', tags: tags.length > 0 ? tags : undefined, }); } catch (err) { @@ -43,6 +78,8 @@ export default function NoteForm({ } }; + const isEmpty = slateToPlainText(editorValue).trim().length === 0; + return (
{error && ( @@ -67,16 +104,11 @@ export default function NoteForm({
- -