Dev #7

Merged
sandrodach merged 33 commits from dev into main 2026-10-27 11:42:12 +01:00
17 changed files with 3299 additions and 7068 deletions
Showing only changes of commit 86867b0d6f - Show all commits

No files matched your search

+1 -3
View File
@@ -16,9 +16,7 @@
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1"
"y-indexeddb": "^9.0.12",
"yjs": "^13.6.33"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
@@ -0,0 +1,664 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import {
Pin,
Share2,
Trash2,
Download,
Folder as FolderIcon,
Wifi,
WifiOff,
Loader2,
ArrowLeft,
Hash,
Lock,
} from 'lucide-react';
import { Document, DocumentBlock } from '../../lib/types';
import { api } from '../../lib/api';
import { useDocumentSync, DocSyncEvent } from '../../lib/useDocumentSync';
import { OutlinerTree } from './OutlinerTree';
export interface DocumentEditorProps {
docId: string;
initialDoc?: Document | null;
availableFolders: string[];
onDocMetadataChange: (updated: Document) => void;
onDeleteDoc: (docId: string) => void;
onOpenShareModal: (doc: Document) => void;
onSelectTag: (tag: string) => void;
onBackMobile?: () => void;
}
const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒'];
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
if (!blocks || blocks.length === 0) {
return [
{
id: `${docId}-b0`,
documentId: docId,
type: 'text',
content: '',
level: 0,
position: 1000,
},
];
}
return blocks.map((b, idx) => ({
...b,
id: b.id || `${docId}-b${idx}`,
documentId: docId,
type: b.type || 'text',
level: Math.max(0, b.level || 0),
position: b.position || (idx + 1) * 1000,
}));
}
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
docId,
initialDoc,
availableFolders,
onDocMetadataChange,
onDeleteDoc,
onOpenShareModal,
onSelectTag,
onBackMobile,
}) => {
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || '');
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
ensureNonEmptyBlocks(docId, initialDoc?.blocks)
);
const [loading, setLoading] = useState<boolean>(!initialDoc);
const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [showFolderMenu, setShowFolderMenu] = useState<boolean>(false);
const titleInputRef = useRef<HTMLInputElement | null>(null);
const titleDebounceRef = useRef<number | null>(null);
const docRef = useRef<Document | null>(doc);
docRef.current = doc;
const canEdit = useMemo(() => {
if (!doc) return true;
return doc.isOwner !== false && doc.permission !== 'read';
}, [doc]);
// Handle incoming WebSocket events for THIS document room
const handleRemoteEvent = useCallback(
(evt: DocSyncEvent) => {
if (evt.docId && evt.docId !== docId) return;
switch (evt.type) {
case 'doc_state': {
if (evt.title !== undefined) {
const isTitleFocused =
titleInputRef.current !== null &&
document.activeElement === titleInputRef.current;
if (!isTitleFocused) {
setTitle(evt.title);
}
}
if (Array.isArray(evt.blocks) && evt.blocks.length > 0) {
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
}
break;
}
case 'title_updated': {
if (evt.title !== undefined) {
const isTitleFocused =
titleInputRef.current !== null &&
document.activeElement === titleInputRef.current;
if (!isTitleFocused) {
setTitle(evt.title);
}
if (docRef.current) {
const updated = { ...docRef.current, title: evt.title };
setDoc(updated);
onDocMetadataChange(updated);
}
}
break;
}
case 'block_created': {
if (!evt.block) return;
const incoming = evt.block;
setBlocks((prev) => {
if (prev.some((b) => b.id === incoming.id)) {
return prev.map((b) => (b.id === incoming.id ? incoming : b));
}
const next = [...prev];
if (evt.afterBlockId) {
const idx = next.findIndex((b) => b.id === evt.afterBlockId);
if (idx >= 0) {
next.splice(idx + 1, 0, incoming);
return ensureNonEmptyBlocks(docId, next);
}
}
next.push(incoming);
return ensureNonEmptyBlocks(docId, next);
});
break;
}
case 'block_updated': {
const targetId = evt.blockId || evt.block?.id;
if (!targetId) return;
setBlocks((prev) =>
prev.map((b) => {
if (b.id !== targetId) return b;
if (evt.block) {
return { ...b, ...evt.block };
}
return {
...b,
content: evt.content !== undefined ? evt.content : b.content,
level: evt.level !== undefined ? evt.level : b.level,
checked: evt.checked !== undefined ? evt.checked : b.checked,
collapsed: evt.collapsed !== undefined ? evt.collapsed : b.collapsed,
};
})
);
break;
}
case 'block_deleted': {
if (!evt.blockId) return;
setBlocks((prev) =>
ensureNonEmptyBlocks(
docId,
prev.filter((b) => b.id !== evt.blockId)
)
);
break;
}
case 'block_moved': {
if (!evt.blockId || evt.newIndex === undefined) return;
const targetIndex = evt.newIndex;
setBlocks((prev) => {
const fromIdx = prev.findIndex((b) => b.id === evt.blockId);
if (fromIdx === -1) return prev;
const copy = [...prev];
const [moving] = copy.splice(fromIdx, 1);
const updatedMoving: DocumentBlock = {
...moving,
level: evt.level !== undefined ? evt.level : moving.level,
};
const boundedIdx = Math.max(0, Math.min(targetIndex, copy.length));
copy.splice(boundedIdx, 0, updatedMoving);
return ensureNonEmptyBlocks(docId, copy);
});
break;
}
case 'blocks_replaced': {
if (Array.isArray(evt.blocks)) {
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
}
break;
}
}
},
[docId, onDocMetadataChange]
);
const {
status: syncStatus,
sendBlockCreated,
sendBlockUpdated,
sendBlockDeleted,
sendBlockMoved,
sendTitleUpdated,
} = useDocumentSync(docId, handleRemoteEvent, canEdit);
// Fetch isolated document state on mount
useEffect(() => {
let isCancelled = false;
setLoading(true);
setError(null);
api
.getDocument(docId)
.then((fetched) => {
if (isCancelled) return;
setDoc(fetched);
setTitle(fetched.title || 'Unbenannte Notiz');
setBlocks(ensureNonEmptyBlocks(docId, fetched.blocks));
setLoading(false);
onDocMetadataChange(fetched);
})
.catch((err: any) => {
if (isCancelled) return;
setError(err?.message || 'Dokument konnte nicht geladen werden');
setLoading(false);
});
return () => {
isCancelled = true;
if (titleDebounceRef.current !== null) {
window.clearTimeout(titleDebounceRef.current);
titleDebounceRef.current = null;
}
};
}, [docId]);
// Extract all #tags present across the current blocks
const extractedTags = useMemo(() => {
const tagSet = new Set<string>();
for (const b of blocks) {
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
if (matches) {
for (const m of matches) {
tagSet.add(m.slice(1).toLowerCase());
}
}
}
return Array.from(tagSet);
}, [blocks]);
// Keep parent sidebar metadata in sync when blocks/tags change
useEffect(() => {
if (!docRef.current) return;
const updatedDoc: Document = {
...docRef.current,
title,
blocks,
tags: extractedTags,
updatedAt: new Date().toISOString(),
};
docRef.current = updatedDoc;
onDocMetadataChange(updatedDoc);
}, [blocks, extractedTags, title]);
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!canEdit) return;
const nextTitle = e.target.value;
setTitle(nextTitle);
if (titleDebounceRef.current !== null) {
window.clearTimeout(titleDebounceRef.current);
}
titleDebounceRef.current = window.setTimeout(() => {
titleDebounceRef.current = null;
sendTitleUpdated(nextTitle);
}, 250);
};
const handleUpdateBlock = useCallback(
(updatedBlock: DocumentBlock) => {
setBlocks((prev) =>
prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))
);
sendBlockUpdated(updatedBlock);
},
[sendBlockUpdated]
);
const handleCreateBlock = useCallback(
(newBlock: DocumentBlock, afterBlockId?: string) => {
setBlocks((prev) => {
const next = [...prev];
if (afterBlockId) {
const idx = next.findIndex((b) => b.id === afterBlockId);
if (idx >= 0) {
next.splice(idx + 1, 0, newBlock);
const normalized = ensureNonEmptyBlocks(docId, next);
sendBlockCreated(newBlock, afterBlockId, normalized);
return normalized;
}
}
next.push(newBlock);
const normalized = ensureNonEmptyBlocks(docId, next);
sendBlockCreated(newBlock, afterBlockId, normalized);
return normalized;
});
},
[docId, sendBlockCreated]
);
const handleDeleteBlock = useCallback(
(blockId: string) => {
setBlocks((prev) => {
const filtered = prev.filter((b) => b.id !== blockId);
const normalized = ensureNonEmptyBlocks(docId, filtered);
sendBlockDeleted(blockId, normalized);
return normalized;
});
},
[docId, sendBlockDeleted]
);
const handleMoveBlock = useCallback(
(blockId: string, newIndex: number, level?: number) => {
setBlocks((prev) => {
const fromIdx = prev.findIndex((b) => b.id === blockId);
if (fromIdx === -1) return prev;
const copy = [...prev];
const [moving] = copy.splice(fromIdx, 1);
const updatedMoving: DocumentBlock = {
...moving,
level: level !== undefined ? level : moving.level,
};
const boundedIdx = Math.max(0, Math.min(newIndex, copy.length));
copy.splice(boundedIdx, 0, updatedMoving);
const normalized = ensureNonEmptyBlocks(docId, copy);
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
return normalized;
});
},
[docId, sendBlockMoved]
);
const handleTogglePin = async () => {
if (!doc) return;
try {
const updated = await api.updateDocument(doc.id, { isPinned: !doc.isPinned });
setDoc(updated);
onDocMetadataChange(updated);
} catch {
// ignore
}
};
const handleSelectIcon = async (icon: string) => {
setShowEmojiPicker(false);
if (!doc || !canEdit) return;
try {
const updated = await api.updateDocument(doc.id, { icon });
setDoc(updated);
onDocMetadataChange(updated);
} catch {
// ignore
}
};
const handleMoveToFolder = async (folderPath: string) => {
setShowFolderMenu(false);
if (!doc || !canEdit) return;
try {
const updated = await api.moveDocument(doc.id, folderPath);
setDoc(updated);
onDocMetadataChange(updated);
} catch {
// ignore
}
};
const handleExportMarkdown = () => {
const lines: string[] = [`# ${title || 'Unbenannte Notiz'}`, ''];
for (const b of blocks) {
const indent = ' '.repeat(Math.max(0, b.level || 0));
if (b.type === 'todo') {
lines.push(`${indent}- [${b.checked ? 'x' : ' '}] ${b.content}`);
} else if (b.type === 'h1') {
lines.push(`${indent}- # ${b.content}`);
} else if (b.type === 'h2') {
lines.push(`${indent}- ## ${b.content}`);
} else if (b.type === 'h3') {
lines.push(`${indent}- ### ${b.content}`);
} else if (b.type === 'code') {
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
} else {
lines.push(`${indent}- ${b.content}`);
}
}
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${(title || 'notiz').toLowerCase().replace(/[^a-z0-9äöüß_-]+/gi, '-')}.md`;
a.click();
URL.revokeObjectURL(url);
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center p-12 text-slate-400">
<Loader2 className="w-6 h-6 animate-spin mr-2 text-indigo-500" />
<span className="text-sm">Notiz wird geladen…</span>
</div>
);
}
if (error) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center">
<p className="text-sm text-rose-500 font-medium mb-2">{error}</p>
{onBackMobile && (
<button
type="button"
onClick={onBackMobile}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300"
>
Zurück zur Übersicht
</button>
)}
</div>
);
}
return (
<div className="flex-1 flex flex-col h-full min-h-0 bg-white dark:bg-slate-900 overflow-y-auto">
{/* Top Document Action Bar */}
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-2 px-4 sm:px-8 py-3 bg-white/90 dark:bg-slate-900/90 backdrop-blur-md border-b border-slate-200/70 dark:border-slate-800/80">
<div className="flex items-center gap-2 min-w-0">
{onBackMobile && (
<button
type="button"
onClick={onBackMobile}
className="lg:hidden p-1.5 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
title="Zurück zur Seitenleiste"
>
<ArrowLeft className="w-4 h-4" />
</button>
)}
{/* Folder Selector */}
<div className="relative">
<button
type="button"
onClick={() => canEdit && setShowFolderMenu((prev) => !prev)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700/70 transition-colors"
title="Ordner wechseln"
>
<FolderIcon className="w-3.5 h-3.5 text-indigo-500" />
<span className="truncate max-w-[160px]">
{doc?.folderPath ? doc.folderPath : 'Hauptebene'}
</span>
</button>
{showFolderMenu && canEdit && (
<div className="absolute left-0 mt-1 w-52 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl shadow-xl py-1 z-30 max-h-60 overflow-y-auto">
<button
type="button"
onClick={() => handleMoveToFolder('')}
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-slate-100 dark:hover:bg-slate-800 ${
!doc?.folderPath
? 'font-semibold text-indigo-600 dark:text-indigo-400'
: 'text-slate-700 dark:text-slate-300'
}`}
>
/ (Hauptebene)
</button>
{availableFolders.map((folder) => (
<button
key={folder}
type="button"
onClick={() => handleMoveToFolder(folder)}
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-slate-100 dark:hover:bg-slate-800 truncate ${
doc?.folderPath === folder
? 'font-semibold text-indigo-600 dark:text-indigo-400'
: 'text-slate-700 dark:text-slate-300'
}`}
>
📁 {folder}
</button>
))}
</div>
)}
</div>
{/* Read-only indicator if shared with read permission */}
{!canEdit && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 dark:bg-amber-950/60 text-amber-700 dark:text-amber-300">
<Lock className="w-3 h-3" />
Nur Lesen
</span>
)}
</div>
{/* Right Status & Action Buttons */}
<div className="flex items-center gap-1.5">
{/* Sync Status Pill */}
<span
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium ${
syncStatus === 'connected'
? 'bg-emerald-50 dark:bg-emerald-950/50 text-emerald-600 dark:text-emerald-400'
: syncStatus === 'connecting' || syncStatus === 'reconnecting'
? 'bg-amber-50 dark:bg-amber-950/50 text-amber-600 dark:text-amber-400'
: 'bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400'
}`}
title={
syncStatus === 'connected'
? 'Echtzeit-Synchronisation aktiv (1.5s Auto-Flush in SQLite)'
: 'Änderungen werden automatisch per REST gespeichert'
}
>
{syncStatus === 'connected' ? (
<>
<Wifi className="w-3 h-3" />
<span className="hidden sm:inline">Live</span>
</>
) : syncStatus === 'connecting' || syncStatus === 'reconnecting' ? (
<>
<Loader2 className="w-3 h-3 animate-spin" />
<span className="hidden sm:inline">Sync…</span>
</>
) : (
<>
<WifiOff className="w-3 h-3" />
<span className="hidden sm:inline">REST-Sync</span>
</>
)}
</span>
<button
type="button"
onClick={handleTogglePin}
className={`p-1.5 rounded-lg transition-colors ${
doc?.isPinned
? 'bg-amber-50 dark:bg-amber-950/50 text-amber-500'
: 'text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
>
<Pin className="w-4 h-4" />
</button>
{doc && (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="p-1.5 rounded-lg text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
title="Notiz teilen / Berechtigungen"
>
<Share2 className="w-4 h-4" />
</button>
)}
<button
type="button"
onClick={handleExportMarkdown}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
title="Als Markdown (.md) exportieren"
>
<Download className="w-4 h-4" />
</button>
{canEdit && doc?.isOwner !== false && (
<button
type="button"
onClick={() => onDeleteDoc(docId)}
className="p-1.5 rounded-lg text-slate-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition-colors"
title="Notiz löschen"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Main Outliner Content Container */}
<div className="max-w-4xl w-full mx-auto px-4 sm:px-10 py-6 sm:py-8 flex-1">
{/* Document Icon + Title Header */}
<div className="flex items-center gap-3 mb-4 relative">
<div className="relative">
<button
type="button"
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
className="w-11 h-11 rounded-2xl bg-slate-100 dark:bg-slate-800/90 hover:bg-slate-200/70 dark:hover:bg-slate-700 flex items-center justify-center text-2xl transition-colors"
title="Icon ändern"
>
{doc?.icon || '📝'}
</button>
{showEmojiPicker && canEdit && (
<div className="absolute left-0 mt-2 p-2 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-xl grid grid-cols-6 gap-1 z-30 w-56">
{EMOJI_PALETTE.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => handleSelectIcon(emoji)}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 text-lg flex items-center justify-center"
>
{emoji}
</button>
))}
</div>
)}
</div>
<input
ref={titleInputRef}
type="text"
value={title}
readOnly={!canEdit}
onChange={handleTitleChange}
placeholder="Titel der Notiz…"
className="flex-1 min-w-0 text-2xl sm:text-3xl font-bold text-slate-900 dark:text-white bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight"
/>
</div>
{/* Document #Tags Summary Bar */}
{extractedTags.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 mb-6 pb-4 border-b border-slate-100 dark:border-slate-800/80">
<Hash className="w-3.5 h-3.5 text-slate-400 mr-0.5" />
{extractedTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => onSelectTag(tag)}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/80 transition-colors"
>
#{tag}
</button>
))}
</div>
)}
{/* Logseq Hierarchical Outliner Tree */}
<OutlinerTree
docId={docId}
blocks={blocks}
canEdit={canEdit}
onUpdateBlock={handleUpdateBlock}
onCreateBlock={handleCreateBlock}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onTagClick={onSelectTag}
/>
</div>
</div>
);
};
@@ -0,0 +1,548 @@
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react';
import {
ChevronRight,
ChevronDown,
CheckSquare,
Square,
Trash2,
GripVertical,
ArrowDownLeft,
Indent,
Outdent,
Code,
Heading1,
Heading2,
ListTodo,
Type,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
export interface OutlinerBlockProps {
block: DocumentBlock;
index: number;
maxAllowedLevel: number;
hasChildren: boolean;
isFocused: boolean;
canEdit: boolean;
onFocusBlock: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
onDeleteBlock: (blockId: string) => void;
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
onTagClick?: (tag: string) => void;
onDragStartBlock?: (index: number) => void;
onDragOverBlock?: (e: React.DragEvent, index: number) => void;
onDropBlock?: (index: number) => void;
isDragTarget?: boolean;
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
}
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
block,
index,
maxAllowedLevel,
hasChildren,
isFocused,
canEdit,
onFocusBlock,
onUpdateBlock,
onCreateBlockBelow,
onDeleteBlock,
onIndentBlock,
onNavigateFocus,
onTagClick,
onDragStartBlock,
onDragOverBlock,
onDropBlock,
isDragTarget,
registerTextareaRef,
}) => {
const [localContent, setLocalContent] = useState<string>(block.content || '');
const [showTypeMenu, setShowTypeMenu] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const debounceTimerRef = useRef<number | null>(null);
const latestBlockRef = useRef<DocumentBlock>(block);
latestBlockRef.current = block;
const localContentRef = useRef<string>(localContent);
localContentRef.current = localContent;
// Sync incoming remote changes ONLY when this textarea does NOT hold active focus
useEffect(() => {
const isActivelyFocused =
textareaRef.current !== null && document.activeElement === textareaRef.current;
if (!isActivelyFocused && block.content !== localContentRef.current) {
setLocalContent(block.content || '');
}
}, [block.content]);
// Cleanup 150ms debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
};
}, []);
// Auto-resize textarea height on content or layout changes
const adjustHeight = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.max(24, el.scrollHeight)}px`;
}, []);
useLayoutEffect(() => {
adjustHeight();
}, [localContent, block.type, block.level, adjustHeight]);
useEffect(() => {
window.addEventListener('resize', adjustHeight);
return () => window.removeEventListener('resize', adjustHeight);
}, [adjustHeight]);
const flushDebounce = useCallback(
(contentToFlush: string, overrides?: Partial<DocumentBlock>) => {
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
const nextBlock: DocumentBlock = {
...latestBlockRef.current,
content: contentToFlush,
...overrides,
};
onUpdateBlock(nextBlock, true);
},
[onUpdateBlock]
);
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!canEdit) return;
let val = e.target.value;
let typeOverride: BlockType | undefined;
let checkedOverride: boolean | undefined;
// Quick markdown prefix shortcuts at start of block
if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
val = val.replace(/^\[\s?\]\s/, '');
typeOverride = 'todo';
checkedOverride = false;
} else if (val.startsWith('[x] ') || val.startsWith('[X] ')) {
val = val.replace(/^\[[xX]\]\s/, '');
typeOverride = 'todo';
checkedOverride = true;
} else if (val.startsWith('# ') && block.type !== 'h1') {
val = val.slice(2);
typeOverride = 'h1';
} else if (val.startsWith('## ') && block.type !== 'h2') {
val = val.slice(3);
typeOverride = 'h2';
} else if (val.startsWith('### ') && block.type !== 'h3') {
val = val.slice(4);
typeOverride = 'h3';
} else if (val.startsWith('/todo ')) {
val = val.slice(6);
typeOverride = 'todo';
checkedOverride = false;
} else if (val.startsWith('/code ')) {
val = val.slice(6);
typeOverride = 'code';
}
setLocalContent(val);
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
}
if (typeOverride) {
flushDebounce(val, {
type: typeOverride,
checked: checkedOverride ?? latestBlockRef.current.checked,
});
return;
}
debounceTimerRef.current = window.setTimeout(() => {
debounceTimerRef.current = null;
onUpdateBlock({
...latestBlockRef.current,
content: val,
});
}, 150);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!canEdit) return;
// 1. Tab / Shift+Tab -> Strict Outliner Indent / Outdent
if (e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
flushDebounce(localContent);
if (e.shiftKey) {
onIndentBlock(block.id, -1);
} else {
onIndentBlock(block.id, 1);
}
return;
}
// 2. Enter (without Shift) -> Create new block on same level
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
e.preventDefault();
e.stopPropagation();
flushDebounce(localContent);
onCreateBlockBelow(block.id, localContent);
return;
}
// 3. Backspace on empty block -> Outdent if indented, or delete block & focus previous
if (e.key === 'Backspace' && localContent === '') {
e.preventDefault();
e.stopPropagation();
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if ((block.level || 0) > 0) {
onIndentBlock(block.id, -1);
} else {
onDeleteBlock(block.id);
}
return;
}
// 4. ArrowUp / ArrowDown navigation between blocks
if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
const el = textareaRef.current;
if (el && el.selectionStart === 0 && el.selectionEnd === 0) {
e.preventDefault();
flushDebounce(localContent);
onNavigateFocus(block.id, 'up');
}
return;
}
if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) {
const el = textareaRef.current;
if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) {
e.preventDefault();
flushDebounce(localContent);
onNavigateFocus(block.id, 'down');
}
return;
}
};
const level = Math.max(0, block.level || 0);
const inlineTags = Array.from(
new Set(
(localContent.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g) || []).map((t) => t.slice(1).toLowerCase())
)
);
const setBlockType = (newType: BlockType) => {
setShowTypeMenu(false);
flushDebounce(localContent, {
type: newType,
checked: newType === 'todo' ? Boolean(block.checked) : false,
});
};
const textStyleClass =
block.type === 'h1'
? 'text-xl font-bold text-slate-900 dark:text-white tracking-tight'
: block.type === 'h2'
? 'text-lg font-semibold text-slate-800 dark:text-slate-100'
: block.type === 'h3'
? 'text-base font-semibold text-slate-800 dark:text-slate-200'
: block.type === 'code'
? 'font-mono text-xs bg-slate-900/90 text-emerald-300 px-3 py-2 rounded-lg border border-slate-700/80'
: block.type === 'todo' && block.checked
? 'text-sm text-slate-400 dark:text-slate-500 line-through'
: 'text-sm text-slate-800 dark:text-slate-200';
return (
<div
className={`group relative flex items-start gap-1.5 py-1 rounded-lg transition-colors ${
isDragTarget
? 'border-t-2 border-indigo-500 bg-indigo-50/40 dark:bg-indigo-950/20'
: isFocused
? 'bg-slate-100/70 dark:bg-slate-800/40'
: 'hover:bg-slate-50/80 dark:hover:bg-slate-800/20'
}`}
style={{ paddingLeft: `${level * 24 + 4}px` }}
onDragOver={(e) => onDragOverBlock?.(e, index)}
onDrop={() => onDropBlock?.(index)}
>
{/* Indentation Guide Lines */}
{level > 0 &&
Array.from({ length: level }).map((_, lvlIdx) => (
<div
key={lvlIdx}
className="pointer-events-none absolute top-0 bottom-0 w-px bg-slate-200/80 dark:bg-slate-800"
style={{ left: `${lvlIdx * 24 + 14}px` }}
/>
))}
{/* Left Controls: Drag Handle + Collapse Toggle + Bullet / Checkbox */}
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
{canEdit && (
<button
type="button"
draggable
onDragStart={() => onDragStartBlock?.(index)}
title="Block ziehen oder Typ ändern"
onClick={() => setShowTypeMenu((prev) => !prev)}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 cursor-grab active:cursor-grabbing transition-opacity"
>
<GripVertical className="w-3.5 h-3.5" />
</button>
)}
{hasChildren ? (
<button
type="button"
onClick={() =>
flushDebounce(localContent, { collapsed: !block.collapsed })
}
className="p-0.5 rounded text-slate-400 hover:text-indigo-500 transition-colors"
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
>
{block.collapsed ? (
<ChevronRight className="w-3.5 h-3.5" />
) : (
<ChevronDown className="w-3.5 h-3.5" />
)}
</button>
) : (
<span className="w-4" />
)}
{block.type === 'todo' ? (
<button
type="button"
disabled={!canEdit}
onClick={() =>
flushDebounce(localContent, { checked: !block.checked })
}
className={`p-0.5 rounded transition-colors ${
block.checked
? 'text-emerald-500 hover:text-emerald-600'
: 'text-slate-400 hover:text-indigo-500'
}`}
title={block.checked ? 'Als offen markieren' : 'Als erledigt markieren'}
>
{block.checked ? (
<CheckSquare className="w-4 h-4" />
) : (
<Square className="w-4 h-4" />
)}
</button>
) : (
<button
type="button"
onClick={() => canEdit && setShowTypeMenu((prev) => !prev)}
className="w-5 h-5 flex items-center justify-center rounded-full group/bullet"
title="Klicken für Block-Typ"
>
<span
className={`w-2 h-2 rounded-full transition-all ${
block.collapsed && hasChildren
? 'bg-indigo-500 ring-4 ring-indigo-500/20'
: isFocused
? 'bg-indigo-500 scale-110'
: 'bg-slate-400 dark:bg-slate-500 group-hover/bullet:bg-indigo-400'
}`}
/>
</button>
)}
</div>
{/* Block Type Quick Popover */}
{showTypeMenu && canEdit && (
<div className="absolute left-8 top-8 z-30 flex items-center gap-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl shadow-lg p-1">
<button
type="button"
onClick={() => setBlockType('text')}
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
block.type === 'text' || block.type === 'paragraph' || block.type === 'bullet'
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
title="Standard-Bullet"
>
<Type className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setBlockType('todo')}
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
block.type === 'todo'
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
title="To-Do Checkbox"
>
<ListTodo className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setBlockType('h1')}
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
block.type === 'h1'
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
title="Überschrift 1"
>
<Heading1 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setBlockType('h2')}
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
block.type === 'h2'
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
title="Überschrift 2"
>
<Heading2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setBlockType('code')}
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
block.type === 'code'
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
title="Code-Block"
>
<Code className="w-3.5 h-3.5" />
</button>
</div>
)}
{/* Main Content Area: Auto-Resizing Multiline Textarea + Tag Pills */}
<div className="flex-1 min-w-0 pr-2">
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={() => {
setShowTypeMenu(false);
onFocusBlock(block.id);
}}
onBlur={() => {
if (debounceTimerRef.current !== null) {
flushDebounce(localContent);
}
}}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
placeholder={
index === 0 && level === 0
? 'Schreibe einen Punkt… (Enter = Neuer Punkt, Tab = Einrücken, #tag, [] für To-Do)'
: ''
}
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${textStyleClass}`}
/>
{/* Inline clickable #tag pills */}
{inlineTags.length > 0 && (
<div className="flex flex-wrap items-center gap-1 mt-1">
{inlineTags.map((t) => (
<button
key={t}
type="button"
onClick={() => onTagClick?.(t)}
className="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/80 transition-colors"
>
#{t}
</button>
))}
</div>
)}
{/* Mobile Quick-Action Bar when focused */}
{isFocused && canEdit && (
<div className="flex sm:hidden items-center gap-1 mt-1.5 pt-1 border-t border-slate-200/60 dark:border-slate-800/60">
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
flushDebounce(localContent);
onIndentBlock(block.id, -1);
}}
disabled={level === 0}
className="p-1.5 rounded-lg text-xs text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 disabled:opacity-40"
title="Ausrücken (Shift+Tab)"
>
<Outdent className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
flushDebounce(localContent);
onIndentBlock(block.id, 1);
}}
disabled={level >= maxAllowedLevel}
className="p-1.5 rounded-lg text-xs text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 disabled:opacity-40"
title="Einrücken (Tab)"
>
<Indent className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
flushDebounce(localContent, {
type: block.type === 'todo' ? 'text' : 'todo',
});
}}
className="p-1.5 rounded-lg text-xs text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800"
title="Als To-Do umschalten"
>
<ListTodo className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
flushDebounce(localContent);
onCreateBlockBelow(block.id, localContent);
}}
className="p-1.5 rounded-lg text-xs text-indigo-600 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-950/60 ml-auto flex items-center gap-1"
>
<ArrowDownLeft className="w-3.5 h-3.5" />
<span>Neu</span>
</button>
</div>
)}
</div>
{/* Right Hover Delete Button */}
{canEdit && (
<button
type="button"
onClick={() => onDeleteBlock(block.id)}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-1 rounded text-slate-400 hover:text-rose-500 transition-opacity shrink-0"
title="Block löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
);
};
@@ -0,0 +1,312 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Plus } from 'lucide-react';
import { DocumentBlock } from '../../lib/types';
import { OutlinerBlock } from './OutlinerBlock';
export interface OutlinerTreeProps {
docId: string;
blocks: DocumentBlock[];
canEdit: boolean;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
onDeleteBlock: (blockId: string) => void;
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
onTagClick?: (tag: string) => void;
}
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
docId,
blocks,
canEdit,
onUpdateBlock,
onCreateBlock,
onDeleteBlock,
onMoveBlock,
onTagClick,
}) => {
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' } | null>(null);
const registerTextareaRef = useCallback((blockId: string, el: HTMLTextAreaElement | null) => {
if (el) {
textareaMapRef.current.set(blockId, el);
if (pendingFocusRef.current && pendingFocusRef.current.blockId === blockId) {
const target = pendingFocusRef.current;
pendingFocusRef.current = null;
requestAnimationFrame(() => {
el.focus();
const pos = target.cursor === 'end' ? el.value.length : 0;
try {
el.setSelectionRange(pos, pos);
} catch {
// ignore
}
});
}
} else {
textareaMapRef.current.delete(blockId);
}
}, []);
const focusBlockInput = useCallback((blockId: string, cursor: 'start' | 'end' = 'end') => {
setFocusedBlockId(blockId);
const el = textareaMapRef.current.get(blockId);
if (el) {
requestAnimationFrame(() => {
el.focus();
const pos = cursor === 'end' ? el.value.length : 0;
try {
el.setSelectionRange(pos, pos);
} catch {
// ignore
}
});
} else {
pendingFocusRef.current = { blockId, cursor };
}
}, []);
// Compute visibility based on collapsed ancestor blocks
const visibleEntries = useMemo(() => {
const result: {
block: DocumentBlock;
index: number;
hasChildren: boolean;
maxAllowedLevel: number;
}[] = [];
let collapsedAboveLevel: number | null = null;
for (let i = 0; i < blocks.length; i++) {
const b = blocks[i];
const lvl = Math.max(0, b.level || 0);
if (collapsedAboveLevel !== null) {
if (lvl > collapsedAboveLevel) {
continue;
} else {
collapsedAboveLevel = null;
}
}
const nextBlock = blocks[i + 1];
const nextLvl = nextBlock ? Math.max(0, nextBlock.level || 0) : 0;
const hasChildren = Boolean(nextBlock && nextLvl > lvl);
const prevBlock = i > 0 ? blocks[i - 1] : null;
const maxAllowedLevel = prevBlock ? Math.max(0, prevBlock.level || 0) + 1 : 0;
result.push({
block: b,
index: i,
hasChildren,
maxAllowedLevel,
});
if (b.collapsed && hasChildren) {
collapsedAboveLevel = lvl;
}
}
return result;
}, [blocks]);
const handleCreateBlockBelow = useCallback(
(afterBlockId: string, _currentContent: string) => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === afterBlockId);
const currentBlock = idx >= 0 ? blocks[idx] : null;
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
const inheritedType =
currentBlock && currentBlock.type === 'todo' ? 'todo' : 'text';
const newBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
documentId: docId,
type: inheritedType,
content: '',
level,
checked: false,
collapsed: false,
position: ((idx >= 0 ? idx + 2 : blocks.length + 1) * 1000),
};
pendingFocusRef.current = { blockId: newBlock.id, cursor: 'start' };
setFocusedBlockId(newBlock.id);
onCreateBlock(newBlock, afterBlockId);
},
[blocks, canEdit, docId, onCreateBlock]
);
const handleDeleteBlock = useCallback(
(blockId: string) => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
// If this is the only block in the document, clear its content/type instead of leaving 0 blocks
if (blocks.length <= 1) {
const only = blocks[0];
onUpdateBlock(
{
...only,
content: '',
type: 'text',
level: 0,
checked: false,
},
true
);
return;
}
const targetFocusBlock = idx > 0 ? blocks[idx - 1] : blocks[idx + 1];
if (targetFocusBlock) {
focusBlockInput(targetFocusBlock.id, 'end');
}
onDeleteBlock(blockId);
},
[blocks, canEdit, focusBlockInput, onDeleteBlock, onUpdateBlock]
);
const handleIndentBlock = useCallback(
(blockId: string, delta: 1 | -1) => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
const current = blocks[idx];
const currentLevel = Math.max(0, current.level || 0);
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 0;
const maxLevel = idx === 0 ? 0 : prevLevel + 1;
const nextLevel =
delta === 1
? Math.min(maxLevel, currentLevel + 1)
: Math.max(0, currentLevel - 1);
if (nextLevel === currentLevel) return;
onUpdateBlock(
{
...current,
level: nextLevel,
},
true
);
},
[blocks, canEdit, onUpdateBlock]
);
const handleNavigateFocus = useCallback(
(blockId: string, direction: 'up' | 'down') => {
const visIdx = visibleEntries.findIndex((e) => e.block.id === blockId);
if (visIdx === -1) return;
const targetEntry =
direction === 'up' ? visibleEntries[visIdx - 1] : visibleEntries[visIdx + 1];
if (targetEntry) {
focusBlockInput(
targetEntry.block.id,
direction === 'up' ? 'end' : 'start'
);
}
},
[focusBlockInput, visibleEntries]
);
const handleDragStartBlock = useCallback((index: number) => {
setDraggedIndex(index);
}, []);
const handleDragOverBlock = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverIndex(index);
}, []);
const handleDropBlock = useCallback(
(targetIndex: number) => {
if (draggedIndex === null || draggedIndex === targetIndex) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
}
const movingBlock = blocks[draggedIndex];
if (movingBlock) {
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
}
setDraggedIndex(null);
setDragOverIndex(null);
},
[blocks, draggedIndex, onMoveBlock]
);
useEffect(() => {
const clearDrag = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
window.addEventListener('dragend', clearDrag);
return () => window.removeEventListener('dragend', clearDrag);
}, []);
return (
<div className="space-y-0.5">
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => (
<OutlinerBlock
key={block.id}
block={block}
index={index}
maxAllowedLevel={maxAllowedLevel}
hasChildren={hasChildren}
isFocused={focusedBlockId === block.id}
canEdit={canEdit}
onFocusBlock={setFocusedBlockId}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onDeleteBlock={handleDeleteBlock}
onIndentBlock={handleIndentBlock}
onNavigateFocus={handleNavigateFocus}
onTagClick={onTagClick}
onDragStartBlock={handleDragStartBlock}
onDragOverBlock={handleDragOverBlock}
onDropBlock={handleDropBlock}
isDragTarget={dragOverIndex === index && draggedIndex !== index}
registerTextareaRef={registerTextareaRef}
/>
))}
{canEdit && (
<div className="pt-2 pl-2">
<button
type="button"
onClick={() => {
const lastBlock = blocks[blocks.length - 1];
if (lastBlock) {
handleCreateBlockBelow(lastBlock.id, lastBlock.content);
} else {
const firstBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_0`,
documentId: docId,
type: 'text',
content: '',
level: 0,
position: 1000,
};
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
onCreateBlock(firstBlock);
}
}}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-slate-100/80 dark:hover:bg-slate-800/50 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
<span>Neuer Punkt</span>
</button>
</div>
)}
</div>
);
};
File diff suppressed because it is too large. Load diff
+1 -77
View File
@@ -602,31 +602,6 @@ export const api = {
}; };
}, },
async getDocumentCRDT(id: string): Promise<{ docId: string; state?: string; updates?: string[] }> {
return request<{ docId: string; state?: string; updates?: string[] }>(`/api/v1/documents/${id}/crdt`);
},
async saveDocumentCRDT(id: string, payload: string | { state?: string; update?: string }): Promise<{ success: boolean }> {
const body = typeof payload === 'string' ? { state: payload } : payload;
return request<{ success: boolean }>(`/api/v1/documents/${id}/crdt`, {
method: 'PUT',
body: JSON.stringify(body),
});
},
async sendDocumentAwareness(id: string, payload: any): Promise<{ success: boolean }> {
return request<{ success: boolean }>(`/api/v1/documents/${id}/awareness`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
async clearDocumentAwareness(id: string): Promise<{ success: boolean }> {
return request<{ success: boolean }>(`/api/v1/documents/${id}/awareness`, {
method: 'DELETE',
});
},
async joinSSERoom(sseId: string, room: string, action: 'join' | 'leave' = 'join'): Promise<{ success: boolean }> { async joinSSERoom(sseId: string, room: string, action: 'join' | 'leave' = 'join'): Promise<{ success: boolean }> {
return request<{ success: boolean }>('/api/v1/events/rooms', { return request<{ success: boolean }>('/api/v1/events/rooms', {
method: 'POST', method: 'POST',
@@ -675,44 +650,6 @@ export const api = {
return ensureArray(res); return ensureArray(res);
}, },
// --- DOCUMENT LOCKING ---
async getDocLock(docId: string): Promise<{ locked: boolean; lock?: DocLockInfo }> {
return request<{ locked: boolean; lock?: DocLockInfo }>(`/api/v1/documents/${docId}/lock`);
},
async lockDocument(
docId: string
): Promise<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }> {
try {
return await request<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }>(
`/api/v1/documents/${docId}/lock`,
{ method: 'POST' }
);
} catch (e: any) {
return { locked: true, acquired: false, message: e.message };
}
},
async heartbeatDocLock(docId: string): Promise<{ extended: boolean }> {
try {
return await request<{ extended: boolean }>(`/api/v1/documents/${docId}/lock/heartbeat`, {
method: 'POST',
});
} catch {
return { extended: false };
}
},
async unlockDocument(docId: string): Promise<{ unlocked: boolean }> {
try {
return await request<{ unlocked: boolean }>(`/api/v1/documents/${docId}/lock`, {
method: 'DELETE',
});
} catch {
return { unlocked: false };
}
},
// --- SPACES & REALTIME LISTS --- // --- SPACES & REALTIME LISTS ---
async getSpaces(): Promise<Space[]> { async getSpaces(): Promise<Space[]> {
const res = await request<Space[]>('/api/v1/spaces'); const res = await request<Space[]>('/api/v1/spaces');
@@ -1398,23 +1335,10 @@ export function sendWebSocketMessage(msg: any): void {
// SSE Fallback: dispatch via REST endpoints // SSE Fallback: dispatch via REST endpoints
try { try {
const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg; const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg;
if (parsed.event === 'CRDT_UPDATE' && parsed.docId) { if (parsed.event === 'JOIN_ROOM' && parsed.room) {
api.saveDocumentCRDT(parsed.docId, { update: parsed.payload?.update }).catch(() => {});
} else if (parsed.event === 'CRDT_AWARENESS' && parsed.docId) {
api.sendDocumentAwareness(parsed.docId, parsed.payload).catch(() => {});
} else if (parsed.event === 'CRDT_LEAVE' && parsed.docId) {
api.clearDocumentAwareness(parsed.docId).catch(() => {});
if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'leave').catch(() => {});
} else if (parsed.event === 'CRDT_JOIN' && parsed.docId) {
if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'join').catch(() => {});
} else if (parsed.event === 'JOIN_ROOM' && parsed.room) {
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'join').catch(() => {}); if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'join').catch(() => {});
} else if (parsed.event === 'LEAVE_ROOM' && parsed.room) { } else if (parsed.event === 'LEAVE_ROOM' && parsed.room) {
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'leave').catch(() => {}); if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'leave').catch(() => {});
} else if (parsed.event === 'LOCK_DOC' && parsed.docId) {
api.lockDocument(parsed.docId).catch(() => {});
} else if (parsed.event === 'UNLOCK_DOC' && parsed.docId) {
api.unlockDocument(parsed.docId).catch(() => {});
} }
} catch (err) { } catch (err) {
console.error('Error dispatching fallback REST message for SSE:', err); console.error('Error dispatching fallback REST message for SSE:', err);
-271
View File
@@ -1,271 +0,0 @@
import * as Y from 'yjs';
import { BlockType, DocumentBlock } from './types';
// Convert Uint8Array to Base64
export function uint8ArrayToBase64(bytes: Uint8Array): string {
let binary = '';
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// Convert Base64 to Uint8Array
export function base64ToUint8Array(base64: string): Uint8Array {
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
const COLLAB_COLORS = [
'#3B82F6', // Blue
'#10B981', // Emerald
'#F59E0B', // Amber
'#8B5CF6', // Purple
'#EC4899', // Pink
'#06B6D4', // Cyan
'#F97316', // Orange
'#14B8A6', // Teal
];
export function getCollaboratorColor(userId: string): string {
if (!userId) return COLLAB_COLORS[0];
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = (hash << 5) - hash + userId.charCodeAt(i);
hash |= 0;
}
const idx = Math.abs(hash) % COLLAB_COLORS.length;
return COLLAB_COLORS[idx];
}
export interface CollaboratorAwareness {
userId: string;
userName: string;
activeBlockId: string | null;
color: string;
lastActive: number;
}
export class DocumentCRDTSession {
public docId: string;
public doc: Y.Doc;
private order: Y.Array<string>;
private blocks: Y.Map<Y.Map<any>>;
private destroyed: boolean = false;
public onLocalUpdate?: (base64Update: string) => void;
public onRemoteChange?: (blocks: DocumentBlock[]) => void;
constructor(docId: string) {
this.docId = docId;
this.doc = new Y.Doc({ guid: docId });
this.order = this.doc.getArray<string>('blockOrder');
this.blocks = this.doc.getMap<Y.Map<any>>('blocks');
this.doc.on('update', (update: Uint8Array, origin: any) => {
if (this.destroyed) return;
if (origin !== 'remote') {
const b64 = uint8ArrayToBase64(update);
if (this.onLocalUpdate) {
this.onLocalUpdate(b64);
}
} else {
if (this.onRemoteChange) {
this.onRemoteChange(this.getBlocks());
}
}
});
}
public destroy(): void {
this.destroyed = true;
this.doc.destroy();
}
public getBlocks(): DocumentBlock[] {
const orderList = this.order.toArray();
const result: DocumentBlock[] = [];
for (const id of orderList) {
const bMap = this.blocks.get(id);
if (bMap) {
result.push({
id,
docId: this.docId,
type: (bMap.get('type') || 'bullet') as BlockType,
content: bMap.get('content') || '',
level: typeof bMap.get('level') === 'number' ? bMap.get('level') : 0,
checked: !!bMap.get('checked'),
collapsed: !!bMap.get('collapsed'),
lang: bMap.get('lang') || '',
sortOrder: typeof bMap.get('sortOrder') === 'number' ? bMap.get('sortOrder') : 0,
updatedAt: bMap.get('updatedAt') || undefined,
});
}
}
return result;
}
public initFromBlocks(initialBlocks: DocumentBlock[]): void {
this.doc.transact(() => {
if (this.order.length === 0 && initialBlocks.length > 0) {
const ids: string[] = [];
for (const b of initialBlocks) {
ids.push(b.id);
const bMap = new Y.Map<any>();
bMap.set('id', b.id);
bMap.set('type', b.type || 'bullet');
bMap.set('content', b.content || '');
bMap.set('level', b.level || 0);
bMap.set('checked', !!b.checked);
bMap.set('collapsed', !!b.collapsed);
bMap.set('lang', b.lang || '');
bMap.set('sortOrder', b.sortOrder || 0);
bMap.set('updatedAt', b.updatedAt || new Date().toISOString());
this.blocks.set(b.id, bMap);
}
this.order.push(ids);
}
}, 'init');
}
public updateBlock(id: string, updates: Partial<DocumentBlock>): void {
this.doc.transact(() => {
const bMap = this.blocks.get(id);
if (bMap) {
if (updates.type !== undefined) bMap.set('type', updates.type);
if (updates.content !== undefined) bMap.set('content', updates.content);
if (updates.level !== undefined) bMap.set('level', updates.level);
if (updates.checked !== undefined) bMap.set('checked', updates.checked);
if (updates.collapsed !== undefined) bMap.set('collapsed', updates.collapsed);
if (updates.lang !== undefined) bMap.set('lang', updates.lang);
if (updates.sortOrder !== undefined) bMap.set('sortOrder', updates.sortOrder);
bMap.set('updatedAt', new Date().toISOString());
}
}, 'local');
}
public insertBlock(index: number, block: DocumentBlock): void {
this.doc.transact(() => {
const bMap = new Y.Map<any>();
bMap.set('id', block.id);
bMap.set('type', block.type || 'bullet');
bMap.set('content', block.content || '');
bMap.set('level', block.level || 0);
bMap.set('checked', !!block.checked);
bMap.set('collapsed', !!block.collapsed);
bMap.set('lang', block.lang || '');
bMap.set('sortOrder', block.sortOrder || 0);
bMap.set('updatedAt', new Date().toISOString());
this.blocks.set(block.id, bMap);
if (index >= 0 && index <= this.order.length) {
this.order.insert(index, [block.id]);
} else {
this.order.push([block.id]);
}
}, 'local');
}
public deleteBlock(id: string): void {
this.doc.transact(() => {
const currentOrder = this.order.toArray();
const idx = currentOrder.indexOf(id);
if (idx !== -1) {
this.order.delete(idx, 1);
}
this.blocks.delete(id);
}, 'local');
}
public deleteMultipleBlocks(ids: string[]): void {
if (ids.length === 0) return;
const toDeleteSet = new Set(ids);
this.doc.transact(() => {
const currentOrder = this.order.toArray();
// Delete from order backwards to preserve indices
for (let i = currentOrder.length - 1; i >= 0; i--) {
if (toDeleteSet.has(currentOrder[i])) {
this.order.delete(i, 1);
}
}
for (const id of ids) {
this.blocks.delete(id);
}
}, 'local');
}
public reorderBlocks(newOrder: string[]): void {
this.doc.transact(() => {
this.order.delete(0, this.order.length);
this.order.push(newOrder);
}, 'local');
}
public syncFullBlocks(blocks: DocumentBlock[]): void {
this.doc.transact(() => {
const nextIds = blocks.map((b) => b.id);
const currentIds = new Set(this.order.toArray());
// Upsert all blocks
for (const b of blocks) {
let bMap = this.blocks.get(b.id);
if (!bMap) {
bMap = new Y.Map<any>();
this.blocks.set(b.id, bMap);
}
bMap.set('id', b.id);
bMap.set('type', b.type || 'bullet');
bMap.set('content', b.content || '');
bMap.set('level', b.level || 0);
bMap.set('checked', !!b.checked);
bMap.set('collapsed', !!b.collapsed);
bMap.set('lang', b.lang || '');
bMap.set('sortOrder', b.sortOrder || 0);
bMap.set('updatedAt', b.updatedAt || new Date().toISOString());
}
// Delete blocks no longer present
const nextIdSet = new Set(nextIds);
currentIds.forEach((id) => {
if (!nextIdSet.has(id)) {
this.blocks.delete(id);
}
});
// Update order if different
const currentOrder = this.order.toArray();
const orderChanged =
currentOrder.length !== nextIds.length || currentOrder.some((id, idx) => id !== nextIds[idx]);
if (orderChanged) {
this.order.delete(0, this.order.length);
this.order.push(nextIds);
}
}, 'local');
}
public applyRemoteUpdate(base64Update: string): void {
try {
const bytes = base64ToUint8Array(base64Update);
Y.applyUpdate(this.doc, bytes, 'remote');
} catch (err) {
console.error('[CRDT] Failed to apply remote update:', err);
}
}
public applyRemoteUpdates(base64Updates: string[]): void {
for (const b64 of base64Updates) {
this.applyRemoteUpdate(b64);
}
}
public encodeState(): string {
const bytes = Y.encodeStateAsUpdate(this.doc);
return uint8ArrayToBase64(bytes);
}
}
+5 -1
View File
@@ -153,16 +153,18 @@ export interface SystemSettings {
smtpConfiguredViaEnv?: boolean; smtpConfiguredViaEnv?: boolean;
} }
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress'; export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
export interface DocumentBlock { export interface DocumentBlock {
id: string; id: string;
docId?: string; docId?: string;
documentId?: string;
type: BlockType; type: BlockType;
content: string; content: string;
checked?: boolean; checked?: boolean;
lang?: string; lang?: string;
level?: number; level?: number;
position?: number;
collapsed?: boolean; collapsed?: boolean;
sortOrder?: number; sortOrder?: number;
updatedAt?: string; updatedAt?: string;
@@ -211,6 +213,8 @@ export interface Document {
spaceId?: string; spaceId?: string;
title: string; title: string;
icon: string; icon: string;
folderId?: string;
markdownCache?: string;
content: string; content: string;
blocks: DocumentBlock[]; blocks: DocumentBlock[];
tags: string[]; tags: string[];
+304
View File
@@ -0,0 +1,304 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { DocumentBlock } from './types';
import { getToken, api } from './api';
export type DocSyncStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline';
export interface DocSyncEvent {
type:
| 'doc_state'
| 'block_created'
| 'block_updated'
| 'block_deleted'
| 'block_moved'
| 'title_updated'
| 'blocks_replaced';
docId?: string;
clientId?: string;
userId?: string;
title?: string;
block?: DocumentBlock;
blockId?: string;
afterBlockId?: string;
newIndex?: number;
content?: string;
level?: number;
checked?: boolean;
collapsed?: boolean;
blockType?: string;
blocks?: DocumentBlock[];
}
export function useDocumentSync(
docId: string | null,
onRemoteEvent: (evt: DocSyncEvent) => void,
canEdit: boolean = true
) {
const [status, setStatus] = useState<DocSyncStatus>('connecting');
const wsRef = useRef<WebSocket | null>(null);
const clientIdRef = useRef<string>(
`cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
);
const onRemoteEventRef = useRef(onRemoteEvent);
onRemoteEventRef.current = onRemoteEvent;
const reconnectTimerRef = useRef<number | null>(null);
const fallbackTimerRef = useRef<number | null>(null);
useEffect(() => {
if (!docId) {
setStatus('offline');
return;
}
let isDisposed = false;
let retryCount = 0;
const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
clientIdRef.current = currentClientId;
const connect = () => {
if (isDisposed) return;
const token = getToken();
if (!token) {
setStatus('offline');
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(
docId
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(currentClientId)}`;
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
let ws: WebSocket;
try {
ws = new WebSocket(wsUrl);
} catch {
setStatus('offline');
return;
}
wsRef.current = ws;
ws.onopen = () => {
if (isDisposed) {
ws.close();
return;
}
retryCount = 0;
setStatus('connected');
};
ws.onmessage = (event) => {
if (isDisposed) return;
try {
const parsed: DocSyncEvent = JSON.parse(event.data);
if (!parsed || !parsed.type) return;
// Echo protection: ignore events originating from our own clientId (except initial doc_state)
if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) {
return;
}
onRemoteEventRef.current(parsed);
} catch {
// Ignore malformed frames
}
};
ws.onerror = () => {
// Handled in onclose
};
ws.onclose = () => {
if (isDisposed) return;
wsRef.current = null;
retryCount += 1;
setStatus(retryCount > 2 ? 'offline' : 'reconnecting');
const delay = Math.min(1000 * Math.pow(1.6, retryCount), 8000);
reconnectTimerRef.current = window.setTimeout(connect, delay);
};
};
connect();
return () => {
isDisposed = true;
if (reconnectTimerRef.current !== null) {
window.clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (fallbackTimerRef.current !== null) {
window.clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}
if (wsRef.current) {
try {
wsRef.current.onopen = null;
wsRef.current.onmessage = null;
wsRef.current.onerror = null;
wsRef.current.onclose = null;
wsRef.current.close();
} catch {
// ignore
}
wsRef.current = null;
}
};
}, [docId]);
const sendRawEvent = useCallback(
(evt: DocSyncEvent, fallbackFn?: () => void) => {
if (!docId || !canEdit) return;
const ws = wsRef.current;
const payload: DocSyncEvent = {
...evt,
docId,
clientId: clientIdRef.current,
};
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
} else if (fallbackFn) {
if (fallbackTimerRef.current !== null) {
window.clearTimeout(fallbackTimerRef.current);
}
fallbackTimerRef.current = window.setTimeout(() => {
fallbackFn();
}, 600);
}
},
[docId, canEdit]
);
const sendBlockCreated = useCallback(
(block: DocumentBlock, afterBlockId?: string, allBlocks?: DocumentBlock[]) => {
sendRawEvent(
{
type: 'block_created',
block,
afterBlockId,
},
() => {
if (docId && allBlocks) {
api
.patchDocumentBlocks(docId, {
upserted: [block],
blockOrder: allBlocks.map((b) => b.id),
})
.catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendBlockUpdated = useCallback(
(block: DocumentBlock) => {
sendRawEvent(
{
type: 'block_updated',
blockId: block.id,
block,
},
() => {
if (docId) {
api.patchDocumentBlocks(docId, { upserted: [block] }).catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendBlockDeleted = useCallback(
(blockId: string, allBlocks?: DocumentBlock[]) => {
sendRawEvent(
{
type: 'block_deleted',
blockId,
},
() => {
if (docId) {
api
.patchDocumentBlocks(docId, {
deletedIds: [blockId],
blockOrder: allBlocks ? allBlocks.map((b) => b.id) : undefined,
})
.catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendBlockMoved = useCallback(
(blockId: string, newIndex: number, level?: number, allBlocks?: DocumentBlock[]) => {
sendRawEvent(
{
type: 'block_moved',
blockId,
newIndex,
level,
},
() => {
if (docId && allBlocks) {
api
.patchDocumentBlocks(docId, {
upserted: allBlocks,
blockOrder: allBlocks.map((b) => b.id),
})
.catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendTitleUpdated = useCallback(
(title: string) => {
sendRawEvent(
{
type: 'title_updated',
title,
},
() => {
if (docId) {
api.updateDocument(docId, { title }).catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendBlocksReplaced = useCallback(
(blocks: DocumentBlock[]) => {
sendRawEvent(
{
type: 'blocks_replaced',
blocks,
},
() => {
if (docId) {
api.updateDocument(docId, { blocks }).catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
return {
status,
clientId: clientIdRef.current,
sendBlockCreated,
sendBlockUpdated,
sendBlockDeleted,
sendBlockMoved,
sendTitleUpdated,
sendBlocksReplaced,
};
}
-380
View File
@@ -1,380 +0,0 @@
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { getToken } from './api';
export type SyncTransportStatus = 'ws' | 'sse' | 'offline';
export interface YjsHybridProviderOptions {
docId: string;
ydoc: Y.Doc;
onStatusChange?: (status: SyncTransportStatus) => void;
onAwareness?: (payload: any) => void;
onSynced?: () => void;
}
export class YjsHybridProvider {
public readonly docId: string;
public readonly ydoc: Y.Doc;
private status: SyncTransportStatus = 'offline';
private indexeddbProvider: IndexeddbPersistence;
private ws: WebSocket | null = null;
private eventSource: EventSource | null = null;
private wsWatchdogTimer: any = null;
private destroyed: boolean = false;
private isConnectingWS: boolean = false;
private isConnectingSSE: boolean = false;
private onStatusChange?: (status: SyncTransportStatus) => void;
private onAwareness?: (payload: any) => void;
private onSynced?: () => void;
constructor(options: YjsHybridProviderOptions) {
this.docId = options.docId;
this.ydoc = options.ydoc;
this.onStatusChange = options.onStatusChange;
this.onAwareness = options.onAwareness;
this.onSynced = options.onSynced;
// 1. Offline-First: Bind IndexedDB persistence
this.indexeddbProvider = new IndexeddbPersistence(`mindsift-doc-${this.docId}`, this.ydoc);
this.indexeddbProvider.on('synced', () => {
if (this.destroyed) return;
if (this.onSynced) {
this.onSynced();
}
});
// 2. Listen to local document updates
this.ydoc.on('update', this.handleDocUpdate);
// 3. Connect via Primary Transport (WebSocket) with 4-second watchdog fallback
this.connect();
// 4. Online/offline window event listeners
if (typeof window !== 'undefined') {
window.addEventListener('online', this.handleOnline);
window.addEventListener('offline', this.handleOffline);
}
}
private setStatus(newStatus: SyncTransportStatus) {
if (this.status !== newStatus) {
this.status = newStatus;
if (this.onStatusChange) {
this.onStatusChange(newStatus);
}
}
}
public getStatus(): SyncTransportStatus {
return this.status;
}
private handleOnline = () => {
if (this.destroyed) return;
if (this.status === 'offline') {
this.connect();
}
};
private handleOffline = () => {
if (this.destroyed) return;
this.setStatus('offline');
};
public connect() {
if (this.destroyed) return;
this.connectWebSocket();
}
private connectWebSocket() {
if (this.destroyed || this.isConnectingWS) return;
this.isConnectingWS = true;
this.cleanupWebSocket();
const token = getToken() || '';
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(this.docId)}?token=${encodeURIComponent(token)}`;
// 4-second watchdog timer: If WebSocket doesn't establish within 4 seconds, fallback to HTTP/SSE
this.clearWatchdog();
this.wsWatchdogTimer = setTimeout(() => {
if (this.status !== 'ws') {
console.warn(`[YjsProvider] WebSocket timeout (4s) for doc ${this.docId}. Switching to HTTP fallback (SSE + POST)...`);
this.fallbackToHTTP();
}
}, 4000);
try {
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
this.ws = ws;
ws.onopen = () => {
this.isConnectingWS = false;
this.clearWatchdog();
this.cleanupSSE();
this.setStatus('ws');
};
ws.onmessage = (event) => {
if (this.destroyed) return;
if (event.data instanceof ArrayBuffer) {
const update = new Uint8Array(event.data);
Y.applyUpdate(this.ydoc, update, 'remote');
return;
}
try {
const data = JSON.parse(event.data);
if (data.event === 'CRDT_INIT' && data.payload) {
this.handleInit(data.payload);
} else if (data.event === 'CRDT_UPDATE' && data.payload) {
this.handleRemoteUpdate(data.payload);
} else if (data.event === 'CRDT_AWARENESS' && data.payload) {
if (this.onAwareness) {
this.onAwareness(data.payload);
}
}
} catch (err) {
console.error('[YjsProvider] WS message parse error:', err);
}
};
ws.onerror = (err) => {
console.warn(`[YjsProvider] WebSocket error on doc ${this.docId}:`, err);
this.isConnectingWS = false;
this.fallbackToHTTP();
};
ws.onclose = (event) => {
this.isConnectingWS = false;
// If closed abnormally (proxy drop, 1006, etc.), fallback to HTTP
if (!this.destroyed && this.status === 'ws') {
console.warn(`[YjsProvider] WebSocket closed (code ${event.code}). Falling back to HTTP/SSE...`);
this.fallbackToHTTP();
}
};
} catch (e) {
console.warn(`[YjsProvider] WebSocket creation failed:`, e);
this.isConnectingWS = false;
this.fallbackToHTTP();
}
}
private fallbackToHTTP() {
if (this.destroyed) return;
this.cleanupWebSocket();
this.connectSSE();
}
private connectSSE() {
if (this.destroyed || this.isConnectingSSE) return;
this.isConnectingSSE = true;
this.cleanupSSE();
const token = getToken() || '';
const sseUrl = `/api/v1/sync/docs/${encodeURIComponent(this.docId)}/stream?token=${encodeURIComponent(token)}`;
try {
const es = new EventSource(sseUrl);
this.eventSource = es;
es.onopen = () => {
this.isConnectingSSE = false;
this.setStatus('sse');
};
es.onmessage = (event) => {
if (this.destroyed) return;
try {
const data = JSON.parse(event.data);
if (data.event === 'CRDT_INIT' && data.payload) {
this.handleInit(data.payload);
} else if (data.event === 'CRDT_UPDATE' && data.payload) {
this.handleRemoteUpdate(data.payload);
} else if (data.event === 'CRDT_AWARENESS' && data.payload) {
if (this.onAwareness) {
this.onAwareness(data.payload);
}
}
} catch {}
};
es.onerror = () => {
this.isConnectingSSE = false;
if (!this.destroyed) {
this.setStatus('offline');
}
};
} catch (e) {
this.isConnectingSSE = false;
this.setStatus('offline');
}
}
private handleInit(payload: { state?: string; updates?: string[] }) {
if (payload.state) {
try {
const stateBytes = this.base64ToUint8(payload.state);
Y.applyUpdate(this.ydoc, stateBytes, 'remote');
} catch (e) {
console.error('[YjsProvider] Failed to apply initial state:', e);
}
}
if (Array.isArray(payload.updates)) {
for (const u of payload.updates) {
if (u) {
try {
const updateBytes = this.base64ToUint8(u);
Y.applyUpdate(this.ydoc, updateBytes, 'remote');
} catch (e) {
console.error('[YjsProvider] Failed to apply initial update:', e);
}
}
}
}
if (this.onSynced) {
this.onSynced();
}
}
private handleRemoteUpdate(payload: { update?: string }) {
if (payload.update) {
try {
const bytes = this.base64ToUint8(payload.update);
Y.applyUpdate(this.ydoc, bytes, 'remote');
} catch (e) {
console.error('[YjsProvider] Failed to apply remote update:', e);
}
}
}
private handleDocUpdate = (update: Uint8Array, origin: any) => {
// Only send updates originating locally (ignore 'remote' and 'indexeddb')
if (this.destroyed || origin === 'remote' || origin === this.indexeddbProvider) {
return;
}
if (this.status === 'ws' && this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(update);
return;
} catch (e) {
console.warn('[YjsProvider] Failed to send update over WS, falling back to HTTP POST:', e);
this.fallbackToHTTP();
}
}
// Upstream via HTTP POST fallback
this.sendUpdateViaHTTP(update);
};
private async sendUpdateViaHTTP(update: Uint8Array) {
const token = getToken() || '';
const url = `/api/v1/sync/docs/${encodeURIComponent(this.docId)}/update?token=${encodeURIComponent(token)}`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: new Blob([update as any]),
});
if (!res.ok) {
console.warn(`[YjsProvider] HTTP sync update status: ${res.status}`);
}
} catch (e) {
console.warn('[YjsProvider] Network error during HTTP sync update (persisted locally in IndexedDB):', e);
this.setStatus('offline');
}
}
public sendAwareness(payload: any) {
if (this.destroyed) return;
if (this.status === 'ws' && this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify({
event: 'CRDT_AWARENESS',
docId: this.docId,
payload,
}));
return;
} catch {}
}
// HTTP awareness fallback
const token = getToken() || '';
fetch(`/api/v1/documents/${encodeURIComponent(this.docId)}/awareness`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(payload),
}).catch(() => {});
}
private clearWatchdog() {
if (this.wsWatchdogTimer) {
clearTimeout(this.wsWatchdogTimer);
this.wsWatchdogTimer = null;
}
}
private cleanupWebSocket() {
this.clearWatchdog();
if (this.ws) {
try {
this.ws.onopen = null;
this.ws.onmessage = null;
this.ws.onerror = null;
this.ws.onclose = null;
this.ws.close();
} catch {}
this.ws = null;
}
}
private cleanupSSE() {
if (this.eventSource) {
try {
this.eventSource.onopen = null;
this.eventSource.onmessage = null;
this.eventSource.onerror = null;
this.eventSource.close();
} catch {}
this.eventSource = null;
}
}
private base64ToUint8(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
public destroy() {
this.destroyed = true;
this.clearWatchdog();
this.cleanupWebSocket();
this.cleanupSSE();
this.ydoc.off('update', this.handleDocUpdate);
if (this.indexeddbProvider) {
try {
this.indexeddbProvider.destroy();
} catch {}
}
if (typeof window !== 'undefined') {
window.removeEventListener('online', this.handleOnline);
window.removeEventListener('offline', this.handleOffline);
}
}
}
+25 -225
View File
@@ -3,7 +3,6 @@ package api
import ( import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -32,7 +31,7 @@ type Server struct {
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
if wsHub != nil && store != nil { if wsHub != nil && store != nil {
wsHub.SetCRDTStore(store) wsHub.SetDocStore(store)
} }
srv := &Server{ srv := &Server{
cfg: cfg, cfg: cfg,
@@ -70,8 +69,6 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket) r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket) r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
r.Get("/api/v1/ws/docs/{id}", s.handleDocWebSocket) r.Get("/api/v1/ws/docs/{id}", s.handleDocWebSocket)
r.Get("/api/v1/sync/docs/{id}/stream", s.handleDocSyncStream)
r.Post("/api/v1/sync/docs/{id}/update", s.handleDocSyncUpdate)
r.Get("/api/v1/events", s.handleSSE) r.Get("/api/v1/events", s.handleSSE)
r.Post("/api/v1/events/rooms", s.handleSSERooms) r.Post("/api/v1/events/rooms", s.handleSSERooms)
@@ -144,18 +141,6 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare) r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare)
r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare) r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare)
// Document Locking
r.Get("/api/v1/documents/{id}/lock", s.handleGetDocLock)
r.Post("/api/v1/documents/{id}/lock", s.handleLockDoc)
r.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock)
r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc)
// Document CRDT Realtime Sync
r.Get("/api/v1/documents/{id}/crdt", s.handleGetDocCRDT)
r.Put("/api/v1/documents/{id}/crdt", s.handleSaveDocCRDT)
r.Post("/api/v1/documents/{id}/awareness", s.handlePostDocAwareness)
r.Delete("/api/v1/documents/{id}/awareness", s.handleDeleteDocAwareness)
// Shared Lists & Realtime Collaboration // Shared Lists & Realtime Collaboration
r.Get("/api/v1/lists", s.handleGetAllLists) r.Get("/api/v1/lists", s.handleGetAllLists)
r.Post("/api/v1/lists", s.handleCreateList) r.Post("/api/v1/lists", s.handleCreateList)
@@ -864,6 +849,18 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden") writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return return
} }
if s.wsHub != nil {
if liveTitle, liveBlocks, ok := s.wsHub.GetActiveDocState(docID); ok {
if liveTitle != "" {
doc.Title = liveTitle
}
if len(liveBlocks) > 0 {
doc.Blocks = liveBlocks
doc.MarkdownCache = domain.CompileBlocksToMarkdown(liveBlocks)
doc.Content = doc.MarkdownCache
}
}
}
writeJSON(w, http.StatusOK, doc) writeJSON(w, http.StatusOK, doc)
} }
@@ -889,6 +886,9 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
if s.wsHub != nil && (req.Title != nil || req.Blocks != nil) {
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
}
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil { if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc) s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
@@ -911,6 +911,9 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
if s.wsHub != nil {
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
}
patchEvent := domain.DocBlocksPatchEvent{ patchEvent := domain.DocBlocksPatchEvent{
DocID: docID, DocID: docID,
@@ -933,6 +936,9 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id") docID := chi.URLParam(r, "id")
if s.wsHub != nil {
s.wsHub.CloseDocRoom(docID)
}
if err := s.store.DeleteDocument(userID, docID); err != nil { if err := s.store.DeleteDocument(userID, docID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -1307,30 +1313,7 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, users) writeJSON(w, http.StatusOK, users)
} }
// --- DOCUMENT LOCKING (Neutralized - Yjs CRDT Pessimistic Locks Removed) --- // --- DOCUMENT REALTIME WEBSOCKET ROOM ---
func (s *Server) handleGetDocLock(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": false})
}
func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
writeJSON(w, http.StatusOK, map[string]interface{}{
"locked": false,
"acquired": true,
"docId": docID,
})
}
func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"extended": true})
}
func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": true})
}
// --- DOCUMENT CRDT REALTIME SYNC & DUAL TRANSPORT ---
func (s *Server) authenticateUserFromReq(r *http.Request) string { func (s *Server) authenticateUserFromReq(r *http.Request) string {
token := r.URL.Query().Get("token") token := r.URL.Query().Get("token")
@@ -1379,192 +1362,9 @@ func (s *Server) handleDocWebSocket(w http.ResponseWriter, r *http.Request) {
} }
} }
clientID := r.URL.Query().Get("clientId")
room := s.wsHub.GetOrCreateDocRoom(docID) room := s.wsHub.GetOrCreateDocRoom(docID)
room.ServeDocWS(w, r, userID) room.ServeDocWS(w, r, userID, clientID)
}
// GET /api/v1/sync/docs/{id}/stream
func (s *Server) handleDocSyncStream(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
if docID == "" {
writeError(w, http.StatusBadRequest, "docId erforderlich")
return
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
userID = auth.GetUserID(r.Context())
}
if userID == "" {
userID = "anonymous"
}
if userID != "anonymous" {
if _, err := s.store.GetDocumentByID(userID, docID); err != nil {
writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument")
return
}
}
room := s.wsHub.GetOrCreateDocRoom(docID)
room.ServeDocSSE(w, r, userID)
}
// POST /api/v1/sync/docs/{id}/update
func (s *Server) handleDocSyncUpdate(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
if docID == "" {
writeError(w, http.StatusBadRequest, "docId erforderlich")
return
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
userID = auth.GetUserID(r.Context())
}
if userID == "" {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
return
}
doc, err := s.store.GetDocumentByID(userID, docID)
if err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
if !doc.IsOwner && doc.Permission != "write" {
writeError(w, http.StatusForbidden, "Keine Schreibberechtigung")
return
}
contentType := r.Header.Get("Content-Type")
var updateBytes []byte
if strings.Contains(contentType, "application/octet-stream") {
body, err := io.ReadAll(io.LimitReader(r.Body, 8*1024*1024))
if err != nil {
writeError(w, http.StatusBadRequest, "Fehler beim Lesen des Payloads")
return
}
updateBytes = body
} else {
var req struct {
Update string `json:"update"`
State string `json:"state"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.State != "" {
if raw, err := base64.StdEncoding.DecodeString(req.State); err == nil && len(raw) > 0 {
room := s.wsHub.GetOrCreateDocRoom(docID)
room.CompactState(raw, userID)
}
}
if req.Update != "" {
raw, err := base64.StdEncoding.DecodeString(req.Update)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges Base64 für Update")
return
}
updateBytes = raw
}
}
if len(updateBytes) > 0 {
room := s.wsHub.GetOrCreateDocRoom(docID)
room.ApplyUpdate(updateBytes, userID, nil, nil)
}
writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "docId": docID})
}
func (s *Server) handleGetDocCRDT(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
if _, err := s.store.GetDocumentByID(userID, docID); err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
room := s.wsHub.GetOrCreateDocRoom(docID)
b64State, b64Updates := room.GetInitialSnapshot()
writeJSON(w, http.StatusOK, map[string]interface{}{
"docId": docID,
"state": b64State,
"updates": b64Updates,
})
}
func (s *Server) handleSaveDocCRDT(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
doc, err := s.store.GetDocumentByID(userID, docID)
if err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
if !doc.IsOwner && doc.Permission != "write" {
writeError(w, http.StatusForbidden, "Keine Schreibberechtigung")
return
}
var req struct {
State string `json:"state"`
Update string `json:"update"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
return
}
room := s.wsHub.GetOrCreateDocRoom(docID)
if req.Update != "" {
raw, err := base64.StdEncoding.DecodeString(req.Update)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges Base64 Format für Update")
return
}
room.ApplyUpdate(raw, userID, nil, nil)
}
if req.State != "" {
raw, err := base64.StdEncoding.DecodeString(req.State)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges Base64 Format")
return
}
room.CompactState(raw, userID)
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handlePostDocAwareness(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
doc, err := s.store.GetDocumentByID(userID, docID)
if err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
_ = doc
var payload json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
s.wsHub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS", payload, nil)
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleDeleteDocAwareness(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
s.wsHub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
"docId": docID,
"userId": userID,
}, nil)
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
} }
// --- REALTIME LISTS & SHARING --- // --- REALTIME LISTS & SHARING ---
+50 -155
View File
@@ -3,7 +3,6 @@ package api
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -842,9 +841,9 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
} }
} }
func TestDocumentCRDTEndpoints(t *testing.T) { func TestDocRoomLiveStateAndDebouncedFlush(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "crdt_api_test.db") dbPath := filepath.Join(tmpDir, "docroom_test.db")
store, err := storage.NewStore(dbPath) store, err := storage.NewStore(dbPath)
if err != nil { if err != nil {
@@ -860,59 +859,76 @@ func TestDocumentCRDTEndpoints(t *testing.T) {
srv := NewServer(cfg, store, hub, nil) srv := NewServer(cfg, store, hub, nil)
router := srv.Router() router := srv.Router()
user, err := store.CreateUser("crdt_api@example.com", "hash", "CRDT API User", "member") user, err := store.CreateUser("outliner_ws@example.com", "hash", "WS Outliner User", "member")
if err != nil { if err != nil {
t.Fatalf("CreateUser failed: %v", err) t.Fatalf("CreateUser failed: %v", err)
} }
token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role) token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role)
doc, err := store.CreateDocument(user.ID, "", "API CRDT Doc", "📄", "", nil, nil, false) doc, err := store.CreateDocument(user.ID, "", "Initial Title", "📄", "", nil, nil, false)
if err != nil { if err != nil {
t.Fatalf("CreateDocument failed: %v", err) t.Fatalf("CreateDocument failed: %v", err)
} }
// 1. Initial GET /api/v1/documents/{id}/crdt // 1. Mutate in-memory DocRoom before 1.5s flush fires
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID+"/crdt", nil) room := hub.GetOrCreateDocRoom(doc.ID)
newContent := "Live RAM Block #ramtag"
newLevel := 1
room.HandleEvent(ws.DocSyncEvent{
Type: "title_updated",
ClientID: "cli_1",
Title: "RAM Updated Title",
}, nil)
room.HandleEvent(ws.DocSyncEvent{
Type: "block_created",
ClientID: "cli_1",
AfterBlockID: doc.Blocks[0].ID,
Block: &domain.DocumentBlock{
ID: "blk_ram_2",
Type: "text",
Content: newContent,
Level: newLevel,
},
}, nil)
// 2. Verify GET /api/v1/documents/{id} immediately returns the un-flushed in-memory state
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil)
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from GET crdt, got %d: %s", rec.Code, rec.Body.String()) t.Fatalf("expected 200 OK from GET document, got %d: %s", rec.Code, rec.Body.String())
}
var liveDoc domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &liveDoc)
if liveDoc.Title != "RAM Updated Title" {
t.Fatalf("expected live RAM title 'RAM Updated Title', got '%s'", liveDoc.Title)
}
if len(liveDoc.Blocks) != 2 || liveDoc.Blocks[1].Content != newContent {
t.Fatalf("expected 2 live blocks with RAM content, got %+v", liveDoc.Blocks)
} }
// 2. PUT /api/v1/documents/{id}/crdt with base64 state // 3. Flush room to SQLite and verify persistence + tag extraction + markdown_cache
savePayload := []byte(`{"state":"AQIDBA=="}`) room.Flush()
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID+"/crdt", bytes.NewReader(savePayload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { persistedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
t.Fatalf("expected 200 OK from PUT crdt, got %d: %s", rec.Code, rec.Body.String()) if err != nil {
t.Fatalf("GetDocumentByID failed: %v", err)
} }
if persistedDoc.Title != "RAM Updated Title" {
// 3. Verify state persisted t.Fatalf("expected persisted title 'RAM Updated Title', got '%s'", persistedDoc.Title)
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID+"/crdt", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from GET crdt, got %d", rec.Code)
} }
var crdtResp struct { if !strings.Contains(persistedDoc.MarkdownCache, " - Live RAM Block #ramtag") {
DocID string `json:"docId"` t.Fatalf("expected compiled markdown_cache to contain indented bullet, got:\n%s", persistedDoc.MarkdownCache)
State string `json:"state"`
} }
_ = json.Unmarshal(rec.Body.Bytes(), &crdtResp) tags, _ := store.GetWorkspaceTags(user.ID)
if crdtResp.State != "AQIDBA==" { if len(tags) != 1 || tags[0].Tag != "ramtag" {
t.Fatalf("expected state 'AQIDBA==', got '%s'", crdtResp.State) t.Fatalf("expected extracted #ramtag in block_tags, got %+v", tags)
} }
} }
func TestSSEAndAwarenessEndpoints(t *testing.T) { func TestSSEGlobalEndpoint(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test_sse.db") dbPath := filepath.Join(t.TempDir(), "test_sse.db")
store, err := storage.NewStore(dbPath) store, err := storage.NewStore(dbPath)
if err != nil { if err != nil {
@@ -921,7 +937,6 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
defer store.Close() defer store.Close()
hub := ws.NewHub() hub := ws.NewHub()
hub.SetCRDTStore(store)
cfg := &config.Config{ cfg := &config.Config{
JWTSecret: "test-jwt-secret-key", JWTSecret: "test-jwt-secret-key",
AllowRegistration: false, AllowRegistration: false,
@@ -929,7 +944,6 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
srv := NewServer(cfg, store, hub, nil) srv := NewServer(cfg, store, hub, nil)
router := srv.Router() router := srv.Router()
// 1. Setup user
setupBody := []byte(`{ setupBody := []byte(`{
"email": "sse_user@example.com", "email": "sse_user@example.com",
"password": "secretpassword", "password": "secretpassword",
@@ -952,51 +966,13 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
_ = json.Unmarshal(rec.Body.Bytes(), &authResp) _ = json.Unmarshal(rec.Body.Bytes(), &authResp)
token := authResp.Token token := authResp.Token
// 2. Create Document
doc, err := store.CreateDocument(authResp.User.ID, "", "SSE Collaborative Note", "📄", "", nil, nil, false)
if err != nil {
t.Fatalf("failed to create document: %v", err)
}
// 3. Test Awareness POST and DELETE
awarenessBody := []byte(`{"userId":"` + authResp.User.ID + `","activeBlockId":"blk_123"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/awareness", bytes.NewReader(awarenessBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from POST awareness, got %d: %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodDelete, "/api/v1/documents/"+doc.ID+"/awareness", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from DELETE awareness, got %d", rec.Code)
}
// 4. Test Delta CRDT Update via PUT /api/v1/documents/{id}/crdt
updateBody := []byte(`{"update":"AQIDBA=="}`)
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID+"/crdt", bytes.NewReader(updateBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from PUT crdt update, got %d: %s", rec.Code, rec.Body.String())
}
// 5. Test SSE Connection GET /api/v1/events
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
req = httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+token+"&docId="+doc.ID, nil).WithContext(ctx) req = httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+token, nil).WithContext(ctx)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
go func() { go func() {
// Cancel context after short moment to terminate stream cleanly
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
cancel() cancel()
}() }()
@@ -1007,92 +983,11 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
if !strings.Contains(contentType, "text/event-stream") { if !strings.Contains(contentType, "text/event-stream") {
t.Fatalf("expected Content-Type text/event-stream, got %s", contentType) t.Fatalf("expected Content-Type text/event-stream, got %s", contentType)
} }
if rec.Header().Get("X-Accel-Buffering") != "no" {
t.Fatalf("expected X-Accel-Buffering: no, got %s", rec.Header().Get("X-Accel-Buffering"))
}
bodyStr := rec.Body.String() bodyStr := rec.Body.String()
if !strings.Contains(bodyStr, "SSE_CONNECTED") { if !strings.Contains(bodyStr, "SSE_CONNECTED") {
t.Fatalf("expected SSE_CONNECTED event in body, got: %s", bodyStr) t.Fatalf("expected SSE_CONNECTED event in body, got: %s", bodyStr)
} }
} }
func TestDocRoomDualTransportAndDebouncedPersistence(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test_dual_transport.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to init store: %v", err)
}
defer store.Close()
cfg := &config.Config{
JWTSecret: "test-secret-dual-transport",
AllowRegistration: true,
}
hub := ws.NewHub()
srv := NewServer(cfg, store, hub, nil)
router := srv.Router()
user, _ := store.CreateUser("dual@test.local", "hash", "Dual User", "member")
token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role)
doc, _ := store.CreateDocument(user.ID, "", "Dual Transport Doc", "📄", "", nil, nil, false)
// 1. Test POST /api/v1/sync/docs/{id}/update with JSON base64
upd1 := base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03})
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync/docs/"+doc.ID+"/update", bytes.NewReader([]byte(`{"update":"`+upd1+`"}`)))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from sync update, got %d: %s", rec.Code, rec.Body.String())
}
// 2. Test POST /api/v1/sync/docs/{id}/update with octet-stream binary
binaryUpd := []byte{0x04, 0x05, 0x06}
req = httptest.NewRequest(http.MethodPost, "/api/v1/sync/docs/"+doc.ID+"/update", bytes.NewReader(binaryUpd))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/octet-stream")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from binary sync update, got %d: %s", rec.Code, rec.Body.String())
}
// 3. Test GET /api/v1/sync/docs/{id}/stream SSE connection
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req = httptest.NewRequest(http.MethodGet, "/api/v1/sync/docs/"+doc.ID+"/stream?token="+token, nil).WithContext(ctx)
rec = httptest.NewRecorder()
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
router.ServeHTTP(rec, req)
if !strings.Contains(rec.Header().Get("Content-Type"), "text/event-stream") {
t.Fatalf("expected text/event-stream, got %s", rec.Header().Get("Content-Type"))
}
bodyStr := rec.Body.String()
if !strings.Contains(bodyStr, "CRDT_INIT") {
t.Fatalf("expected CRDT_INIT in SSE stream, got %s", bodyStr)
}
// 4. Test Lock Neutrality: document update and block patch work without 423 Locked
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader([]byte(`{"title":"Updated Without Locks"}`)))
updateReq.Header.Set("Authorization", "Bearer "+token)
updateReq.Header.Set("Content-Type", "application/json")
updateRec := httptest.NewRecorder()
router.ServeHTTP(updateRec, updateReq)
if updateRec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from document update, got %d: %s", updateRec.Code, updateRec.Body.String())
}
}
+37 -1
View File
@@ -261,14 +261,48 @@ type SystemSettings struct {
type DocumentBlock struct { type DocumentBlock struct {
ID string `json:"id"` ID string `json:"id"`
DocumentID string `json:"documentId,omitempty"`
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
Content string `json:"content"` Content string `json:"content"`
Checked bool `json:"checked,omitempty"` Checked bool `json:"checked,omitempty"`
Lang string `json:"lang,omitempty"` Lang string `json:"lang,omitempty"`
Level int `json:"level,omitempty"` Level int `json:"level"`
Position float64 `json:"position"`
Collapsed bool `json:"collapsed,omitempty"` Collapsed bool `json:"collapsed,omitempty"`
} }
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
var sb strings.Builder
for _, b := range blocks {
lvl := b.Level
if lvl < 0 {
lvl = 0
}
indent := strings.Repeat(" ", lvl)
switch b.Type {
case "h1":
sb.WriteString(indent + "- # " + b.Content + "\n")
case "h2":
sb.WriteString(indent + "- ## " + b.Content + "\n")
case "h3":
sb.WriteString(indent + "- ### " + b.Content + "\n")
case "todo":
mark := "[ ]"
if b.Checked {
mark = "[x]"
}
sb.WriteString(indent + "- " + mark + " " + b.Content + "\n")
case "code":
sb.WriteString(indent + "- ```" + b.Lang + "\n" + b.Content + "\n" + indent + " ```\n")
case "divider":
sb.WriteString(indent + "- ---\n")
default:
sb.WriteString(indent + "- " + b.Content + "\n")
}
}
return sb.String()
}
type DocBlocksPatchRequest struct { type DocBlocksPatchRequest struct {
Upserted []DocumentBlock `json:"upserted,omitempty"` Upserted []DocumentBlock `json:"upserted,omitempty"`
DeletedIDs []string `json:"deletedIds,omitempty"` DeletedIDs []string `json:"deletedIds,omitempty"`
@@ -316,8 +350,10 @@ type Document struct {
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon"` Icon string `json:"icon"`
Content string `json:"content"` Content string `json:"content"`
MarkdownCache string `json:"markdownCache,omitempty"`
Blocks []DocumentBlock `json:"blocks"` Blocks []DocumentBlock `json:"blocks"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
FolderID string `json:"folderId,omitempty"`
FolderPath string `json:"folderPath,omitempty"` FolderPath string `json:"folderPath,omitempty"`
IsPinned bool `json:"isPinned"` IsPinned bool `json:"isPinned"`
IsArchived bool `json:"isArchived"` IsArchived bool `json:"isArchived"`
+162 -152
View File
@@ -162,9 +162,11 @@ func (s *Store) migrate() error {
CREATE TABLE IF NOT EXISTS documents ( CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL DEFAULT '',
space_id TEXT DEFAULT '', space_id TEXT DEFAULT '',
title TEXT NOT NULL, title TEXT NOT NULL DEFAULT '',
folder_id TEXT NOT NULL DEFAULT '',
markdown_cache TEXT NOT NULL DEFAULT '',
icon TEXT NOT NULL DEFAULT '📄', icon TEXT NOT NULL DEFAULT '📄',
content TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
blocks_json TEXT NOT NULL DEFAULT '[]', blocks_json TEXT NOT NULL DEFAULT '[]',
@@ -172,13 +174,40 @@ func (s *Store) migrate() error {
folder_path TEXT NOT NULL DEFAULT '', folder_path TEXT NOT NULL DEFAULT '',
is_pinned INTEGER NOT NULL DEFAULT 0, is_pinned INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0, is_archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC);
CREATE TABLE IF NOT EXISTS blocks (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
level INT NOT NULL DEFAULT 0,
position REAL NOT NULL DEFAULT 0,
type TEXT NOT NULL DEFAULT 'bullet',
checked INTEGER NOT NULL DEFAULT 0,
collapsed INTEGER NOT NULL DEFAULT 0,
lang TEXT NOT NULL DEFAULT '',
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_blocks_doc_pos ON blocks(document_id, position ASC);
CREATE TABLE IF NOT EXISTS block_tags (
tag TEXT NOT NULL,
document_id TEXT NOT NULL,
block_id TEXT NOT NULL,
PRIMARY KEY(tag, block_id),
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE,
FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);
CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);
CREATE TABLE IF NOT EXISTS doc_shares ( CREATE TABLE IF NOT EXISTS doc_shares (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL, doc_id TEXT NOT NULL,
@@ -208,22 +237,6 @@ func (s *Store) migrate() error {
INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at)
SELECT user_id, id, updated_at FROM documents WHERE is_pinned = 1; SELECT user_id, id, updated_at FROM documents WHERE is_pinned = 1;
CREATE TABLE IF NOT EXISTS doc_block_tags (
id TEXT PRIMARY KEY,
tag TEXT NOT NULL,
user_id TEXT NOT NULL,
doc_id TEXT NOT NULL,
block_id TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(doc_id, block_id, tag),
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_tag ON doc_block_tags(tag);
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_user_tag ON doc_block_tags(user_id, tag);
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_doc ON doc_block_tags(doc_id);
CREATE TABLE IF NOT EXISTS tag_pages ( CREATE TABLE IF NOT EXISTS tag_pages (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
tag TEXT NOT NULL, tag TEXT NOT NULL,
@@ -232,25 +245,6 @@ func (s *Store) migrate() error {
PRIMARY KEY(user_id, tag) PRIMARY KEY(user_id, tag)
); );
CREATE TABLE IF NOT EXISTS document_crdt_state (
doc_id TEXT PRIMARY KEY,
state_blob BLOB NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL DEFAULT '',
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS document_crdt_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
doc_id TEXT NOT NULL,
update_blob BLOB NOT NULL,
created_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_doc_crdt_updates_doc ON document_crdt_updates(doc_id);
CREATE TABLE IF NOT EXISTS lists ( CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -382,17 +376,28 @@ func (s *Store) migrate() error {
"CREATE INDEX IF NOT EXISTS idx_workspace_folders_user ON workspace_folders(user_id);", "CREATE INDEX IF NOT EXISTS idx_workspace_folders_user ON workspace_folders(user_id);",
"CREATE INDEX IF NOT EXISTS idx_workspace_folders_sf ON workspace_folders(shared_folder_id);", "CREATE INDEX IF NOT EXISTS idx_workspace_folders_sf ON workspace_folders(shared_folder_id);",
"CREATE TABLE IF NOT EXISTS document_blocks (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, sort_order REAL NOT NULL DEFAULT 0, type TEXT NOT NULL DEFAULT 'bullet', content TEXT NOT NULL DEFAULT '', level INTEGER NOT NULL DEFAULT 0, checked INTEGER NOT NULL DEFAULT 0, collapsed INTEGER NOT NULL DEFAULT 0, lang TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL, updated_by TEXT NOT NULL DEFAULT '', FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE);", "ALTER TABLE documents ADD COLUMN folder_id TEXT NOT NULL DEFAULT '';",
"CREATE INDEX IF NOT EXISTS idx_document_blocks_doc_order ON document_blocks(doc_id, sort_order);", "ALTER TABLE documents ADD COLUMN markdown_cache TEXT NOT NULL DEFAULT '';",
"CREATE TABLE IF NOT EXISTS blocks (id TEXT PRIMARY KEY, document_id TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', level INT NOT NULL DEFAULT 0, position REAL NOT NULL DEFAULT 0, type TEXT NOT NULL DEFAULT 'bullet', checked INTEGER NOT NULL DEFAULT 0, collapsed INTEGER NOT NULL DEFAULT 0, lang TEXT NOT NULL DEFAULT '', FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE);",
"CREATE INDEX IF NOT EXISTS idx_blocks_doc_pos ON blocks(document_id, position ASC);",
"CREATE TABLE IF NOT EXISTS block_tags (tag TEXT NOT NULL, document_id TEXT NOT NULL, block_id TEXT NOT NULL, PRIMARY KEY(tag, block_id), FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE, FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE);",
"CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);",
"CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);",
} }
for _, q := range columnMigrations { for _, q := range columnMigrations {
_, _ = s.db.Exec(q) _, _ = s.db.Exec(q)
} }
// Clean up legacy space_id from documents and task_lists if empty or orphaned // Clean up legacy space and old document block/crdt tables
_, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`) _, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`) _, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`) _, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_state`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_updates`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_blocks`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS doc_block_tags`)
// Backfill username for existing accounts where username is empty // Backfill username for existing accounts where username is empty
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`) _, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
@@ -445,7 +450,7 @@ func (s *Store) migrate() error {
// Reindex block tags if table is newly added or empty // Reindex block tags if table is newly added or empty
var tagCount int var tagCount int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM doc_block_tags`).Scan(&tagCount) _ = s.db.QueryRow(`SELECT COUNT(*) FROM block_tags`).Scan(&tagCount)
if tagCount == 0 { if tagCount == 0 {
_ = s.reindexAllDocBlockTags() _ = s.reindexAllDocBlockTags()
} }
@@ -1269,7 +1274,7 @@ func (s *Store) migrateWorkspaceToPureDB() {
for _, md := range docsToMigrate { for _, md := range docsToMigrate {
var count int var count int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM document_blocks WHERE doc_id = ?`, md.id).Scan(&count) _ = s.db.QueryRow(`SELECT COUNT(*) FROM blocks WHERE document_id = ?`, md.id).Scan(&count)
if count == 0 && md.blocksJSON != "" && md.blocksJSON != "[]" { if count == 0 && md.blocksJSON != "" && md.blocksJSON != "[]" {
var blocks []domain.DocumentBlock var blocks []domain.DocumentBlock
if err := json.Unmarshal([]byte(md.blocksJSON), &blocks); err == nil && len(blocks) > 0 { if err := json.Unmarshal([]byte(md.blocksJSON), &blocks); err == nil && len(blocks) > 0 {
@@ -1327,17 +1332,17 @@ func (s *Store) migrateWorkspaceToPureDB() {
func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock { func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT id, type, content, level, checked, collapsed, lang SELECT id, document_id, type, content, level, position, checked, collapsed, lang
FROM document_blocks FROM blocks
WHERE doc_id = ? WHERE document_id = ?
ORDER BY sort_order ASC, rowid ASC ORDER BY position ASC, rowid ASC
`, docID) `, docID)
if err == nil { if err == nil {
blocks := make([]domain.DocumentBlock, 0) blocks := make([]domain.DocumentBlock, 0)
for rows.Next() { for rows.Next() {
var b domain.DocumentBlock var b domain.DocumentBlock
var chk, col int var chk, col int
if scanErr := rows.Scan(&b.ID, &b.Type, &b.Content, &b.Level, &chk, &col, &b.Lang); scanErr == nil { if scanErr := rows.Scan(&b.ID, &b.DocumentID, &b.Type, &b.Content, &b.Level, &b.Position, &chk, &col, &b.Lang); scanErr == nil {
b.Checked = chk != 0 b.Checked = chk != 0
b.Collapsed = col != 0 b.Collapsed = col != 0
blocks = append(blocks, b) blocks = append(blocks, b)
@@ -1359,31 +1364,24 @@ func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.Docume
return blocks return blocks
} }
func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.DocumentBlock, now string) { func (s *Store) replaceDocBlocksInDB(docID, _ string, blocks []domain.DocumentBlock, now string) {
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, docID) _, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, docID)
if len(blocks) == 0 { if len(blocks) > 0 {
return
}
stmt, err := s.db.Prepare(` stmt, err := s.db.Prepare(`
INSERT INTO document_blocks (id, doc_id, sort_order, type, content, level, checked, collapsed, lang, updated_at, updated_by) INSERT INTO blocks (id, document_id, content, level, position, type, checked, collapsed, lang)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
doc_id = excluded.doc_id, document_id = excluded.document_id,
sort_order = excluded.sort_order,
type = excluded.type,
content = excluded.content, content = excluded.content,
level = excluded.level, level = excluded.level,
position = excluded.position,
type = excluded.type,
checked = excluded.checked, checked = excluded.checked,
collapsed = excluded.collapsed, collapsed = excluded.collapsed,
lang = excluded.lang, lang = excluded.lang
updated_at = excluded.updated_at,
updated_by = excluded.updated_by
`) `)
if err != nil { if err == nil {
return
}
defer stmt.Close() defer stmt.Close()
for i, b := range blocks { for i, b := range blocks {
bID := strings.TrimSpace(b.ID) bID := strings.TrimSpace(b.ID)
if bID == "" { if bID == "" {
@@ -1401,8 +1399,76 @@ func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.Docum
if b.Collapsed { if b.Collapsed {
col = 1 col = 1
} }
_, _ = stmt.Exec(bID, docID, float64((i+1)*1000), bType, b.Content, b.Level, chk, col, b.Lang, now, userID) pos := b.Position
if pos <= 0 {
pos = float64((i + 1) * 1000)
} }
_, _ = stmt.Exec(bID, docID, b.Content, b.Level, pos, bType, chk, col, b.Lang)
}
}
}
// Update markdown_cache on documents
mdCache := domain.CompileBlocksToMarkdown(blocks)
_, _ = s.db.Exec(`UPDATE documents SET markdown_cache = ?, updated_at = ? WHERE id = ?`, mdCache, now, docID)
}
func (s *Store) LoadDocumentState(docID string) (string, []domain.DocumentBlock, error) {
var title, blocksJSON string
err := s.db.QueryRow(`SELECT title, blocks_json FROM documents WHERE id = ?`, docID).Scan(&title, &blocksJSON)
if err != nil {
return "", nil, err
}
blocks := s.loadDocBlocks(docID, blocksJSON)
return title, blocks, nil
}
// FlushDocumentState persists the in-memory blocks into `blocks`, extracts all #tags into `block_tags`,
// and compiles the hierarchical Markdown into `documents.markdown_cache`.
func (s *Store) FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error {
now := domain.NowISO()
// Normalize positions sequentially
normalized := make([]domain.DocumentBlock, len(blocks))
var plainBuilder strings.Builder
for i, b := range blocks {
if strings.TrimSpace(b.ID) == "" {
b.ID = domain.NewID("blk")
}
if b.Type == "" {
b.Type = "bullet"
}
b.DocumentID = docID
b.Position = float64((i + 1) * 1000)
normalized[i] = b
if b.Content != "" {
plainBuilder.WriteString(b.Content)
plainBuilder.WriteString("\n")
}
}
s.replaceDocBlocksInDB(docID, "", normalized, now)
_ = s.indexDocBlockTags(docID, "", normalized)
mdCache := domain.CompileBlocksToMarkdown(normalized)
plainContent := plainBuilder.String()
blocksJSON, _ := json.Marshal(normalized)
if strings.TrimSpace(title) != "" {
_, err := s.db.Exec(`
UPDATE documents
SET title = ?, markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ?
WHERE id = ?
`, title, mdCache, plainContent, string(blocksJSON), now, docID)
return err
}
_, err := s.db.Exec(`
UPDATE documents
SET markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ?
WHERE id = ?
`, mdCache, plainContent, string(blocksJSON), now, docID)
return err
} }
// PatchDocumentBlocks applies atomic block-level upserts, deletions, and order reconciliation without overwriting concurrent edits from other sessions. // PatchDocumentBlocks applies atomic block-level upserts, deletions, and order reconciliation without overwriting concurrent edits from other sessions.
@@ -1417,16 +1483,16 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
now := domain.NowISO() now := domain.NowISO()
// Ensure document_blocks is seeded if previously only in blocks_json // Ensure blocks table is seeded if previously only in blocks_json
var existingCount int var existingCount int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM document_blocks WHERE doc_id = ?`, docID).Scan(&existingCount) _ = s.db.QueryRow(`SELECT COUNT(*) FROM blocks WHERE document_id = ?`, docID).Scan(&existingCount)
if existingCount == 0 && len(doc.Blocks) > 0 { if existingCount == 0 && len(doc.Blocks) > 0 {
s.replaceDocBlocksInDB(docID, doc.UserID, doc.Blocks, doc.UpdatedAt) s.replaceDocBlocksInDB(docID, doc.UserID, doc.Blocks, doc.UpdatedAt)
} }
// Read current ordered block IDs from DB before patch // Read current ordered block IDs from DB before patch
var existingOrder []string var existingOrder []string
ordRows, err := s.db.Query(`SELECT id FROM document_blocks WHERE doc_id = ? ORDER BY sort_order ASC, rowid ASC`, docID) ordRows, err := s.db.Query(`SELECT id FROM blocks WHERE document_id = ? ORDER BY position ASC, rowid ASC`, docID)
if err == nil { if err == nil {
for ordRows.Next() { for ordRows.Next() {
var bid string var bid string
@@ -1445,7 +1511,7 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
continue continue
} }
deletedSet[delID] = true deletedSet[delID] = true
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ? AND id = ?`, docID, delID) _, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ? AND id = ?`, docID, delID)
} }
// Filter existingOrder to exclude deleted blocks // Filter existingOrder to exclude deleted blocks
@@ -1479,18 +1545,16 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
} }
defaultSort := float64((len(survivingExisting) + idx + 1) * 1000) defaultSort := float64((len(survivingExisting) + idx + 1) * 1000)
_, _ = s.db.Exec(` _, _ = s.db.Exec(`
INSERT INTO document_blocks (id, doc_id, sort_order, type, content, level, checked, collapsed, lang, updated_at, updated_by) INSERT INTO blocks (id, document_id, content, level, position, type, checked, collapsed, lang)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
type = excluded.type, type = excluded.type,
content = excluded.content, content = excluded.content,
level = excluded.level, level = excluded.level,
checked = excluded.checked, checked = excluded.checked,
collapsed = excluded.collapsed, collapsed = excluded.collapsed,
lang = excluded.lang, lang = excluded.lang
updated_at = excluded.updated_at, `, bID, docID, b.Content, b.Level, defaultSort, bType, chk, col, b.Lang)
updated_by = excluded.updated_by
`, bID, docID, defaultSort, bType, b.Content, b.Level, chk, col, b.Lang, now, userID)
upsertedIDs = append(upsertedIDs, bID) upsertedIDs = append(upsertedIDs, bID)
survivingSet[bID] = true survivingSet[bID] = true
} }
@@ -1506,8 +1570,6 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
inMerged[bid] = true inMerged[bid] = true
} }
} }
// Any surviving block in DB that was not in req.BlockOrder (e.g. concurrently added by another user)
// is inserted right after its predecessor from survivingExisting!
for i, bid := range survivingExisting { for i, bid := range survivingExisting {
if inMerged[bid] { if inMerged[bid] {
continue continue
@@ -1528,7 +1590,6 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
mergedOrder = append(mergedOrder[:insertPos], append([]string{bid}, mergedOrder[insertPos:]...)...) mergedOrder = append(mergedOrder[:insertPos], append([]string{bid}, mergedOrder[insertPos:]...)...)
inMerged[bid] = true inMerged[bid] = true
} }
// Ensure any upserted block not in BlockOrder is also appended
for _, bid := range upsertedIDs { for _, bid := range upsertedIDs {
if !inMerged[bid] { if !inMerged[bid] {
mergedOrder = append(mergedOrder, bid) mergedOrder = append(mergedOrder, bid)
@@ -1550,9 +1611,9 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
} }
} }
// Update sort_order in document_blocks // Update position in blocks
for idx, bid := range mergedOrder { for idx, bid := range mergedOrder {
_, _ = s.db.Exec(`UPDATE document_blocks SET sort_order = ? WHERE doc_id = ? AND id = ?`, float64((idx+1)*1000), docID, bid) _, _ = s.db.Exec(`UPDATE blocks SET position = ? WHERE document_id = ? AND id = ?`, float64((idx+1)*1000), docID, bid)
} }
// Reload authoritative block list and update document snapshot // Reload authoritative block list and update document snapshot
@@ -1565,12 +1626,14 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
} }
} }
plainContent := plainBuilder.String() plainContent := plainBuilder.String()
mdCache := domain.CompileBlocksToMarkdown(finalBlocks)
blocksJSON, _ := json.Marshal(finalBlocks) blocksJSON, _ := json.Marshal(finalBlocks)
_, _ = s.db.Exec(`UPDATE documents SET content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`, _, _ = s.db.Exec(`UPDATE documents SET markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`,
plainContent, string(blocksJSON), now, docID) mdCache, plainContent, string(blocksJSON), now, docID)
doc.Blocks = finalBlocks doc.Blocks = finalBlocks
doc.MarkdownCache = mdCache
doc.Content = plainContent doc.Content = plainContent
doc.UpdatedAt = now doc.UpdatedAt = now
_ = s.indexDocBlockTags(doc.ID, doc.UserID, finalBlocks) _ = s.indexDocBlockTags(doc.ID, doc.UserID, finalBlocks)
@@ -1578,59 +1641,6 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
return doc, mergedOrder, nil return doc, mergedOrder, nil
} }
// GetDocumentCRDTState returns the compacted CRDT state blob and any pending updates for a document.
func (s *Store) GetDocumentCRDTState(docID string) ([]byte, [][]byte, error) {
var stateBlob []byte
err := s.db.QueryRow(`SELECT state_blob FROM document_crdt_state WHERE doc_id = ?`, docID).Scan(&stateBlob)
if err != nil && err != sql.ErrNoRows {
return nil, nil, err
}
rows, err := s.db.Query(`SELECT update_blob FROM document_crdt_updates WHERE doc_id = ? ORDER BY id ASC`, docID)
if err != nil {
return stateBlob, nil, nil
}
defer rows.Close()
var updates [][]byte
for rows.Next() {
var u []byte
if err := rows.Scan(&u); err == nil && len(u) > 0 {
updates = append(updates, u)
}
}
return stateBlob, updates, nil
}
// AppendDocumentCRDTUpdate appends a new CRDT update delta for a document.
func (s *Store) AppendDocumentCRDTUpdate(docID string, update []byte, userID string) error {
now := domain.NowISO()
_, err := s.db.Exec(`
INSERT INTO document_crdt_updates (doc_id, update_blob, created_at, created_by)
VALUES (?, ?, ?, ?)
`, docID, update, now, userID)
return err
}
// SaveDocumentCRDTState saves a compacted snapshot of the document CRDT state and prunes older incremental updates.
func (s *Store) SaveDocumentCRDTState(docID string, stateBlob []byte, userID string) error {
now := domain.NowISO()
_, err := s.db.Exec(`
INSERT INTO document_crdt_state (doc_id, state_blob, updated_at, updated_by)
VALUES (?, ?, ?, ?)
ON CONFLICT(doc_id) DO UPDATE SET
state_blob = excluded.state_blob,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by
`, docID, stateBlob, now, userID)
if err != nil {
return err
}
// Prune incremental updates older than the snapshot
_, _ = s.db.Exec(`DELETE FROM document_crdt_updates WHERE doc_id = ?`, docID)
return nil
}
// GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID. // GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID.
func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) { func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) {
userSet := make(map[string]bool) userSet := make(map[string]bool)
@@ -1964,6 +1974,8 @@ func (s *Store) scanDocumentWithBlocks(rows interface {
_ = json.Unmarshal([]byte(tagsStr), &d.Tags) _ = json.Unmarshal([]byte(tagsStr), &d.Tags)
d.Tags = stripFolderTags(d.Tags) d.Tags = stripFolderTags(d.Tags)
d.Blocks = s.loadDocBlocks(d.ID, blocksStr) d.Blocks = s.loadDocBlocks(d.ID, blocksStr)
d.MarkdownCache = domain.CompileBlocksToMarkdown(d.Blocks)
d.FolderID = d.FolderPath
return d, nil return d, nil
} }
@@ -2303,8 +2315,8 @@ func (s *Store) DeleteDocument(userID, docID string) error {
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID) _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
return s.DeleteDocShare(userID, docID, userID) return s.DeleteDocShare(userID, docID, userID)
} }
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, docID) _, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, docID)
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID) _, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID)
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, docID) _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, docID)
_, err = s.db.Exec(`DELETE FROM documents WHERE id = ?`, docID) _, err = s.db.Exec(`DELETE FROM documents WHERE id = ?`, docID)
return err return err
@@ -2338,27 +2350,25 @@ func (s *Store) reindexAllDocBlockTags() error {
return nil return nil
} }
func (s *Store) indexDocBlockTags(docID, userID string, blocks []domain.DocumentBlock) error { func (s *Store) indexDocBlockTags(docID, _ string, blocks []domain.DocumentBlock) error {
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID) _, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID)
if len(blocks) == 0 { if len(blocks) == 0 {
return nil return nil
} }
stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO doc_block_tags (id, tag, user_id, doc_id, block_id, created_at) VALUES (?, ?, ?, ?, ?, ?)`) stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO block_tags (tag, document_id, block_id) VALUES (?, ?, ?)`)
if err != nil { if err != nil {
return err return err
} }
defer stmt.Close() defer stmt.Close()
now := domain.NowISO()
for _, b := range blocks { for _, b := range blocks {
if b.Content == "" { if b.Content == "" {
continue continue
} }
tags := domain.ExtractTags(b.Content) tags := domain.ExtractTags(b.Content)
for _, tag := range tags { for _, tag := range tags {
tagID := domain.NewID("dbtag") _, _ = stmt.Exec(tag, docID, b.ID)
_, _ = stmt.Exec(tagID, tag, userID, docID, b.ID, now)
} }
} }
return nil return nil
@@ -2384,9 +2394,9 @@ func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) {
func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) { func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT t.tag, COUNT(DISTINCT t.doc_id || ':' || t.block_id) as count `SELECT t.tag, COUNT(DISTINCT t.document_id || ':' || t.block_id) as count
FROM doc_block_tags t FROM block_tags t
JOIN documents d ON t.doc_id = d.id JOIN documents d ON t.document_id = d.id
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ? LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
LEFT JOIN ( LEFT JOIN (
SELECT dts2.doc_id SELECT dts2.doc_id
@@ -2451,8 +2461,8 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id, `SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
COALESCE(u.display_name, '') as owner_name, COALESCE(u.display_name, '') as owner_name,
COALESCE(s.permission, dts.permission, '') as shared_permission COALESCE(s.permission, dts.permission, '') as shared_permission
FROM doc_block_tags t FROM block_tags t
JOIN documents d ON t.doc_id = d.id JOIN documents d ON t.document_id = d.id
LEFT JOIN users u ON d.user_id = u.id LEFT JOIN users u ON d.user_id = u.id
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ? LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
LEFT JOIN ( LEFT JOIN (
@@ -2768,8 +2778,8 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
// Delete documents from SQLite, blocks, pins, shares, tags, user_folders // Delete documents from SQLite, blocks, pins, shares, tags, user_folders
for _, id := range deletedDocIDs { for _, id := range deletedDocIDs {
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, id) _, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, id) _, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, id) _, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ?`, id) _, _ = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ?`, id) _, _ = s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ?`, id)
+42 -34
View File
@@ -683,9 +683,9 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
} }
} }
func TestDocumentCRDTState(t *testing.T) { func TestFlushDocumentStateAndSchema(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "crdt_test.db") dbPath := filepath.Join(tmpDir, "tabula_rasa_test.db")
store, err := NewStore(dbPath) store, err := NewStore(dbPath)
if err != nil { if err != nil {
@@ -693,52 +693,60 @@ func TestDocumentCRDTState(t *testing.T) {
} }
defer store.Close() defer store.Close()
user, err := store.CreateUser("crdt@example.com", "hash", "CRDT User", "member") user, err := store.CreateUser("outliner@example.com", "hash", "Outliner User", "member")
if err != nil { if err != nil {
t.Fatalf("CreateUser failed: %v", err) t.Fatalf("CreateUser failed: %v", err)
} }
doc, err := store.CreateDocument(user.ID, "", "CRDT Test Doc", "📝", "", nil, nil, false) doc, err := store.CreateDocument(user.ID, "", "Initial Note", "📝", "Projekte", nil, nil, false)
if err != nil { if err != nil {
t.Fatalf("CreateDocument failed: %v", err) t.Fatalf("CreateDocument failed: %v", err)
} }
// 1. Initial CRDT state is empty blocks := []domain.DocumentBlock{
state, updates, err := store.GetDocumentCRDTState(doc.ID) {
ID: "blk_root_1",
Type: "text",
Content: "Architektur Übersicht #backend #go",
Level: 0,
},
{
ID: "blk_child_1",
Type: "todo",
Content: "SQLite WAL Modus aktivieren #sqlite",
Level: 1,
Checked: true,
},
}
if err := store.FlushDocumentState(doc.ID, "Architektur Notiz", blocks); err != nil {
t.Fatalf("FlushDocumentState failed: %v", err)
}
updatedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
if err != nil { if err != nil {
t.Fatalf("GetDocumentCRDTState failed: %v", err) t.Fatalf("GetDocumentByID failed: %v", err)
} }
if len(state) != 0 || len(updates) != 0 { if updatedDoc.Title != "Architektur Notiz" {
t.Fatalf("expected empty CRDT state, got state=%d updates=%d", len(state), len(updates)) t.Fatalf("expected title 'Architektur Notiz', got '%s'", updatedDoc.Title)
}
if len(updatedDoc.Blocks) != 2 {
t.Fatalf("expected 2 blocks, got %d", len(updatedDoc.Blocks))
}
if updatedDoc.Blocks[1].Level != 1 || updatedDoc.Blocks[1].Position != 2000 {
t.Fatalf("expected block[1] level=1 position=2000, got level=%d position=%v", updatedDoc.Blocks[1].Level, updatedDoc.Blocks[1].Position)
}
if updatedDoc.MarkdownCache == "" {
t.Fatalf("expected markdown_cache to be populated")
} }
// 2. Append incremental updates tags, err := store.GetWorkspaceTags(user.ID)
u1 := []byte{1, 2, 3, 4}
u2 := []byte{5, 6, 7, 8}
if err := store.AppendDocumentCRDTUpdate(doc.ID, u1, user.ID); err != nil {
t.Fatalf("AppendDocumentCRDTUpdate 1 failed: %v", err)
}
if err := store.AppendDocumentCRDTUpdate(doc.ID, u2, user.ID); err != nil {
t.Fatalf("AppendDocumentCRDTUpdate 2 failed: %v", err)
}
_, updatesAfter, err := store.GetDocumentCRDTState(doc.ID)
if err != nil || len(updatesAfter) != 2 {
t.Fatalf("expected 2 updates, got %d (err: %v)", len(updatesAfter), err)
}
// 3. Compact snapshot state
snapshot := []byte{99, 100, 101}
if err := store.SaveDocumentCRDTState(doc.ID, snapshot, user.ID); err != nil {
t.Fatalf("SaveDocumentCRDTState failed: %v", err)
}
stateAfter, updatesAfterCompact, err := store.GetDocumentCRDTState(doc.ID)
if err != nil { if err != nil {
t.Fatalf("GetDocumentCRDTState after compact failed: %v", err) t.Fatalf("GetWorkspaceTags failed: %v", err)
} }
if len(stateAfter) != 3 || len(updatesAfterCompact) != 0 { if len(tags) != 3 {
t.Fatalf("expected compacted state of length 3 and 0 updates, got state=%d updates=%d", len(stateAfter), len(updatesAfterCompact)) t.Fatalf("expected 3 extracted tags in block_tags (#backend, #go, #sqlite), got %d: %+v", len(tags), tags)
} }
} }
+368 -299
View File
@@ -1,9 +1,7 @@
package ws package ws
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"sync" "sync"
@@ -19,158 +17,369 @@ type DocWSClient struct {
room *DocRoom room *DocRoom
conn *websocket.Conn conn *websocket.Conn
userID string userID string
clientID string
send chan []byte send chan []byte
} }
// DocSSEClient represents an active SSE connection to a specific document room. // DocSyncEvent is the lightweight JSON payload exchanged over /api/v1/ws/docs/{id}.
type DocSSEClient struct { type DocSyncEvent struct {
hub *Hub Type string `json:"type"`
room *DocRoom DocID string `json:"docId,omitempty"`
id string ClientID string `json:"clientId,omitempty"`
userID string UserID string `json:"userId,omitempty"`
send chan []byte Title string `json:"title,omitempty"`
Block *domain.DocumentBlock `json:"block,omitempty"`
BlockID string `json:"blockId,omitempty"`
AfterBlockID string `json:"afterBlockId,omitempty"`
NewIndex *int `json:"newIndex,omitempty"`
Content *string `json:"content,omitempty"`
Level *int `json:"level,omitempty"`
Checked *bool `json:"checked,omitempty"`
Collapsed *bool `json:"collapsed,omitempty"`
BlockType *string `json:"blockType,omitempty"`
Blocks []domain.DocumentBlock `json:"blocks,omitempty"`
} }
// DocRoom coordinates in-memory real-time updates and debounced persistence for one document. // DocRoom coordinates in-memory real-time block state and 1.5s debounced SQLite persistence for one document.
type DocRoom struct { type DocRoom struct {
mu sync.Mutex mu sync.Mutex
docID string docID string
hub *Hub hub *Hub
stateBlob []byte title string
pendingUpdates [][]byte blocks []domain.DocumentBlock
persistedUpdatesCount int loaded bool
wsClients map[*DocWSClient]bool wsClients map[*DocWSClient]bool
sseClients map[*DocSSEClient]bool
debounceTimer *time.Timer debounceTimer *time.Timer
dirty bool dirty bool
lastUserID string
} }
// GetOrCreateDocRoom returns the active in-memory room for docID or initializes one from SQLite.
func (h *Hub) GetOrCreateDocRoom(docID string) *DocRoom { func (h *Hub) GetOrCreateDocRoom(docID string) *DocRoom {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock()
if h.docRooms == nil { if h.docRooms == nil {
h.docRooms = make(map[string]*DocRoom) h.docRooms = make(map[string]*DocRoom)
} }
room, exists := h.docRooms[docID]
if room, exists := h.docRooms[docID]; exists { if !exists {
return room room = &DocRoom{
}
room := &DocRoom{
docID: docID, docID: docID,
hub: h, hub: h,
wsClients: make(map[*DocWSClient]bool), wsClients: make(map[*DocWSClient]bool),
sseClients: make(map[*DocSSEClient]bool),
} }
// Pre-load current state from CRDT store if available
if h.crdtStore != nil {
stateBlob, updates, err := h.crdtStore.GetDocumentCRDTState(docID)
if err == nil {
room.stateBlob = stateBlob
room.pendingUpdates = updates
room.persistedUpdatesCount = len(updates)
}
}
h.docRooms[docID] = room h.docRooms[docID] = room
}
store := h.docStore
h.mu.Unlock()
room.mu.Lock()
if !room.loaded && store != nil {
title, blocks, err := store.LoadDocumentState(docID)
if err == nil {
room.title = title
room.blocks = normalizeRoomBlocks(docID, blocks)
room.loaded = true
}
}
room.mu.Unlock()
return room return room
} }
// ApplyUpdate processes an incremental Yjs binary update in-memory, broadcasts it immediately // GetActiveDocState returns the live in-memory title and blocks if an active DocRoom exists in RAM.
// to all other connected clients (WS & SSE), and schedules an asynchronous debounced flush to SQLite. func (h *Hub) GetActiveDocState(docID string) (string, []domain.DocumentBlock, bool) {
func (r *DocRoom) ApplyUpdate(update []byte, fromUserID string, senderWS *DocWSClient, senderSSE *DocSSEClient) { h.mu.RLock()
if len(update) == 0 { room, exists := h.docRooms[docID]
h.mu.RUnlock()
if !exists || room == nil {
return "", nil, false
}
room.mu.Lock()
defer room.mu.Unlock()
if !room.loaded {
return "", nil, false
}
blocksCopy := make([]domain.DocumentBlock, len(room.blocks))
copy(blocksCopy, room.blocks)
return room.title, blocksCopy, true
}
// SyncActiveDocRoom updates an in-memory room if one is currently loaded (e.g. after REST PUT/PATCH).
func (h *Hub) SyncActiveDocRoom(docID string, title string, blocks []domain.DocumentBlock) {
h.mu.RLock()
room, exists := h.docRooms[docID]
h.mu.RUnlock()
if !exists || room == nil {
return return
} }
r.mu.Lock() room.mu.Lock()
r.pendingUpdates = append(r.pendingUpdates, update) if title != "" {
r.dirty = true room.title = title
if fromUserID != "" {
r.lastUserID = fromUserID
} }
if blocks != nil {
room.blocks = normalizeRoomBlocks(docID, blocks)
}
room.loaded = true
room.mu.Unlock()
}
b64Update := base64.StdEncoding.EncodeToString(update) // CloseDocRoom removes a document room from memory when a document is deleted.
msg := domain.WSMessage{ func (h *Hub) CloseDocRoom(docID string) {
Event: "CRDT_UPDATE", h.mu.Lock()
Payload: map[string]interface{}{ room, exists := h.docRooms[docID]
"docId": r.docID, if exists {
"update": b64Update, delete(h.docRooms, docID)
"fromUserId": fromUserID, }
h.mu.Unlock()
if exists && room != nil {
room.mu.Lock()
if room.debounceTimer != nil {
room.debounceTimer.Stop()
}
room.dirty = false
for c := range room.wsClients {
_ = c.conn.Close()
}
room.wsClients = make(map[*DocWSClient]bool)
room.mu.Unlock()
}
}
func normalizeRoomBlocks(docID string, blocks []domain.DocumentBlock) []domain.DocumentBlock {
if len(blocks) == 0 {
return []domain.DocumentBlock{
{
ID: docID + "-b0",
DocumentID: docID,
Type: "text",
Content: "",
Level: 0,
Position: 1000,
}, },
Timestamp: domain.NowISO(),
}
msgBytes, _ := json.Marshal(msg)
// Broadcast to all WS clients except sender
for client := range r.wsClients {
if senderWS != nil && client == senderWS {
continue
}
select {
case client.send <- msgBytes:
default:
} }
} }
out := make([]domain.DocumentBlock, len(blocks))
// Broadcast to all SSE clients except sender for i, b := range blocks {
for client := range r.sseClients { if b.ID == "" {
if senderSSE != nil && client == senderSSE { b.ID = domain.NewID("blk")
continue
} }
select { b.DocumentID = docID
case client.send <- msgBytes: if b.Type == "" {
default: b.Type = "text"
} }
if b.Level < 0 {
b.Level = 0
} }
b.Position = float64((i + 1) * 1000)
// Schedule 2.5s debounced persistence out[i] = b
r.scheduleDebouncedFlushLocked() }
r.mu.Unlock() return out
// Also inform legacy room subscribers if any
r.hub.BroadcastRoom("doc:"+r.docID, "CRDT_UPDATE", map[string]interface{}{
"docId": r.docID,
"update": b64Update,
"fromUserId": fromUserID,
}, nil)
} }
// CompactState replaces the incremental pending updates with a full compacted state blob. // GetStateSnapshot returns a copy of the room's current live in-memory title and blocks.
func (r *DocRoom) CompactState(stateBlob []byte, fromUserID string) { func (r *DocRoom) GetStateSnapshot() (string, []domain.DocumentBlock) {
if len(stateBlob) == 0 {
return
}
r.mu.Lock()
r.stateBlob = stateBlob
r.pendingUpdates = nil
r.persistedUpdatesCount = 0
r.dirty = true
if fromUserID != "" {
r.lastUserID = fromUserID
}
r.scheduleDebouncedFlushLocked()
r.mu.Unlock()
}
// BroadcastAwareness forwards user awareness state to all other participants in the room.
func (r *DocRoom) BroadcastAwareness(payload json.RawMessage, fromUserID string, senderWS *DocWSClient, senderSSE *DocSSEClient) {
msg := domain.WSMessage{
Event: "CRDT_AWARENESS",
Payload: payload,
Timestamp: domain.NowISO(),
}
msgBytes, _ := json.Marshal(msg)
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
copy(blocksCopy, r.blocks)
return r.title, blocksCopy
}
// HandleEvent applies a granular block or title mutation to the in-memory room,
// broadcasts it to all OTHER clients in the room (excluding senderClientID for echo protection),
// and resets the 1.5-second inactivity debounce timer for SQLite persistence.
func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
r.mu.Lock()
evt.DocID = r.docID
if sender != nil {
if evt.ClientID == "" {
evt.ClientID = sender.clientID
}
evt.UserID = sender.userID
}
mutated := false
switch evt.Type {
case "title_updated":
if evt.Title != "" || r.title != evt.Title {
r.title = evt.Title
mutated = true
}
case "block_created":
if evt.Block != nil {
newBlock := *evt.Block
if newBlock.ID == "" {
newBlock.ID = domain.NewID("blk")
}
newBlock.DocumentID = r.docID
if newBlock.Type == "" {
newBlock.Type = "text"
}
if newBlock.Level < 0 {
newBlock.Level = 0
}
// Avoid duplicate ID if already created
existsIdx := -1
for i, b := range r.blocks {
if b.ID == newBlock.ID {
existsIdx = i
break
}
}
if existsIdx >= 0 {
r.blocks[existsIdx] = newBlock
} else {
insertIdx := len(r.blocks)
if evt.AfterBlockID != "" {
for i, b := range r.blocks {
if b.ID == evt.AfterBlockID {
insertIdx = i + 1
break
}
}
} else if evt.NewIndex != nil && *evt.NewIndex >= 0 && *evt.NewIndex <= len(r.blocks) {
insertIdx = *evt.NewIndex
}
updated := make([]domain.DocumentBlock, 0, len(r.blocks)+1)
updated = append(updated, r.blocks[:insertIdx]...)
updated = append(updated, newBlock)
updated = append(updated, r.blocks[insertIdx:]...)
r.blocks = normalizeRoomBlocks(r.docID, updated)
}
evt.Block = &newBlock
mutated = true
}
case "block_updated":
targetID := evt.BlockID
if targetID == "" && evt.Block != nil {
targetID = evt.Block.ID
}
if targetID != "" {
for i := range r.blocks {
if r.blocks[i].ID == targetID {
if evt.Block != nil {
r.blocks[i].Content = evt.Block.Content
r.blocks[i].Level = evt.Block.Level
if evt.Block.Type != "" {
r.blocks[i].Type = evt.Block.Type
}
r.blocks[i].Checked = evt.Block.Checked
r.blocks[i].Collapsed = evt.Block.Collapsed
r.blocks[i].Lang = evt.Block.Lang
} else {
if evt.Content != nil {
r.blocks[i].Content = *evt.Content
}
if evt.Level != nil {
lvl := *evt.Level
if lvl < 0 {
lvl = 0
}
r.blocks[i].Level = lvl
}
if evt.Checked != nil {
r.blocks[i].Checked = *evt.Checked
}
if evt.Collapsed != nil {
r.blocks[i].Collapsed = *evt.Collapsed
}
if evt.BlockType != nil && *evt.BlockType != "" {
r.blocks[i].Type = *evt.BlockType
}
}
bCopy := r.blocks[i]
evt.Block = &bCopy
evt.BlockID = targetID
mutated = true
break
}
}
}
case "block_deleted":
if evt.BlockID != "" {
filtered := make([]domain.DocumentBlock, 0, len(r.blocks))
for _, b := range r.blocks {
if b.ID != evt.BlockID {
filtered = append(filtered, b)
}
}
r.blocks = normalizeRoomBlocks(r.docID, filtered)
mutated = true
}
case "block_moved":
if evt.BlockID != "" && evt.NewIndex != nil {
fromIdx := -1
for i, b := range r.blocks {
if b.ID == evt.BlockID {
fromIdx = i
break
}
}
if fromIdx >= 0 {
moving := r.blocks[fromIdx]
if evt.Level != nil {
lvl := *evt.Level
if lvl < 0 {
lvl = 0
}
moving.Level = lvl
}
without := make([]domain.DocumentBlock, 0, len(r.blocks)-1)
without = append(without, r.blocks[:fromIdx]...)
without = append(without, r.blocks[fromIdx+1:]...)
toIdx := *evt.NewIndex
if toIdx < 0 {
toIdx = 0
}
if toIdx > len(without) {
toIdx = len(without)
}
reordered := make([]domain.DocumentBlock, 0, len(r.blocks))
reordered = append(reordered, without[:toIdx]...)
reordered = append(reordered, moving)
reordered = append(reordered, without[toIdx:]...)
r.blocks = normalizeRoomBlocks(r.docID, reordered)
mutated = true
}
}
case "blocks_replaced":
if evt.Blocks != nil {
r.blocks = normalizeRoomBlocks(r.docID, evt.Blocks)
evt.Blocks = r.blocks
mutated = true
}
}
if !mutated {
r.mu.Unlock()
return
}
r.dirty = true
r.loaded = true
msgBytes, err := json.Marshal(evt)
if err == nil {
for client := range r.wsClients { for client := range r.wsClients {
if senderWS != nil && client == senderWS { // Strict Echo Protection: never send back to the originating connection or clientId
if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) {
continue
}
if evt.ClientID != "" && client.clientID == evt.ClientID {
continue continue
} }
select { select {
@@ -178,223 +387,114 @@ func (r *DocRoom) BroadcastAwareness(payload json.RawMessage, fromUserID string,
default: default:
} }
} }
}
for client := range r.sseClients { r.scheduleDebouncedFlushLocked()
if senderSSE != nil && client == senderSSE { r.mu.Unlock()
continue
}
select {
case client.send <- msgBytes:
default:
}
}
} }
func (r *DocRoom) scheduleDebouncedFlushLocked() { func (r *DocRoom) scheduleDebouncedFlushLocked() {
if r.debounceTimer != nil { if r.debounceTimer != nil {
r.debounceTimer.Stop() r.debounceTimer.Stop()
} }
r.debounceTimer = time.AfterFunc(2500*time.Millisecond, func() { r.debounceTimer = time.AfterFunc(1500*time.Millisecond, func() {
r.Flush() r.Flush()
}) })
} }
// Flush persists in-memory pending updates to SQLite asynchronously in a background goroutine. // Flush persists the current in-memory document state (blocks, #tags in block_tags, and markdown_cache) to SQLite.
func (r *DocRoom) Flush() { func (r *DocRoom) Flush() {
r.mu.Lock() r.mu.Lock()
if !r.dirty { if !r.dirty {
r.mu.Unlock() r.mu.Unlock()
return return
} }
if r.debounceTimer != nil {
r.debounceTimer.Stop()
r.debounceTimer = nil
}
// Prepare data to flush
docID := r.docID docID := r.docID
userID := r.lastUserID title := r.title
stateBlob := r.stateBlob blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
var updatesToFlush [][]byte copy(blocksCopy, r.blocks)
if len(stateBlob) > 0 && len(r.pendingUpdates) == 0 {
// Compacted state snapshot
r.dirty = false r.dirty = false
r.mu.Unlock() r.mu.Unlock()
go func() { r.hub.mu.RLock()
if r.hub.crdtStore != nil { store := r.hub.docStore
_ = r.hub.crdtStore.SaveDocumentCRDTState(docID, stateBlob, userID) r.hub.mu.RUnlock()
}
}()
return
}
if r.persistedUpdatesCount < len(r.pendingUpdates) { if store != nil {
updatesToFlush = make([][]byte, len(r.pendingUpdates)-r.persistedUpdatesCount) if err := store.FlushDocumentState(docID, title, blocksCopy); err != nil {
copy(updatesToFlush, r.pendingUpdates[r.persistedUpdatesCount:]) log.Printf("[doc-room] flush error for doc %s: %v", docID, err)
r.persistedUpdatesCount = len(r.pendingUpdates)
} }
r.dirty = false
r.mu.Unlock()
if len(updatesToFlush) > 0 {
go func() {
if r.hub.crdtStore != nil {
for _, u := range updatesToFlush {
_ = r.hub.crdtStore.AppendDocumentCRDTUpdate(docID, u, userID)
}
}
}()
} }
} }
// GetInitialSnapshot returns the base64-encoded state blob and array of incremental updates. // ServeDocWS handles WebSocket connections for GET /api/v1/ws/docs/{id}
func (r *DocRoom) GetInitialSnapshot() (string, []string) { func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string, clientID string) {
r.mu.Lock()
defer r.mu.Unlock()
b64State := ""
if len(r.stateBlob) > 0 {
b64State = base64.StdEncoding.EncodeToString(r.stateBlob)
}
var b64Updates []string
for _, u := range r.pendingUpdates {
if len(u) > 0 {
b64Updates = append(b64Updates, base64.StdEncoding.EncodeToString(u))
}
}
return b64State, b64Updates
}
// ServeDocWS handles WebSocket connection for GET /api/v1/ws/docs/{id}
func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string) {
conn, err := upgrader.Upgrade(w, req, nil) conn, err := upgrader.Upgrade(w, req, nil)
if err != nil { if err != nil {
log.Printf("[doc-ws] upgrade error for doc %s: %v", r.docID, err) log.Printf("[doc-ws] upgrade error for doc %s: %v", r.docID, err)
return return
} }
if clientID == "" {
clientID = req.URL.Query().Get("clientId")
}
if clientID == "" {
clientID = domain.NewID("cli")
}
client := &DocWSClient{ client := &DocWSClient{
hub: r.hub, hub: r.hub,
room: r, room: r,
conn: conn, conn: conn,
userID: userID, userID: userID,
clientID: clientID,
send: make(chan []byte, 256), send: make(chan []byte, 256),
} }
r.mu.Lock() r.mu.Lock()
r.wsClients[client] = true r.wsClients[client] = true
title := r.title
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
copy(blocksCopy, r.blocks)
r.mu.Unlock() r.mu.Unlock()
// Send initial snapshot // Immediately send live in-memory state to the newly connected client
state, updates := r.GetInitialSnapshot() initEvt := DocSyncEvent{
initMsg := domain.WSMessage{ Type: "doc_state",
Event: "CRDT_INIT", DocID: r.docID,
Payload: map[string]interface{}{ ClientID: clientID,
"docId": r.docID, Title: title,
"state": state, Blocks: blocksCopy,
"updates": updates,
},
Timestamp: domain.NowISO(),
} }
initBytes, _ := json.Marshal(initMsg) if initBytes, err := json.Marshal(initEvt); err == nil {
client.send <- initBytes client.send <- initBytes
}
go client.writePump() go client.writePump()
go client.readPump() go client.readPump()
} }
// ServeDocSSE handles SSE stream fallback for GET /api/v1/sync/docs/{id}/stream
func (r *DocRoom) ServeDocSSE(w http.ResponseWriter, req *http.Request, userID string) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Access-Control-Allow-Origin", "*")
client := &DocSSEClient{
hub: r.hub,
room: r,
id: domain.NewID("sse-doc"),
userID: userID,
send: make(chan []byte, 256),
}
r.mu.Lock()
r.sseClients[client] = true
r.mu.Unlock()
defer func() {
r.mu.Lock()
delete(r.sseClients, client)
clientCount := len(r.wsClients) + len(r.sseClients)
r.mu.Unlock()
if clientCount == 0 {
r.Flush()
}
}()
// Send initial connected comment and CRDT_INIT
fmt.Fprintf(w, ": connected (transport: sse, docId: %s)\n\n", r.docID)
state, updates := r.GetInitialSnapshot()
initMsg, _ := json.Marshal(domain.WSMessage{
Event: "CRDT_INIT",
Payload: map[string]interface{}{
"docId": r.docID,
"state": state,
"updates": updates,
},
Timestamp: domain.NowISO(),
})
fmt.Fprintf(w, "data: %s\n\n", initMsg)
flusher.Flush()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
notify := req.Context().Done()
for {
select {
case <-notify:
return
case msg, ok := <-client.send:
if !ok {
return
}
fmt.Fprintf(w, "data: %s\n\n", msg)
flusher.Flush()
case <-ticker.C:
// Ping to keep proxy connection alive
fmt.Fprintf(w, ": ping\n\n")
flusher.Flush()
}
}
}
func (c *DocWSClient) readPump() { func (c *DocWSClient) readPump() {
defer func() { defer func() {
c.room.mu.Lock() c.room.mu.Lock()
delete(c.room.wsClients, c) delete(c.room.wsClients, c)
clientCount := len(c.room.wsClients) + len(c.room.sseClients) remaining := len(c.room.wsClients)
c.room.mu.Unlock() c.room.mu.Unlock()
_ = c.conn.Close() _ = c.conn.Close()
// Broadcast awareness leave // Flush immediately when the last client leaves the document room
c.room.BroadcastAwareness([]byte(fmt.Sprintf(`{"userId":"%s","activeBlockId":null}`, c.userID)), c.userID, nil, nil) if remaining == 0 {
if clientCount == 0 {
c.room.Flush() c.room.Flush()
} }
}() }()
c.conn.SetReadLimit(8 * 1024 * 1024) // 8MB limit for binary/text updates c.conn.SetReadLimit(4 * 1024 * 1024)
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) _ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error { c.conn.SetPongHandler(func(string) error {
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) _ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
@@ -402,51 +502,20 @@ func (c *DocWSClient) readPump() {
}) })
for { for {
msgType, message, err := c.conn.ReadMessage() _, message, err := c.conn.ReadMessage()
if err != nil { if err != nil {
break break
} }
if msgType == websocket.BinaryMessage { var evt DocSyncEvent
// Direct binary Yjs update if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" {
c.room.ApplyUpdate(message, c.userID, c, nil) c.room.HandleEvent(evt, c)
continue
}
// Text JSON message
var incoming struct {
Event string `json:"event"`
Payload json.RawMessage `json:"payload"`
}
if json.Unmarshal(message, &incoming) == nil {
switch incoming.Event {
case "CRDT_UPDATE":
var p struct {
Update string `json:"update"`
}
if json.Unmarshal(incoming.Payload, &p) == nil && p.Update != "" {
if raw, err := base64.StdEncoding.DecodeString(p.Update); err == nil && len(raw) > 0 {
c.room.ApplyUpdate(raw, c.userID, c, nil)
}
}
case "CRDT_COMPACT":
var p struct {
State string `json:"state"`
}
if json.Unmarshal(incoming.Payload, &p) == nil && p.State != "" {
if raw, err := base64.StdEncoding.DecodeString(p.State); err == nil && len(raw) > 0 {
c.room.CompactState(raw, c.userID)
}
}
case "CRDT_AWARENESS":
c.room.BroadcastAwareness(incoming.Payload, c.userID, c, nil)
}
} }
} }
} }
func (c *DocWSClient) writePump() { func (c *DocWSClient) writePump() {
ticker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(25 * time.Second)
defer func() { defer func() {
ticker.Stop() ticker.Stop()
_ = c.conn.Close() _ = c.conn.Close()
+6 -84
View File
@@ -1,7 +1,6 @@
package ws package ws
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@@ -47,10 +46,9 @@ type docLockItem struct {
expiresAt time.Time expiresAt time.Time
} }
type CRDTStore interface { type DocStore interface {
GetDocumentCRDTState(docID string) ([]byte, [][]byte, error) LoadDocumentState(docID string) (string, []domain.DocumentBlock, error)
AppendDocumentCRDTUpdate(docID string, update []byte, userID string) error FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error
SaveDocumentCRDTState(docID string, stateBlob []byte, userID string) error
} }
type Hub struct { type Hub struct {
@@ -63,7 +61,7 @@ type Hub struct {
sseRooms map[string]map[*SSEClient]bool sseRooms map[string]map[*SSEClient]bool
locks map[string]*docLockItem locks map[string]*docLockItem
docRooms map[string]*DocRoom docRooms map[string]*DocRoom
crdtStore CRDTStore docStore DocStore
} }
func NewHub() *Hub { func NewHub() *Hub {
@@ -81,10 +79,10 @@ func NewHub() *Hub {
return h return h
} }
func (h *Hub) SetCRDTStore(store CRDTStore) { func (h *Hub) SetDocStore(store DocStore) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
h.crdtStore = store h.docStore = store
} }
func (h *Hub) Register(c *Client) { func (h *Hub) Register(c *Client) {
@@ -688,39 +686,6 @@ func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request, roomID, userID st
fmt.Fprintf(w, "data: %s\n\n", connectedMsg) fmt.Fprintf(w, "data: %s\n\n", connectedMsg)
flusher.Flush() flusher.Flush()
// If initial rooms had doc rooms, send CRDT_INIT
if h.crdtStore != nil {
for rID := range client.subscribedRooms {
if strings.HasPrefix(rID, "doc:") {
docID := strings.TrimPrefix(rID, "doc:")
stateBlob, updates, err := h.crdtStore.GetDocumentCRDTState(docID)
if err == nil && (len(stateBlob) > 0 || len(updates) > 0) {
var b64Updates []string
for _, u := range updates {
b64Updates = append(b64Updates, base64.StdEncoding.EncodeToString(u))
}
b64State := ""
if len(stateBlob) > 0 {
b64State = base64.StdEncoding.EncodeToString(stateBlob)
}
initMsg := domain.WSMessage{
Event: "CRDT_INIT",
Payload: map[string]interface{}{
"docId": docID,
"state": b64State,
"updates": b64Updates,
},
Timestamp: domain.NowISO(),
}
if initBytes, err := json.Marshal(initMsg); err == nil {
fmt.Fprintf(w, "data: %s\n\n", initBytes)
flusher.Flush()
}
}
}
}
}
ticker := time.NewTicker(15 * time.Second) ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop() defer ticker.Stop()
@@ -811,49 +776,6 @@ func (c *Client) readPump() {
if c.hub.ReleaseDocLock(docID, c.userID, false) { if c.hub.ReleaseDocLock(docID, c.userID, false) {
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
} }
case "CRDT_JOIN":
c.hub.JoinRoom(c, "doc:"+docID)
room := c.hub.GetOrCreateDocRoom(docID)
b64State, b64Updates := room.GetInitialSnapshot()
if len(b64State) > 0 || len(b64Updates) > 0 {
c.SendEvent("CRDT_INIT", map[string]interface{}{
"docId": docID,
"state": b64State,
"updates": b64Updates,
})
}
case "CRDT_UPDATE":
var p struct {
Update string `json:"update"`
}
_ = json.Unmarshal(incoming.Payload, &p)
if p.Update != "" {
if raw, err := base64.StdEncoding.DecodeString(p.Update); err == nil && len(raw) > 0 {
room := c.hub.GetOrCreateDocRoom(docID)
room.ApplyUpdate(raw, c.userID, nil, nil)
}
}
case "CRDT_COMPACT":
var p struct {
State string `json:"state"`
}
_ = json.Unmarshal(incoming.Payload, &p)
if p.State != "" {
if raw, err := base64.StdEncoding.DecodeString(p.State); err == nil && len(raw) > 0 {
room := c.hub.GetOrCreateDocRoom(docID)
room.CompactState(raw, c.userID)
}
}
case "CRDT_AWARENESS":
c.hub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS", incoming.Payload, c)
room := c.hub.GetOrCreateDocRoom(docID)
room.BroadcastAwareness(incoming.Payload, c.userID, nil, nil)
case "CRDT_LEAVE":
c.hub.LeaveRoom(c, "doc:"+docID)
c.hub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
"docId": docID,
"userId": c.userID,
}, c)
} }
} }
} }