From ed673b070733ed8d10c66ff1cee0501241abad0c Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 11:05:09 +0100 Subject: [PATCH] feat(workspace): migrate notes, blocks, and folders to pure database-first architecture with atomic block patching and live sync --- .../src/features/workspace/WorkspaceView.tsx | 494 ++++++++-- frontend/src/lib/api.ts | 19 + frontend/src/lib/types.ts | 15 + internal/api/server.go | 79 +- internal/domain/models.go | 19 + internal/storage/sqlite.go | 910 +++++++++++++----- internal/storage/sqlite_test.go | 155 +++ 7 files changed, 1353 insertions(+), 338 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 0c6654c..e7f2524 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -49,7 +49,7 @@ import { User as UserIcon, Radio, } from 'lucide-react'; -import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types'; +import { BlockType, Document, DocumentBlock, DocBlocksPatchEvent, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types'; import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected } from '../../lib/api'; import { CodeBlockItem } from './CodeBlockItem'; import { UserAutocomplete } from '../UserAutocomplete'; @@ -237,7 +237,130 @@ export const WorkspaceView: React.FC = ({ const blockInputRefs = useRef>({}); const isLockedByOther = !!(activeLock && activeLock.userId !== currentUser?.id); - const isReadOnly = selectedDoc?.permission === 'read' || isLockedByOther; + const isReadOnly = selectedDoc?.permission === 'read'; + + // Track server-synced blocks & metadata per document, plus local unsaved block deltas + const syncedDocMetaRef = useRef>(new Map()); + const syncedBlocksRef = useRef>>(new Map()); + const syncedOrderRef = useRef>(new Map()); + const localDirtyBlocksRef = useRef>>(new Map()); + const localDeletedBlocksRef = useRef>>(new Map()); + + const blocksAreEqual = (a?: DocumentBlock, b?: DocumentBlock): boolean => { + if (!a || !b) return false; + return ( + a.id === b.id && + (a.type || 'bullet') === (b.type || 'bullet') && + (a.content || '') === (b.content || '') && + (a.level || 0) === (b.level || 0) && + !!a.checked === !!b.checked && + !!a.collapsed === !!b.collapsed && + (a.lang || '') === (b.lang || '') + ); + }; + + const recordSyncedDoc = (doc: Document) => { + if (!doc || !doc.id) return; + syncedDocMetaRef.current.set(doc.id, { + title: doc.title || '', + icon: doc.icon || '📄', + tagsJson: JSON.stringify((doc.tags || []).filter((t) => !t.startsWith('folder:'))), + isPinned: !!doc.isPinned, + }); + const bMap = new Map(); + const order: string[] = []; + (doc.blocks || []).forEach((b) => { + bMap.set(b.id, { ...b }); + order.push(b.id); + }); + syncedBlocksRef.current.set(doc.id, bMap); + syncedOrderRef.current.set(doc.id, order); + }; + + const mergeIncomingBlocks = ( + docId: string, + currentBlocks: DocumentBlock[], + patch: { upserted?: DocumentBlock[]; deletedIds?: string[]; blockOrder?: string[] } + ): DocumentBlock[] => { + const dirtyMap = localDirtyBlocksRef.current.get(docId); + const localDeleted = localDeletedBlocksRef.current.get(docId); + + // Update server baseline refs + let srvMap = syncedBlocksRef.current.get(docId); + if (!srvMap) { + srvMap = new Map(); + currentBlocks.forEach((b) => srvMap!.set(b.id, { ...b })); + syncedBlocksRef.current.set(docId, srvMap); + } + const deletedSet = new Set(patch.deletedIds || []); + deletedSet.forEach((delId) => srvMap!.delete(delId)); + (patch.upserted || []).forEach((b) => srvMap!.set(b.id, { ...b })); + if (patch.blockOrder && patch.blockOrder.length > 0) { + syncedOrderRef.current.set(docId, [...patch.blockOrder]); + } + + // Build merged block map preserving any block with unsaved local edits + const blockMap = new Map(); + currentBlocks.forEach((b) => { + if (!deletedSet.has(b.id) || dirtyMap?.has(b.id)) { + blockMap.set(b.id, b); + } + }); + + (patch.upserted || []).forEach((b) => { + if (localDeleted?.has(b.id)) { + return; + } + if (dirtyMap?.has(b.id)) { + blockMap.set(b.id, dirtyMap.get(b.id)!); + } else { + blockMap.set(b.id, b); + } + }); + + // Reconcile ordering + const mergedOrder: string[] = []; + const seen = new Set(); + + if (patch.blockOrder && patch.blockOrder.length > 0) { + patch.blockOrder.forEach((id) => { + if (blockMap.has(id) && !seen.has(id) && !localDeleted?.has(id)) { + mergedOrder.push(id); + seen.add(id); + } + }); + // Preserve any locally created unsaved blocks in their relative position + currentBlocks.forEach((b, idx) => { + if (!seen.has(b.id) && blockMap.has(b.id) && !deletedSet.has(b.id)) { + let insertIdx = 0; + for (let j = idx - 1; j >= 0; j--) { + const predPos = mergedOrder.indexOf(currentBlocks[j].id); + if (predPos !== -1) { + insertIdx = predPos + 1; + break; + } + } + mergedOrder.splice(insertIdx, 0, b.id); + seen.add(b.id); + } + }); + } else { + currentBlocks.forEach((b) => { + if (blockMap.has(b.id) && !seen.has(b.id)) { + mergedOrder.push(b.id); + seen.add(b.id); + } + }); + (patch.upserted || []).forEach((b) => { + if (blockMap.has(b.id) && !seen.has(b.id)) { + mergedOrder.push(b.id); + seen.add(b.id); + } + }); + } + + return mergedOrder.map((id) => blockMap.get(id)!).filter(Boolean); + }; const stopHeartbeat = () => { if (heartbeatTimerRef.current) { @@ -282,7 +405,6 @@ export const WorkspaceView: React.FC = ({ const ensureLock = async (force = false): Promise => { if (!selectedDoc || selectedDoc.permission === 'read') return false; - if (isLockedByOther) return false; if (holdsLockRef.current && !force) { return true; } @@ -296,8 +418,6 @@ export const WorkspaceView: React.FC = ({ return true; } else if (res.lock) { setActiveLock(res.lock); - holdsLockRef.current = false; - setHoldsLock(false); return false; } } catch (e) { @@ -312,7 +432,7 @@ export const WorkspaceView: React.FC = ({ clearTimeout(idleUnlockTimerRef.current); idleUnlockTimerRef.current = null; } - if (!isReadOnly && !isLockedByOther) { + if (!isReadOnly) { ensureLock(); } }; @@ -322,11 +442,9 @@ export const WorkspaceView: React.FC = ({ if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); } - // If a block is currently being dragged, do not schedule an unlock if (isDraggingBlockRef.current) { return; } - // Generous grace period: if user clicked into another block in the doc or paused typing idleUnlockTimerRef.current = setTimeout(async () => { if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { await flushPendingSave(); @@ -335,7 +453,7 @@ export const WorkspaceView: React.FC = ({ }, 8000); }; - // Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD) + // Debounced auto-save refs (500ms delay) const debounceSaveTimerRef = useRef(null); const pendingSaveDocRef = useRef(null); @@ -343,27 +461,86 @@ export const WorkspaceView: React.FC = ({ if (isReadOnly) return; let saveSucceeded = false; try { - const saved = await api.updateDocument(docToSave.id, { - title: docToSave.title, - icon: docToSave.icon, - folderPath: docToSave.folderPath, - blocks: docToSave.blocks, - tags: docToSave.tags, - isPinned: docToSave.isPinned, + const docId = docToSave.id; + const dirtyMap = localDirtyBlocksRef.current.get(docId); + const deletedSet = localDeletedBlocksRef.current.get(docId); + const srvBlocks = syncedBlocksRef.current.get(docId); + const srvOrder = syncedOrderRef.current.get(docId) || []; + + const upsertedMap = new Map(); + if (dirtyMap) { + dirtyMap.forEach((b, id) => upsertedMap.set(id, b)); + } + (docToSave.blocks || []).forEach((b) => { + const srv = srvBlocks?.get(b.id); + if (!srv || !blocksAreEqual(b, srv)) { + upsertedMap.set(b.id, b); + } }); + + const upsertedToSend = Array.from(upsertedMap.values()); + const deletedToSend = Array.from(deletedSet?.values() || []); + const currentOrder = (docToSave.blocks || []).map((b) => b.id); + const orderChanged = + currentOrder.length !== srvOrder.length || + currentOrder.some((id, idx) => id !== srvOrder[idx]); + + const srvMeta = syncedDocMetaRef.current.get(docId); + const cleanTags = (docToSave.tags || []).filter((t) => !t.startsWith('folder:')); + const cleanTagsJson = JSON.stringify(cleanTags); + const metaChanged = + !srvMeta || + srvMeta.title !== docToSave.title || + srvMeta.icon !== docToSave.icon || + srvMeta.tagsJson !== cleanTagsJson || + srvMeta.isPinned !== !!docToSave.isPinned; + + // Clear the sent snapshot from dirty/deleted maps before awaiting network call + if (dirtyMap) { + upsertedToSend.forEach((b) => { + const cur = dirtyMap.get(b.id); + if (cur && blocksAreEqual(cur, b)) { + dirtyMap.delete(b.id); + } + }); + } + if (deletedSet) { + deletedToSend.forEach((id) => deletedSet.delete(id)); + } + + if (upsertedToSend.length > 0 || deletedToSend.length > 0 || orderChanged) { + const patchedDoc = await api.patchDocumentBlocks(docId, { + upserted: upsertedToSend, + deletedIds: deletedToSend, + blockOrder: currentOrder, + }); + // Update server block baseline with patched result + const nextSrvMap = new Map(); + const nextSrvOrder: string[] = []; + (patchedDoc.blocks || []).forEach((b) => { + nextSrvMap.set(b.id, { ...b }); + nextSrvOrder.push(b.id); + }); + syncedBlocksRef.current.set(docId, nextSrvMap); + syncedOrderRef.current.set(docId, nextSrvOrder); + } + + if (metaChanged) { + await api.updateDocument(docId, { + title: docToSave.title, + icon: docToSave.icon, + tags: cleanTags, + isPinned: docToSave.isPinned, + }); + syncedDocMetaRef.current.set(docId, { + title: docToSave.title, + icon: docToSave.icon, + tagsJson: cleanTagsJson, + isPinned: !!docToSave.isPinned, + }); + } + saveSucceeded = true; - setSelectedDoc((prev) => - prev && prev.id === saved.id - ? { - ...prev, - ...saved, - permission: prev.permission, - isOwner: prev.isOwner, - ownerName: prev.ownerName, - sharedWith: prev.sharedWith, - } - : prev - ); onStatsChange(); api.getWorkspaceTags().then(setWorkspaceTags).catch(() => {}); if (selectedTag) { @@ -371,11 +548,6 @@ export const WorkspaceView: React.FC = ({ } } catch (e: any) { console.error('Failed to save document to backend', e); - if (e.message && e.message.includes('423')) { - api.getDocLock(docToSave.id).then((res) => { - if (res.lock) setActiveLock(res.lock); - }).catch(() => {}); - } } finally { if (saveSucceeded) { if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) { @@ -383,7 +555,6 @@ export const WorkspaceView: React.FC = ({ setSaveState('saved'); } } else { - // Keep pendingSaveDocRef so changes are preserved and not silently marked as saved! setSaveState('saving'); } if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { @@ -413,7 +584,40 @@ export const WorkspaceView: React.FC = ({ const persistDocument = async (updatedDoc: Document, immediate = false) => { if (isReadOnly) return; - await ensureLock(); + ensureLock(); + + // Record block-level delta against previous local state and server baseline + const docId = updatedDoc.id; + let dirtyMap = localDirtyBlocksRef.current.get(docId); + if (!dirtyMap) { + dirtyMap = new Map(); + localDirtyBlocksRef.current.set(docId, dirtyMap); + } + let deletedSet = localDeletedBlocksRef.current.get(docId); + if (!deletedSet) { + deletedSet = new Set(); + localDeletedBlocksRef.current.set(docId, deletedSet); + } + const srvBlocks = syncedBlocksRef.current.get(docId); + const prevLocalBlocks = + selectedDoc && selectedDoc.id === docId ? selectedDoc.blocks || [] : []; + + const nextIds = new Set(); + (updatedDoc.blocks || []).forEach((b) => { + nextIds.add(b.id); + deletedSet!.delete(b.id); + const srv = srvBlocks?.get(b.id); + if (!srv || !blocksAreEqual(b, srv)) { + dirtyMap!.set(b.id, { ...b }); + } + }); + + prevLocalBlocks.forEach((prevB) => { + if (!nextIds.has(prevB.id)) { + dirtyMap!.delete(prevB.id); + deletedSet!.add(prevB.id); + } + }); // 1. Update local UI state immediately for responsive, instant typing setSelectedDoc(updatedDoc); @@ -430,7 +634,6 @@ export const WorkspaceView: React.FC = ({ if (immediate) { await flushPendingSave(); } else { - // 500 ms debounced delay after last keystroke so disk/SSD is only written after typing pauses debounceSaveTimerRef.current = setTimeout(async () => { debounceSaveTimerRef.current = null; if (pendingSaveDocRef.current) { @@ -438,7 +641,7 @@ export const WorkspaceView: React.FC = ({ pendingSaveDocRef.current = null; await executeSaveToBackend(doc); } - }, 500); + }, 350); } }; @@ -450,6 +653,7 @@ export const WorkspaceView: React.FC = ({ api.getWorkspaceTags().catch(() => []), api.getSharedFolders().catch(() => []), ]); + list.forEach(recordSyncedDoc); setDocs(list); setCustomFolders(serverFolders); setWorkspaceTags(tags); @@ -616,7 +820,7 @@ export const WorkspaceView: React.FC = ({ }; }, []); - // Real-time WebSocket listener for Document & Lock events + // Real-time WebSocket listener for Document, Block Delta & Folder events useEffect(() => { const handleWs = (e: any) => { const msg = e.detail; @@ -634,30 +838,85 @@ export const WorkspaceView: React.FC = ({ const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id; if (unlockedDocId && unlockedDocId === selectedDocIdRef.current) { setActiveLock(null); - // Fetch fresh document from backend so updates made by the other user are immediately visible - api.getDocument(unlockedDocId).then((freshDoc) => { - if (freshDoc && freshDoc.id === selectedDocIdRef.current) { - setSelectedDoc((prev) => (prev && prev.id === freshDoc.id ? { ...prev, ...freshDoc, isPinned: prev.isPinned } : prev)); - setDocs((prev) => prev.map((d) => (d.id === freshDoc.id ? { ...d, ...freshDoc, isPinned: d.isPinned } : d))); + } + } else if (msg.event === 'DOC_BLOCKS_PATCHED') { + const patch = msg.payload as DocBlocksPatchEvent; + if (!patch || !patch.docId) return; + + setDocs((prev) => + prev.map((d) => { + if (d.id !== patch.docId) return d; + const merged = mergeIncomingBlocks(d.id, d.blocks || [], { + upserted: patch.upserted, + deletedIds: patch.deletedIds, + blockOrder: patch.blockOrder, + }); + return { ...d, blocks: merged, updatedAt: patch.updatedAt || d.updatedAt }; + }) + ); + + if (selectedDocIdRef.current === patch.docId) { + setSelectedDoc((prev) => { + if (!prev || prev.id !== patch.docId) return prev; + const merged = mergeIncomingBlocks(prev.id, prev.blocks || [], { + upserted: patch.upserted, + deletedIds: patch.deletedIds, + blockOrder: patch.blockOrder, + }); + const nextDoc = { ...prev, blocks: merged, updatedAt: patch.updatedAt || prev.updatedAt }; + if (pendingSaveDocRef.current && pendingSaveDocRef.current.id === patch.docId) { + pendingSaveDocRef.current = { ...pendingSaveDocRef.current, blocks: merged }; } - }).catch(() => {}); + return nextDoc; + }); } } else if (msg.event === 'DOC_CREATED') { const created = msg.payload as Document; if (created && created.id) { + recordSyncedDoc(created); setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev])); onStatsChange(); } } else if (msg.event === 'DOC_UPDATED') { const updated = msg.payload as Document; if (updated && updated.id) { + const incomingOrder = (updated.blocks || []).map((b) => b.id); setDocs((prev) => - prev.map((d) => (d.id === updated.id ? { ...d, ...updated, isPinned: d.isPinned } : d)) + prev.map((d) => { + if (d.id !== updated.id) return d; + const mergedBlocks = mergeIncomingBlocks(d.id, d.blocks || [], { + upserted: updated.blocks || [], + blockOrder: incomingOrder, + }); + return { + ...d, + ...updated, + blocks: mergedBlocks, + isPinned: d.isPinned, + }; + }) ); if (selectedDocIdRef.current === updated.id) { - if (!holdsLockRef.current) { - setSelectedDoc((prev) => (prev ? { ...prev, ...updated, isPinned: prev.isPinned } : updated)); - } + setSelectedDoc((prev) => { + if (!prev || prev.id !== updated.id) return prev; + const mergedBlocks = mergeIncomingBlocks(prev.id, prev.blocks || [], { + upserted: updated.blocks || [], + blockOrder: incomingOrder, + }); + const nextDoc = { + ...prev, + ...updated, + blocks: mergedBlocks, + isPinned: prev.isPinned, + }; + if (pendingSaveDocRef.current && pendingSaveDocRef.current.id === updated.id) { + pendingSaveDocRef.current = { + ...pendingSaveDocRef.current, + blocks: mergedBlocks, + }; + } + return nextDoc; + }); } onStatsChange(); } @@ -673,7 +932,28 @@ export const WorkspaceView: React.FC = ({ } else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') { api.getFolders().then(setCustomFolders).catch(() => {}); api.getSharedFolders().then(setSharedFolders).catch(() => {}); - api.getDocuments().then(setDocs).catch(() => {}); + api.getDocuments().then((freshList) => { + freshList.forEach(recordSyncedDoc); + setDocs(freshList); + if (selectedDocIdRef.current) { + const found = freshList.find((d) => d.id === selectedDocIdRef.current); + if (found) { + setSelectedDoc((prev) => + prev && prev.id === found.id + ? { + ...prev, + folderPath: found.folderPath, + sharedFolderId: found.sharedFolderId, + subFolderPath: found.subFolderPath, + isSharedFolder: found.isSharedFolder, + permission: found.permission, + isOwner: found.isOwner, + } + : prev + ); + } + } + }).catch(() => {}); } }; @@ -849,9 +1129,7 @@ export const WorkspaceView: React.FC = ({ const getDocFolder = (doc: Document): string => { - if (doc.folderPath) return doc.folderPath; - const tag = doc.tags?.find((t) => t.startsWith('folder:')); - return tag ? tag.replace('folder:', '') : ''; + return doc.folderPath || ''; }; // Compute all unique folder paths @@ -894,9 +1172,6 @@ export const WorkspaceView: React.FC = ({ triggerHaptic(15); const tags = ['notiz']; const path = folderPath || ''; - if (path) { - tags.push(`folder:${path}`); - } const created = await api.createDocument({ title: 'Neue Notiz', icon: '📄', @@ -945,16 +1220,37 @@ export const WorkspaceView: React.FC = ({ const handleChangeDocFolder = async (folderPath: string) => { if (!selectedDoc) return; const cleanPath = folderPath.trim(); - const updatedTags = (selectedDoc.tags || []).filter((t) => !t.startsWith('folder:')); - if (cleanPath) { - updatedTags.push(`folder:${cleanPath}`); + try { + const updatedDoc = await api.moveDocument(selectedDoc.id, cleanPath); + setDocs((prev) => + prev.map((d) => + d.id === selectedDoc.id + ? { + ...d, + folderPath: updatedDoc.folderPath, + sharedFolderId: updatedDoc.sharedFolderId, + subFolderPath: updatedDoc.subFolderPath, + isSharedFolder: updatedDoc.isSharedFolder, + } + : d + ) + ); + setSelectedDoc((prev) => + prev && prev.id === selectedDoc.id + ? { + ...prev, + folderPath: updatedDoc.folderPath, + sharedFolderId: updatedDoc.sharedFolderId, + subFolderPath: updatedDoc.subFolderPath, + isSharedFolder: updatedDoc.isSharedFolder, + } + : prev + ); + api.getFolders().then(setCustomFolders).catch(() => {}); + } catch (err: any) { + console.error('Failed to change document folder', err); + alert(err.message || 'Fehler beim Verschieben der Notiz'); } - const updatedDoc = { - ...selectedDoc, - folderPath: cleanPath, - tags: updatedTags, - }; - await persistDocument(updatedDoc, true); }; // Global drag cleanup so drag states never get stuck if dragend is lost during re-render @@ -1020,16 +1316,10 @@ export const WorkspaceView: React.FC = ({ const updatedDocsList = docs.map((d) => { const f = getDocFolder(d); if (f === cleanSource) { - const nextFolder = newFolderPath; - const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:')); - if (nextFolder) updatedTags.push(`folder:${nextFolder}`); - return { ...d, folderPath: nextFolder, tags: updatedTags }; + return { ...d, folderPath: newFolderPath }; } else if (f.startsWith(cleanSource + '/')) { const suffix = f.slice(cleanSource.length); - const nextFolder = `${newFolderPath}${suffix}`; - const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:')); - if (nextFolder) updatedTags.push(`folder:${nextFolder}`); - return { ...d, folderPath: nextFolder, tags: updatedTags }; + return { ...d, folderPath: `${newFolderPath}${suffix}` }; } return d; }); @@ -1040,25 +1330,32 @@ export const WorkspaceView: React.FC = ({ if (found) setSelectedDoc(found); } - // Persist all affected docs to backend using api.moveDocument - for (const d of docsToUpdate) { - const f = getDocFolder(d); - let nextFolder = newFolderPath; - if (f.startsWith(cleanSource + '/')) { - const suffix = f.slice(cleanSource.length); - nextFolder = `${newFolderPath}${suffix}`; + try { + await api.createFolder(newFolderPath); + const subFoldersToRecreate = customFolders.filter((cf) => cf.startsWith(cleanSource + '/')); + for (const sf of subFoldersToRecreate) { + const suffix = sf.slice(cleanSource.length); + await api.createFolder(`${newFolderPath}${suffix}`); } - api.moveDocument(d.id, nextFolder).catch((err) => console.error('Failed to move doc', err)); + // Persist all affected docs to backend BEFORE deleting the old empty folder + await Promise.all( + docsToUpdate.map((d) => { + const f = getDocFolder(d); + let nextFolder = newFolderPath; + if (f.startsWith(cleanSource + '/')) { + const suffix = f.slice(cleanSource.length); + nextFolder = `${newFolderPath}${suffix}`; + } + return api.moveDocument(d.id, nextFolder); + }) + ); + await api.deleteFolder(cleanSource); + const freshFolders = await api.getFolders(); + setCustomFolders(freshFolders); + } catch (err) { + console.error('Error syncing moved folders with server', err); } - // 2. Sync folder structure with server - api - .createFolder(newFolderPath) - .then(() => api.deleteFolder(cleanSource)) - .then(() => api.getFolders()) - .then(setCustomFolders) - .catch((err) => console.error('Error syncing moved folders with server', err)); - // Expand the new folder path and its parent setExpandedFolders((prev) => { const next = new Set(prev); @@ -2887,10 +3184,13 @@ export const WorkspaceView: React.FC = ({ }; }); - // Persist immediately to backend & disk - await api.updateDocument(docId, { - blocks: updatedBlocks, - }); + // Persist immediately via atomic block patch + const toggledBlock = updatedBlocks.find((b) => b.id === blockId); + if (toggledBlock) { + await api.patchDocumentBlocks(docId, { + upserted: [toggledBlock], + }); + } }; // Jump directly to document from rollup @@ -3916,16 +4216,16 @@ export const WorkspaceView: React.FC = ({ {isLockedByOther && ( -
- +
+
- Diese Notiz wird aktuell von {activeLock?.userName || 'einem anderen Benutzer'} bearbeitet. -

Die Bearbeitung ist vorübergehend gesperrt, um Zeilen- und Datenverlust zu vermeiden.

+ {activeLock?.userName || 'Ein anderer Benutzer'} bearbeitet diese Notiz gerade ebenfalls. +

Live-Zusammenarbeit aktiv – Änderungen werden automatisch Punkt für Punkt synchronisiert.

)} - {!isLockedByOther && selectedDoc.permission === 'read' && ( + {selectedDoc.permission === 'read' && (
Du hast Nur-Lesezugriff auf diese Notiz. Änderungen können nicht vorgenommen werden. diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1c6fef5..acf96fe 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -576,6 +576,25 @@ export const api = { }; }, + async patchDocumentBlocks( + id: string, + payload: { + upserted?: DocumentBlock[]; + deletedIds?: string[]; + blockOrder?: string[]; + } + ): Promise { + const d = await request(`/api/v1/documents/${id}/blocks`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); + return { + ...d, + blocks: ensureArray(d.blocks), + tags: ensureArray(d.tags), + }; + }, + async deleteDocument(id: string): Promise { await request(`/api/v1/documents/${id}`, { method: 'DELETE' }); }, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f32d9e0..a29321b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -165,6 +165,21 @@ export interface DocumentBlock { collapsed?: boolean; } +export interface DocBlocksPatchRequest { + upserted?: DocumentBlock[]; + deletedIds?: string[]; + blockOrder?: string[]; +} + +export interface DocBlocksPatchEvent { + docId: string; + updatedBy: string; + updatedAt: string; + upserted?: DocumentBlock[]; + deletedIds?: string[]; + blockOrder?: string[]; +} + export interface DocShare { id: string; docId: string; diff --git a/internal/api/server.go b/internal/api/server.go index 5b6919a..849865c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -124,6 +124,7 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/documents", s.handleCreateDocument) r.Get("/api/v1/documents/{id}", s.handleGetDocument) r.Put("/api/v1/documents/{id}", s.handleUpdateDocument) + r.Patch("/api/v1/documents/{id}/blocks", s.handlePatchDocumentBlocks) r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument) r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument) @@ -772,6 +773,18 @@ func (s *Server) handlePurgeDiscarded(w http.ResponseWriter, r *http.Request) { // --- DOCUMENTS --- +func (s *Server) broadcastDocToAuthorizedUsers(docID, event string) { + users, err := s.store.GetAuthorizedDocUsers(docID) + if err != nil { + return + } + for _, uid := range users { + if userDoc, err := s.store.GetDocumentByID(uid, docID); err == nil && userDoc != nil { + s.wsHub.BroadcastToUser(uid, event, userDoc) + } + } +} + func (s *Server) handleGetDocuments(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docs, err := s.store.GetDocuments(userID) @@ -803,7 +816,7 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - s.wsHub.BroadcastDocEvent(doc.ID, "DOC_CREATED", doc) + s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED") writeJSON(w, http.StatusCreated, doc) } @@ -822,16 +835,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") - // Check document lock - if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID { - w.WriteHeader(http.StatusLocked) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "Dokument wird aktuell bearbeitet von " + lock.UserName, - "currentLock": lock, - }) - return - } - var req struct { Title *string `json:"title"` Icon *string `json:"icon"` @@ -851,11 +854,44 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { return } if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil { - s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc) + s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") } writeJSON(w, http.StatusOK, doc) } +func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + docID := chi.URLParam(r, "id") + + var req domain.DocBlocksPatchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiges JSON") + return + } + + doc, mergedOrder, err := s.store.PatchDocumentBlocks(userID, docID, req) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + patchEvent := domain.DocBlocksPatchEvent{ + DocID: docID, + UpdatedBy: userID, + UpdatedAt: doc.UpdatedAt, + Upserted: req.Upserted, + DeletedIDs: req.DeletedIDs, + BlockOrder: mergedOrder, + } + if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { + for _, uid := range users { + s.wsHub.BroadcastToUser(uid, "DOC_BLOCKS_PATCHED", patchEvent) + } + } + + writeJSON(w, http.StatusOK, doc) +} + func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") @@ -990,7 +1026,7 @@ func (s *Server) handleUpdateFolderMount(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusBadRequest, err.Error()) return } - s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "mountPath": req.MountPath}) + s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "mountPath": req.MountPath}) writeJSON(w, http.StatusOK, map[string]bool{"updated": true}) } @@ -1071,7 +1107,7 @@ func (s *Server) handleMoveDocument(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc) + s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", nil) writeJSON(w, http.StatusOK, doc) } @@ -1153,11 +1189,8 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - members, _ := s.store.GetTeamMembers(req.TeamID) - for _, tm := range members { - s.wsHub.BroadcastToUser(tm.UserID, "DOC_SHARED", share) - } - s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share) + s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED") + s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") writeJSON(w, http.StatusCreated, share) return } @@ -1176,8 +1209,8 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - s.wsHub.BroadcastToUser(share.UserID, "DOC_SHARED", share) - s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share) + s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED") + s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") writeJSON(w, http.StatusCreated, share) } @@ -1189,8 +1222,8 @@ func (s *Server) handleDeleteDocShare(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - s.wsHub.BroadcastToUser(targetUserID, "DOC_UNSHARED", map[string]string{"docId": docID}) - s.wsHub.BroadcastDocEvent(docID, "DOC_UNSHARED", map[string]string{"docId": docID, "userId": targetUserID}) + s.wsHub.BroadcastToUser(targetUserID, "DOC_DELETED", map[string]string{"id": docID}) + s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } diff --git a/internal/domain/models.go b/internal/domain/models.go index 8616ed5..0f6d863 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -269,6 +269,25 @@ type DocumentBlock struct { Collapsed bool `json:"collapsed,omitempty"` } +type DocBlocksPatchRequest struct { + Upserted []DocumentBlock `json:"upserted,omitempty"` + DeletedIDs []string `json:"deletedIds,omitempty"` + BlockOrder []string `json:"blockOrder,omitempty"` + Title *string `json:"title,omitempty"` + Icon *string `json:"icon,omitempty"` +} + +type DocBlocksPatchEvent struct { + DocID string `json:"docId"` + Upserted []DocumentBlock `json:"upserted,omitempty"` + DeletedIDs []string `json:"deletedIds,omitempty"` + BlockOrder []string `json:"blockOrder,omitempty"` + Title *string `json:"title,omitempty"` + Icon *string `json:"icon,omitempty"` + UpdatedBy string `json:"updatedBy"` + UpdatedAt string `json:"updatedAt"` +} + type DocShare struct { ID string `json:"id"` DocID string `json:"docId"` diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 399d85a..60b95e9 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -366,6 +366,9 @@ func (s *Store) migrate() error { "CREATE TABLE IF NOT EXISTS workspace_folders (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, shared_folder_id TEXT NOT NULL DEFAULT '', path TEXT NOT NULL, sub_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL);", "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 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);", + "CREATE INDEX IF NOT EXISTS idx_document_blocks_doc_order ON document_blocks(doc_id, sort_order);", } for _, q := range columnMigrations { _, _ = s.db.Exec(q) @@ -422,6 +425,9 @@ func (s *Store) migrate() error { _, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`) _, _ = s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`) + // Migrate workspace documents, blocks, folders, and strip legacy folder:* tags into pure DB tables + s.migrateWorkspaceToPureDB() + // Reindex block tags if table is newly added or empty var tagCount int _ = s.db.QueryRow(`SELECT COUNT(*) FROM doc_block_tags`).Scan(&tagCount) @@ -1210,74 +1216,458 @@ func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) { return res.RowsAffected() } -// --- DOCUMENTS (WORKSPACE) --- +// --- DOCUMENTS (WORKSPACE - PURE DATABASE ARCHITECTURE) --- -func (s *Store) saveDocumentToDisk(doc *domain.Document) { - if s.docDir == "" || doc == nil { - return +func stripFolderTags(tags []string) []string { + if len(tags) == 0 { + return []string{} } - userDir := filepath.Join(s.docDir, doc.UserID) - if doc.FolderPath != "" { - userDir = filepath.Join(userDir, filepath.Clean(doc.FolderPath)) - } - _ = os.MkdirAll(userDir, 0755) - - var md strings.Builder - md.WriteString(fmt.Sprintf("# %s %s\n\n", doc.Icon, doc.Title)) - if len(doc.Blocks) > 0 { - for _, b := range doc.Blocks { - indent := strings.Repeat(" ", b.Level) - switch b.Type { - case "h1": - md.WriteString(fmt.Sprintf("%s- # %s\n", indent, b.Content)) - case "h2": - md.WriteString(fmt.Sprintf("%s- ## %s\n", indent, b.Content)) - case "h3": - md.WriteString(fmt.Sprintf("%s- ### %s\n", indent, b.Content)) - case "todo": - chk := " " - if b.Checked { - chk = "x" - } - formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" ") - md.WriteString(fmt.Sprintf("%s- [%s] %s\n", indent, chk, formatted)) - case "bullet", "paragraph": - formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" ") - md.WriteString(fmt.Sprintf("%s- %s\n", indent, formatted)) - case "code": - md.WriteString(fmt.Sprintf("%s- ```%s\n%s %s\n%s ```\n", indent, b.Lang, indent, b.Content, indent)) - case "callout": - formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" > ") - md.WriteString(fmt.Sprintf("%s- > %s\n", indent, formatted)) - case "divider": - md.WriteString(fmt.Sprintf("%s- ---\n", indent)) - case "table": - md.WriteString(fmt.Sprintf("%s- %s\n", indent, b.Content)) - case "progress": - md.WriteString(fmt.Sprintf("%s- \n", indent, b.Content)) - default: - formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" ") - md.WriteString(fmt.Sprintf("%s- %s\n", indent, formatted)) - } + cleaned := make([]string, 0, len(tags)) + for _, t := range tags { + if !strings.HasPrefix(strings.TrimSpace(t), "folder:") { + cleaned = append(cleaned, t) } - } else if doc.Content != "" { - md.WriteString(doc.Content + "\n\n") } - - targetPath := filepath.Join(userDir, doc.ID+".md") - _ = os.WriteFile(targetPath, []byte(md.String()), 0644) + return cleaned } -func (s *Store) removeDocumentFromDisk(doc *domain.Document) { - if s.docDir == "" || doc == nil { +func (s *Store) migrateWorkspaceToPureDB() { + now := domain.NowISO() + + // 1. Migrate documents.blocks_json into document_blocks for any doc without rows in document_blocks + rows, err := s.db.Query(` + SELECT d.id, d.user_id, d.blocks_json, d.tags, d.folder_path, d.updated_at + FROM documents d + `) + if err == nil { + type migDoc struct { + id, userID, blocksJSON, tagsJSON, folderPath, updatedAt string + } + var docsToMigrate []migDoc + for rows.Next() { + var md migDoc + if err := rows.Scan(&md.id, &md.userID, &md.blocksJSON, &md.tagsJSON, &md.folderPath, &md.updatedAt); err == nil { + docsToMigrate = append(docsToMigrate, md) + } + } + _ = rows.Close() + + for _, md := range docsToMigrate { + var count int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM document_blocks WHERE doc_id = ?`, md.id).Scan(&count) + if count == 0 && md.blocksJSON != "" && md.blocksJSON != "[]" { + var blocks []domain.DocumentBlock + if err := json.Unmarshal([]byte(md.blocksJSON), &blocks); err == nil && len(blocks) > 0 { + ts := md.updatedAt + if ts == "" { + ts = now + } + s.replaceDocBlocksInDB(md.id, md.userID, blocks, ts) + } + } + + // Strip legacy folder:* tags from documents.tags + if strings.Contains(md.tagsJSON, "folder:") { + var tags []string + if err := json.Unmarshal([]byte(md.tagsJSON), &tags); err == nil { + cleaned := stripFolderTags(tags) + cleanedBytes, _ := json.Marshal(cleaned) + _, _ = s.db.Exec(`UPDATE documents SET tags = ? WHERE id = ?`, string(cleanedBytes), md.id) + } + } + + // Ensure personal folder exists in workspace_folders + if md.folderPath != "" { + var existsID string + err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ?`, md.userID, md.folderPath).Scan(&existsID) + if err == sql.ErrNoRows { + _, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`, + domain.NewID("wfld"), md.userID, md.folderPath, now) + } + } + } + } + + // 2. Sync all existing shared folders so all docs/subfolders in their owner's mount path are linked + sfRows, err := s.db.Query(` + SELECT f.id, f.owner_id, COALESCE(m.mount_path, f.name) + FROM workspace_shared_folders f + LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = f.owner_id + `) + if err == nil { + type sfInfo struct{ id, ownerID, mountPath string } + var sfs []sfInfo + for sfRows.Next() { + var info sfInfo + if err := sfRows.Scan(&info.id, &info.ownerID, &info.mountPath); err == nil { + sfs = append(sfs, info) + } + } + _ = sfRows.Close() + for _, info := range sfs { + s.syncSharedFolderContents(info.ownerID, info.id, info.mountPath) + } + } +} + +func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock { + rows, err := s.db.Query(` + SELECT id, type, content, level, checked, collapsed, lang + FROM document_blocks + WHERE doc_id = ? + ORDER BY sort_order ASC, rowid ASC + `, docID) + if err == nil { + blocks := make([]domain.DocumentBlock, 0) + for rows.Next() { + var b domain.DocumentBlock + var chk, col int + if scanErr := rows.Scan(&b.ID, &b.Type, &b.Content, &b.Level, &chk, &col, &b.Lang); scanErr == nil { + b.Checked = chk != 0 + b.Collapsed = col != 0 + blocks = append(blocks, b) + } + } + _ = rows.Close() + if len(blocks) > 0 { + return blocks + } + } + + blocks := make([]domain.DocumentBlock, 0) + if fallbackJSON != "" && fallbackJSON != "[]" { + _ = json.Unmarshal([]byte(fallbackJSON), &blocks) + if len(blocks) > 0 { + s.replaceDocBlocksInDB(docID, "", blocks, domain.NowISO()) + } + } + return blocks +} + +func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.DocumentBlock, now string) { + _, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, docID) + if len(blocks) == 0 { return } - userDir := filepath.Join(s.docDir, doc.UserID) - if doc.FolderPath != "" { - userDir = filepath.Join(userDir, filepath.Clean(doc.FolderPath)) + stmt, err := s.db.Prepare(` + INSERT INTO document_blocks (id, doc_id, sort_order, type, content, level, checked, collapsed, lang, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + doc_id = excluded.doc_id, + sort_order = excluded.sort_order, + type = excluded.type, + content = excluded.content, + level = excluded.level, + checked = excluded.checked, + collapsed = excluded.collapsed, + lang = excluded.lang, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by + `) + if err != nil { + return } - targetPath := filepath.Join(userDir, doc.ID+".md") - _ = os.Remove(targetPath) + defer stmt.Close() + + for i, b := range blocks { + bID := strings.TrimSpace(b.ID) + if bID == "" { + bID = domain.NewID("blk") + } + bType := b.Type + if bType == "" { + bType = "bullet" + } + chk := 0 + if b.Checked { + chk = 1 + } + col := 0 + if b.Collapsed { + col = 1 + } + _, _ = stmt.Exec(bID, docID, float64((i+1)*1000), bType, b.Content, b.Level, chk, col, b.Lang, now, userID) + } +} + +// PatchDocumentBlocks applies atomic block-level upserts, deletions, and order reconciliation without overwriting concurrent edits from other sessions. +func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPatchRequest) (*domain.Document, []string, error) { + doc, err := s.GetDocumentByID(userID, docID) + if err != nil { + return nil, nil, err + } + if !doc.IsOwner && doc.Permission != "write" { + return nil, nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") + } + + now := domain.NowISO() + + // Ensure document_blocks is seeded if previously only in blocks_json + var existingCount int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM document_blocks WHERE doc_id = ?`, docID).Scan(&existingCount) + if existingCount == 0 && len(doc.Blocks) > 0 { + s.replaceDocBlocksInDB(docID, doc.UserID, doc.Blocks, doc.UpdatedAt) + } + + // Read current ordered block IDs from DB before patch + var existingOrder []string + ordRows, err := s.db.Query(`SELECT id FROM document_blocks WHERE doc_id = ? ORDER BY sort_order ASC, rowid ASC`, docID) + if err == nil { + for ordRows.Next() { + var bid string + if err := ordRows.Scan(&bid); err == nil { + existingOrder = append(existingOrder, bid) + } + } + _ = ordRows.Close() + } + + // 1. Delete explicitly deleted block IDs + deletedSet := make(map[string]bool, len(req.DeletedIDs)) + for _, delID := range req.DeletedIDs { + delID = strings.TrimSpace(delID) + if delID == "" { + continue + } + deletedSet[delID] = true + _, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ? AND id = ?`, docID, delID) + } + + // Filter existingOrder to exclude deleted blocks + survivingExisting := make([]string, 0, len(existingOrder)) + survivingSet := make(map[string]bool, len(existingOrder)) + for _, bid := range existingOrder { + if !deletedSet[bid] { + survivingExisting = append(survivingExisting, bid) + survivingSet[bid] = true + } + } + + // 2. Upsert modified or newly created blocks + upsertedIDs := make([]string, 0, len(req.Upserted)) + for idx, b := range req.Upserted { + bID := strings.TrimSpace(b.ID) + if bID == "" || deletedSet[bID] { + continue + } + bType := b.Type + if bType == "" { + bType = "bullet" + } + chk := 0 + if b.Checked { + chk = 1 + } + col := 0 + if b.Collapsed { + col = 1 + } + defaultSort := float64((len(survivingExisting) + idx + 1) * 1000) + _, _ = s.db.Exec(` + INSERT INTO document_blocks (id, doc_id, sort_order, type, content, level, checked, collapsed, lang, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + type = excluded.type, + content = excluded.content, + level = excluded.level, + checked = excluded.checked, + collapsed = excluded.collapsed, + lang = excluded.lang, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by + `, bID, docID, defaultSort, bType, b.Content, b.Level, chk, col, b.Lang, now, userID) + upsertedIDs = append(upsertedIDs, bID) + survivingSet[bID] = true + } + + // 3. Reconcile block ordering without dropping blocks concurrently created by other users + var mergedOrder []string + inMerged := make(map[string]bool) + + if len(req.BlockOrder) > 0 { + for _, bid := range req.BlockOrder { + if !deletedSet[bid] && survivingSet[bid] && !inMerged[bid] { + mergedOrder = append(mergedOrder, bid) + 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 { + if inMerged[bid] { + continue + } + insertPos := 0 + for j := i - 1; j >= 0; j-- { + predID := survivingExisting[j] + if inMerged[predID] { + for k, mID := range mergedOrder { + if mID == predID { + insertPos = k + 1 + break + } + } + break + } + } + mergedOrder = append(mergedOrder[:insertPos], append([]string{bid}, mergedOrder[insertPos:]...)...) + inMerged[bid] = true + } + // Ensure any upserted block not in BlockOrder is also appended + for _, bid := range upsertedIDs { + if !inMerged[bid] { + mergedOrder = append(mergedOrder, bid) + inMerged[bid] = true + } + } + } else { + for _, bid := range survivingExisting { + if !inMerged[bid] { + mergedOrder = append(mergedOrder, bid) + inMerged[bid] = true + } + } + for _, bid := range upsertedIDs { + if !inMerged[bid] { + mergedOrder = append(mergedOrder, bid) + inMerged[bid] = true + } + } + } + + // Update sort_order in document_blocks + 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) + } + + // Reload authoritative block list and update document snapshot + finalBlocks := s.loadDocBlocks(docID, "") + var plainBuilder strings.Builder + for _, b := range finalBlocks { + if b.Content != "" { + plainBuilder.WriteString(b.Content) + plainBuilder.WriteString("\n") + } + } + plainContent := plainBuilder.String() + blocksJSON, _ := json.Marshal(finalBlocks) + + _, _ = s.db.Exec(`UPDATE documents SET content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`, + plainContent, string(blocksJSON), now, docID) + + doc.Blocks = finalBlocks + doc.Content = plainContent + doc.UpdatedAt = now + _ = s.indexDocBlockTags(doc.ID, doc.UserID, finalBlocks) + + return doc, mergedOrder, nil +} + +// GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID. +func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) { + userSet := make(map[string]bool) + + var ownerID, sharedFolderID string + err := s.db.QueryRow(`SELECT user_id, shared_folder_id FROM documents WHERE id = ?`, docID).Scan(&ownerID, &sharedFolderID) + if err != nil { + return nil, err + } + if ownerID != "" { + userSet[ownerID] = true + } + + // Direct user shares + uRows, err := s.db.Query(`SELECT user_id FROM doc_shares WHERE doc_id = ?`, docID) + if err == nil { + for uRows.Next() { + var uid string + if err := uRows.Scan(&uid); err == nil && uid != "" { + userSet[uid] = true + } + } + _ = uRows.Close() + } + + // Team shares + tRows, err := s.db.Query(` + SELECT tm.user_id + FROM doc_team_shares dts + JOIN team_members tm ON tm.team_id = dts.team_id + WHERE dts.doc_id = ? + `, docID) + if err == nil { + for tRows.Next() { + var uid string + if err := tRows.Scan(&uid); err == nil && uid != "" { + userSet[uid] = true + } + } + _ = tRows.Close() + } + + // Shared folder access + if sharedFolderID != "" { + sfUsers, err := s.GetAuthorizedSharedFolderUsers(sharedFolderID) + if err == nil { + for _, uid := range sfUsers { + userSet[uid] = true + } + } + } + + users := make([]string, 0, len(userSet)) + for uid := range userSet { + users = append(users, uid) + } + return users, nil +} + +// GetAuthorizedSharedFolderUsers returns all user IDs who have access to a shared folder. +func (s *Store) GetAuthorizedSharedFolderUsers(folderID string) ([]string, error) { + userSet := make(map[string]bool) + + var ownerID string + if err := s.db.QueryRow(`SELECT owner_id FROM workspace_shared_folders WHERE id = ?`, folderID).Scan(&ownerID); err == nil && ownerID != "" { + userSet[ownerID] = true + } + + rows, err := s.db.Query(` + SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? + `, folderID) + if err == nil { + type shareTarget struct{ tType, tID string } + var targets []shareTarget + for rows.Next() { + var st shareTarget + if err := rows.Scan(&st.tType, &st.tID); err == nil { + targets = append(targets, st) + } + } + _ = rows.Close() + + for _, st := range targets { + if st.tType == "user" && st.tID != "" { + userSet[st.tID] = true + } else if st.tType == "team" && st.tID != "" { + mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, st.tID) + if err == nil { + for mRows.Next() { + var uid string + if err := mRows.Scan(&uid); err == nil && uid != "" { + userSet[uid] = true + } + } + _ = mRows.Close() + } + } + } + } + + users := make([]string, 0, len(userSet)) + for uid := range userSet { + users = append(users, uid) + } + return users, nil } func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string, blocks []domain.DocumentBlock, tags []string, isPinned bool) (*domain.Document, error) { @@ -1292,9 +1682,7 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string, {ID: domain.NewID("blk"), Type: "paragraph", Content: ""}, } } - if tags == nil { - tags = []string{} - } + tags = stripFolderTags(tags) var plainBuilder strings.Builder for _, b := range blocks { @@ -1312,26 +1700,34 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string, pinnedInt = 1 } + cleanFolder := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath))) + if cleanFolder == "." { + cleanFolder = "" + } + mounts, _, _ := s.getUserFolderMounts(userID) var sharedFolderID, subFolderPath string - if fID, sub, ok := matchMountPath(folderPath, mounts); ok { + if fID, sub, ok := matchMountPath(cleanFolder, mounts); ok { sharedFolderID = fID subFolderPath = sub } doc := &domain.Document{ - ID: domain.NewID("doc"), - UserID: userID, - SpaceID: spaceID, - Title: strings.TrimSpace(title), - Icon: icon, - Content: plainBuilder.String(), - Blocks: blocks, - Tags: tags, - FolderPath: folderPath, + ID: domain.NewID("doc"), + UserID: userID, + SpaceID: spaceID, + Title: strings.TrimSpace(title), + Icon: icon, + Content: plainBuilder.String(), + Blocks: blocks, + Tags: tags, + FolderPath: cleanFolder, IsPinned: isPinned, SharedFolderID: sharedFolderID, SubFolderPath: subFolderPath, + IsSharedFolder: sharedFolderID != "", + Permission: "owner", + IsOwner: true, CreatedAt: now, UpdatedAt: now, } @@ -1347,7 +1743,7 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string, if isPinned { _, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, doc.ID, doc.CreatedAt) } - s.saveDocumentToDisk(doc) + s.replaceDocBlocksInDB(doc.ID, userID, doc.Blocks, now) _ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks) return doc, nil } @@ -1476,6 +1872,30 @@ func scanDocument(rows interface { d.Tags = []string{} _ = json.Unmarshal([]byte(blocksStr), &d.Blocks) _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) + return d, nil +} + +func (s *Store) scanDocumentWithBlocks(rows interface { + Scan(dest ...any) error +}) (*domain.Document, error) { + d := &domain.Document{} + var blocksStr, tagsStr string + var isPinned, isArchived int + err := rows.Scan( + &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, + &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, + &d.SharedFolderID, &d.SubFolderPath, + ) + if err != nil { + return nil, err + } + d.IsPinned = isPinned != 0 + d.IsArchived = isArchived != 0 + d.Tags = []string{} + _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) + d.Blocks = s.loadDocBlocks(d.ID, blocksStr) return d, nil } @@ -1510,18 +1930,39 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) { return nil, err } - docs := make([]domain.Document, 0) + type rawDocRow struct { + doc domain.Document + blocksStr string + } + var ownRaw []rawDocRow for rows.Next() { - d, err := scanDocument(rows) - if err == nil { + d := domain.Document{} + var blocksStr, tagsStr string + var isPinned, isArchived int + if err := rows.Scan( + &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, + &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, + &d.SharedFolderID, &d.SubFolderPath, + ); err == nil { + d.IsPinned = isPinned != 0 + d.IsArchived = isArchived != 0 + d.Tags = []string{} + _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) d.Permission = "owner" d.IsOwner = true - resolveDocumentPath(userID, d, mounts, names, userFolders) - docs = append(docs, *d) + resolveDocumentPath(userID, &d, mounts, names, userFolders) + ownRaw = append(ownRaw, rawDocRow{doc: d, blocksStr: blocksStr}) } } _ = rows.Close() + docs := make([]domain.Document, 0, len(ownRaw)) + for _, r := range ownRaw { + r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr) + docs = append(docs, r.doc) + } + // 2. Fetch documents shared with the user directly, via teams, or via shared folders sharedRows, err := s.db.Query( `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, @@ -1559,8 +2000,9 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) { userID, userID, userID, userID, userID, userID, userID, userID, ) if err == nil { + var sharedRaw []rawDocRow for sharedRows.Next() { - d := &domain.Document{} + d := domain.Document{} var blocksStr, tagsStr string var isPinned, isArchived int var perm, ownerName string @@ -1572,18 +2014,22 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) { ); scanErr == nil { d.IsPinned = isPinned != 0 d.IsArchived = isArchived != 0 - d.Blocks = []domain.DocumentBlock{} d.Tags = []string{} - _ = json.Unmarshal([]byte(blocksStr), &d.Blocks) _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) d.Permission = perm d.IsOwner = (perm == "owner") d.OwnerName = ownerName - resolveDocumentPath(userID, d, mounts, names, userFolders) - docs = append(docs, *d) + resolveDocumentPath(userID, &d, mounts, names, userFolders) + sharedRaw = append(sharedRaw, rawDocRow{doc: d, blocksStr: blocksStr}) } } _ = sharedRows.Close() + + for _, r := range sharedRaw { + r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr) + docs = append(docs, r.doc) + } } // 3. Attach shares to owned documents @@ -1617,7 +2063,7 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) WHERE d.id = ? AND d.user_id = ?`, userID, docID, userID, ) - doc, err := scanDocument(row) + doc, err := s.scanDocumentWithBlocks(row) if err == nil { doc.Permission = "owner" doc.IsOwner = true @@ -1676,10 +2122,10 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) } d.IsPinned = isPinned != 0 d.IsArchived = isArchived != 0 - d.Blocks = []domain.DocumentBlock{} d.Tags = []string{} - _ = json.Unmarshal([]byte(blocksStr), &d.Blocks) _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) + d.Blocks = s.loadDocBlocks(d.ID, blocksStr) d.Permission = perm d.IsOwner = (perm == "owner") d.OwnerName = ownerName @@ -1743,11 +2189,14 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st if icon != nil && *icon != "" { doc.Icon = *icon } - if folderPath != nil { - doc.FolderPath = *folderPath + if tags != nil { + doc.Tags = stripFolderTags(*tags) } + + doc.UpdatedAt = domain.NowISO() if blocks != nil { doc.Blocks = *blocks + s.replaceDocBlocksInDB(docID, userID, doc.Blocks, doc.UpdatedAt) var plainBuilder strings.Builder for _, b := range doc.Blocks { if b.Content != "" { @@ -1757,23 +2206,19 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st } doc.Content = plainBuilder.String() } - if tags != nil { - doc.Tags = *tags - } - doc.UpdatedAt = domain.NowISO() blocksJSON, _ := json.Marshal(doc.Blocks) tagsJSON, _ := json.Marshal(doc.Tags) + // IMPORTANT: Never overwrite documents.folder_path here! Folder placement is managed exclusively by MoveDocument. _, err = s.db.Exec( - `UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, folder_path = ?, updated_at = ? + `UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, updated_at = ? WHERE id = ?`, - doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, doc.UpdatedAt, docID, + doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.UpdatedAt, docID, ) if err != nil { return nil, err } - s.saveDocumentToDisk(doc) if blocks != nil { _ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks) } @@ -1787,13 +2232,13 @@ func (s *Store) DeleteDocument(userID, docID string) error { } if !doc.IsOwner { // Collaborator removing their shared access + _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID) return s.DeleteDocShare(userID, docID, userID) } - if doc != nil { - s.removeDocumentFromDisk(doc) - } + _, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, docID) _, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID) - _, err = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, docID, userID) + _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, docID) + _, err = s.db.Exec(`DELETE FROM documents WHERE id = ?`, docID) return err } @@ -1817,8 +2262,8 @@ func (s *Store) reindexAllDocBlockTags() error { _ = rows.Close() for _, d := range docs { - var blocks []domain.DocumentBlock - if err := json.Unmarshal([]byte(d.blocksJSON), &blocks); err == nil && len(blocks) > 0 { + blocks := s.loadDocBlocks(d.id, d.blocksJSON) + if len(blocks) > 0 { _ = s.indexDocBlockTags(d.id, d.userID, blocks) } } @@ -1851,22 +2296,6 @@ func (s *Store) indexDocBlockTags(docID, userID string, blocks []domain.Document return nil } -func (s *Store) saveTagPageToDisk(userID, tag, content string) { - if s.docDir == "" || tag == "" { - return - } - tagDir := filepath.Join(filepath.Dir(s.docDir), "tag_pages", userID) - _ = os.MkdirAll(tagDir, 0755) - safeTag := regexp.MustCompile(`[^a-zA-Z0-9äöüÄÖÜß_-]+`).ReplaceAllString(tag, "_") - targetPath := filepath.Join(tagDir, safeTag+".md") - if strings.TrimSpace(content) == "" { - _ = os.Remove(targetPath) - return - } - md := fmt.Sprintf("# #%s\n\n%s\n", tag, content) - _ = os.WriteFile(targetPath, []byte(md), 0644) -} - func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) { cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#")) if cleanTag == "" { @@ -1882,7 +2311,6 @@ func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) { if err != nil { return "", err } - s.saveTagPageToDisk(userID, cleanTag, content) return now, nil } @@ -2015,8 +2443,7 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) { matches := docMap[docID] first := matches[0] - var blocks []domain.DocumentBlock - _ = json.Unmarshal([]byte(first.blocksJSON), &blocks) + blocks := s.loadDocBlocks(first.docID, first.blocksJSON) blockIdxMap := make(map[string]int, len(blocks)) for i, b := range blocks { @@ -2137,23 +2564,6 @@ func (s *Store) GetFolders(userID string) ([]string, error) { _ = pRows.Close() } - // 4. Scan physical directories on disk in docDir/userID - if s.docDir != "" { - userDir := filepath.Join(s.docDir, userID) - if fi, err := os.Stat(userDir); err == nil && fi.IsDir() { - _ = filepath.Walk(userDir, func(path string, info os.FileInfo, err error) error { - if err != nil || !info.IsDir() || path == userDir { - return nil - } - rel, err := filepath.Rel(userDir, path) - if err == nil && rel != "" && rel != "." { - addFolderSegments(rel, folderSet) - } - return nil - }) - } - } - result := make([]string, 0, len(folderSet)) for f := range folderSet { result = append(result, f) @@ -2212,12 +2622,6 @@ func (s *Store) CreateFolder(userID, folderPath string) (string, error) { } } - if s.docDir != "" { - targetDir := filepath.Join(s.docDir, userID, clean) - if err := os.MkdirAll(targetDir, 0755); err != nil { - return "", err - } - } return clean, nil } @@ -2227,54 +2631,87 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) { return nil, fmt.Errorf("ungültiger Ordnerpfad") } - // Find all documents in this folder and subfolders - rows, err := s.db.Query( - `SELECT id FROM documents - WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`, - userID, clean, clean+"/%", - ) - if err != nil { - return nil, err - } - defer rows.Close() - var deletedDocIDs []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err == nil { - deletedDocIDs = append(deletedDocIDs, id) - } - } - _ = rows.Close() + seenDocID := make(map[string]bool) - // Delete from SQLite, pins, shares, tags - for _, id := range deletedDocIDs { - _, _ = s.db.Exec(`DELETE FROM doc_block_tags 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 documents WHERE id = ? AND user_id = ?`, id, userID) - } - - // Clean up from workspace_folders mounts, _, err := s.getUserFolderMounts(userID) if err == nil { sfID, subPath, isMatch := matchMountPath(clean, mounts) if isMatch && sfID != "" { if subPath == "" { + // Root of a shared folder + shFolder, sfErr := s.GetSharedFolderByID(userID, sfID) + if sfErr == nil && !shFolder.IsOwner { + // Collaborator removing mount/share + _ = s.DeleteSharedFolder(userID, sfID) + return []string{}, nil + } + rows, qErr := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ?`, sfID) + if qErr == nil { + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil && !seenDocID[id] { + seenDocID[id] = true + deletedDocIDs = append(deletedDocIDs, id) + } + } + _ = rows.Close() + } _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, sfID) } else { + // Subfolder inside a shared folder + rows, qErr := s.db.Query( + `SELECT id FROM documents WHERE shared_folder_id = ? AND (sub_folder_path = ? OR sub_folder_path LIKE ?)`, + sfID, subPath, subPath+"/%", + ) + if qErr == nil { + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil && !seenDocID[id] { + seenDocID[id] = true + deletedDocIDs = append(deletedDocIDs, id) + } + } + _ = rows.Close() + } _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ? AND (sub_path = ? OR sub_path LIKE ?)`, sfID, subPath, subPath+"/%") } } } - _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%") - // Remove physical folder and subdirectories from disk - if s.docDir != "" { - targetDir := filepath.Join(s.docDir, userID, clean) - _ = os.RemoveAll(targetDir) + // Find all personal documents owned by this user in this folder and subfolders + rows, err := s.db.Query( + `SELECT id FROM documents + WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL) AND (folder_path = ? OR folder_path LIKE ?)`, + userID, clean, clean+"/%", + ) + if err != nil { + return nil, err } + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil && !seenDocID[id] { + seenDocID[id] = true + deletedDocIDs = append(deletedDocIDs, id) + } + } + _ = rows.Close() + + // Delete documents from SQLite, blocks, pins, shares, tags, user_folders + for _, id := range deletedDocIDs { + _, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, id) + _, _ = s.db.Exec(`DELETE FROM doc_block_tags 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_team_shares WHERE doc_id = ?`, id) + _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, id) + _, _ = s.db.Exec(`DELETE FROM documents WHERE id = ?`, id) + } + + // Also clear any personal folder placements for shared notes in this folder + _, _ = s.db.Exec(`UPDATE doc_user_folders SET folder_path = '' WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`, userID, clean, clean+"/%") + _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%") return deletedDocIDs, nil } @@ -2370,49 +2807,15 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do return doc, nil } -func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) (*domain.SharedFolder, error) { - cleanName := strings.TrimSpace(folderName) - if cleanName == "" { - return nil, fmt.Errorf("ordnername darf nicht leer sein") - } - cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(initialMountPath))) - if cleanMount == "." || cleanMount == "" { - cleanMount = cleanName +// syncSharedFolderContents ensures all documents and subfolders under cleanMount owned by ownerID are linked to folderID. +func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) { + if ownerID == "" || folderID == "" || cleanMount == "" { + return } - // Check if already shared by this user at this mount path - var existingID string - err := s.db.QueryRow(` - SELECT f.id FROM workspace_shared_folders f - JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ? - WHERE f.owner_id = ? AND (m.mount_path = ? OR f.name = ?) - `, userID, userID, cleanMount, cleanName).Scan(&existingID) - if err == nil && existingID != "" { - return s.GetSharedFolderByID(userID, existingID) - } - - now := domain.NowISO() - folderID := domain.NewID("fld") - - _, err = s.db.Exec(` - INSERT INTO workspace_shared_folders (id, owner_id, name, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - `, folderID, userID, cleanName, now, now) - if err != nil { - return nil, err - } - - _, err = s.db.Exec(` - INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - `, userID, folderID, cleanMount, now, now) - if err != nil { - return nil, err - } - - // Link all existing documents in cleanMount to this shared folder + // 1. Link all documents owned by ownerID in cleanMount to this shared folder rows, err := s.db.Query(`SELECT id, folder_path FROM documents WHERE user_id = ? AND is_archived = 0 AND (folder_path = ? OR folder_path LIKE ?)`, - userID, cleanMount, cleanMount+"/%") + ownerID, cleanMount, cleanMount+"/%") if err == nil { type docEntry struct{ id, fp string } var entries []docEntry @@ -2436,8 +2839,9 @@ func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) } } - // Migrate any existing workspace_folders created by this user in cleanMount - fRows, err := s.db.Query(`SELECT id, path FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, cleanMount, cleanMount+"/%") + // 2. Link any workspace_folders created by ownerID in cleanMount + fRows, err := s.db.Query(`SELECT id, path FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, + ownerID, cleanMount, cleanMount+"/%") if err == nil { type wfEntry struct{ id, path string } var wfEntries []wfEntry @@ -2454,9 +2858,54 @@ func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) if strings.HasPrefix(wfe.path, cleanMount+"/") { subP = strings.TrimPrefix(wfe.path, cleanMount+"/") } - _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, folderID, subP, wfe.id) + _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, + folderID, subP, wfe.id) } } +} + +func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) (*domain.SharedFolder, error) { + cleanName := strings.TrimSpace(folderName) + if cleanName == "" { + return nil, fmt.Errorf("ordnername darf nicht leer sein") + } + cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(initialMountPath))) + if cleanMount == "." || cleanMount == "" { + cleanMount = cleanName + } + + // Check if already shared by this user at this mount path + var existingID string + err := s.db.QueryRow(` + SELECT f.id FROM workspace_shared_folders f + JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ? + WHERE f.owner_id = ? AND (m.mount_path = ? OR f.name = ?) + `, userID, userID, cleanMount, cleanName).Scan(&existingID) + if err == nil && existingID != "" { + s.syncSharedFolderContents(userID, existingID, cleanMount) + return s.GetSharedFolderByID(userID, existingID) + } + + now := domain.NowISO() + folderID := domain.NewID("fld") + + _, err = s.db.Exec(` + INSERT INTO workspace_shared_folders (id, owner_id, name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, folderID, userID, cleanName, now, now) + if err != nil { + return nil, err + } + + _, err = s.db.Exec(` + INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, userID, folderID, cleanMount, now, now) + if err != nil { + return nil, err + } + + s.syncSharedFolderContents(userID, folderID, cleanMount) var ownerName string _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName) @@ -2676,11 +3125,36 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde return nil, err } + var ownerMount string + _ = s.db.QueryRow(`SELECT COALESCE(m.mount_path, f.name) FROM workspace_shared_folders f LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = f.owner_id WHERE f.id = ?`, folderID).Scan(&ownerMount) + if ownerMount == "" { + ownerMount = f.Name + } + s.syncSharedFolderContents(f.OwnerID, folderID, ownerMount) + if cleanType == "user" { _, _ = s.db.Exec(` INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?) `, targetID, folderID, f.Name, now, now) + } else if cleanType == "team" { + mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, targetID) + if err == nil { + var memberIDs []string + for mRows.Next() { + var uid string + if err := mRows.Scan(&uid); err == nil && uid != "" { + memberIDs = append(memberIDs, uid) + } + } + _ = mRows.Close() + for _, uid := range memberIDs { + _, _ = s.db.Exec(` + INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, uid, folderID, f.Name, now, now) + } + } } return &domain.FolderShare{ diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 2eb8bcc..e28b60e 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -512,3 +512,158 @@ func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) { t.Fatalf("expected vorrat and kühlregal, got %v", tagMapAfter) } } + +func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test_db_first.db") + + store, err := NewStore(dbPath) + if err != nil { + t.Fatalf("NewStore failed: %v", err) + } + defer store.Close() + + userA, err := store.EnsureDefaultUser() + if err != nil { + t.Fatalf("EnsureDefaultUser failed: %v", err) + } + userB, err := store.CreateUser("userb@example.com", "hash", "User B", "member") + if err != nil { + t.Fatalf("CreateUser userB failed: %v", err) + } + + // 1. Create a document in User A's folder "Privat" and share with User B (write) + initialBlocks := []domain.DocumentBlock{ + {ID: "b1", Type: "bullet", Content: "Zeile 1 Original", Level: 0}, + {ID: "b2", Type: "bullet", Content: "Zeile 2 Original", Level: 0}, + } + doc, err := store.CreateDocument(userA.ID, "", "Gemeinsame Notiz", "📝", "Privat", initialBlocks, []string{"folder:Privat", "notiz"}, false) + if err != nil { + t.Fatalf("CreateDocument failed: %v", err) + } + // Verify folder:* tag was stripped + for _, tg := range doc.Tags { + if tg == "folder:Privat" { + t.Fatalf("expected folder:* tag to be stripped, got tags: %v", doc.Tags) + } + } + + _, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "write") + if err != nil { + t.Fatalf("CreateDocShare failed: %v", err) + } + + // User B moves the individually shared note into "Arbeit/Eingang" + _, err = store.MoveDocument(userB.ID, doc.ID, "Arbeit/Eingang") + if err != nil { + t.Fatalf("MoveDocument by userB failed: %v", err) + } + + // User B edits the document via PatchDocumentBlocks (modifying b2 and adding b3) + // Simultaneously, User A edits b1 and adds b4 using a slightly stale order + _, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{ + Upserted: []domain.DocumentBlock{ + {ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0}, + {ID: "b3", Type: "bullet", Content: "Zeile 3 neu von B", Level: 0}, + }, + BlockOrder: []string{"b1", "b2", "b3"}, + }) + if err != nil { + t.Fatalf("PatchDocumentBlocks by userB failed: %v", err) + } + + // User A's client only knew about b1, b2 when sending its patch (adds b4) + patchedDocA, _, err := store.PatchDocumentBlocks(userA.ID, doc.ID, domain.DocBlocksPatchRequest{ + Upserted: []domain.DocumentBlock{ + {ID: "b1", Type: "bullet", Content: "Zeile 1 von A bearbeitet", Level: 0}, + {ID: "b4", Type: "bullet", Content: "Zeile 4 neu von A", Level: 0}, + }, + BlockOrder: []string{"b1", "b2", "b4"}, // stale order without b3! + }) + if err != nil { + t.Fatalf("PatchDocumentBlocks by userA failed: %v", err) + } + + // Verify NO blocks were lost! All 4 blocks (b1, b2, b3, b4) must exist with both users' edits preserved + if len(patchedDocA.Blocks) != 4 { + t.Fatalf("expected 4 blocks after concurrent patches, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks) + } + blockContent := make(map[string]string) + for _, b := range patchedDocA.Blocks { + blockContent[b.ID] = b.Content + } + if blockContent["b1"] != "Zeile 1 von A bearbeitet" { + t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"]) + } + if blockContent["b2"] != "Zeile 2 von B bearbeitet" { + t.Fatalf("expected b2 updated by B to be preserved, got %q", blockContent["b2"]) + } + if blockContent["b3"] != "Zeile 3 neu von B" { + t.Fatalf("expected b3 added by B to be preserved even with stale order from A, got %q", blockContent["b3"]) + } + if blockContent["b4"] != "Zeile 4 neu von A" { + t.Fatalf("expected b4 added by A, got %q", blockContent["b4"]) + } + + // Verify folder independence: User A still sees "Privat", User B still sees "Arbeit/Eingang" + docForA, err := store.GetDocumentByID(userA.ID, doc.ID) + if err != nil || docForA.FolderPath != "Privat" { + t.Fatalf("expected User A FolderPath 'Privat', got %q (err=%v)", docForA.FolderPath, err) + } + docForB, err := store.GetDocumentByID(userB.ID, doc.ID) + if err != nil || docForB.FolderPath != "Arbeit/Eingang" { + t.Fatalf("expected User B FolderPath 'Arbeit/Eingang', got %q (err=%v)", docForB.FolderPath, err) + } + + foldersA, err := store.GetFolders(userA.ID) + if err != nil { + t.Fatalf("GetFolders for userA failed: %v", err) + } + for _, f := range foldersA { + if f == "Arbeit" || f == "Arbeit/Eingang" { + t.Fatalf("User B's private folder leaked into User A's folder list: %v", foldersA) + } + } + + // 2. Shared Folder & Empty Subfolder Sync + _, err = store.CreateFolder(userA.ID, "TeamProjekt") + if err != nil { + t.Fatalf("CreateFolder TeamProjekt failed: %v", err) + } + sf, err := store.CreateSharedFolder(userA.ID, "TeamProjekt", "TeamProjekt") + if err != nil { + t.Fatalf("CreateSharedFolder failed: %v", err) + } + _, err = store.CreateFolderShare(userA.ID, sf.ID, "user", userB.ID, "write") + if err != nil { + t.Fatalf("CreateFolderShare failed: %v", err) + } + + // User B moves their mount root of the shared folder to "ExterneProjekte/TeamProjekt" + err = store.UpdateFolderMount(userB.ID, sf.ID, "ExterneProjekte/TeamProjekt") + if err != nil { + t.Fatalf("UpdateFolderMount failed: %v", err) + } + + // User A creates an EMPTY subfolder inside "TeamProjekt/Konzepte" + _, err = store.CreateFolder(userA.ID, "TeamProjekt/Konzepte") + if err != nil { + t.Fatalf("CreateFolder TeamProjekt/Konzepte failed: %v", err) + } + + // User B must immediately see "ExterneProjekte/TeamProjekt/Konzepte" in GetFolders without any document inside! + foldersB, err := store.GetFolders(userB.ID) + if err != nil { + t.Fatalf("GetFolders for userB failed: %v", err) + } + foundSubfolderForB := false + for _, f := range foldersB { + if f == "ExterneProjekte/TeamProjekt/Konzepte" { + foundSubfolderForB = true + } + } + if !foundSubfolderForB { + t.Fatalf("expected User B to see empty shared subfolder 'ExterneProjekte/TeamProjekt/Konzepte', got folders: %v", foldersB) + } +} +