Dev #5

Merged
sandrodach merged 19 commits from dev into main 2026-10-26 13:35:00 +01:00
7 changed files with 1335 additions and 320 deletions
Showing only changes of commit ed673b0707 - Show all commits

No files matched your search

+387 -87
View File
@@ -49,7 +49,7 @@ import {
User as UserIcon, User as UserIcon,
Radio, Radio,
} from 'lucide-react'; } 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 { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected } from '../../lib/api';
import { CodeBlockItem } from './CodeBlockItem'; import { CodeBlockItem } from './CodeBlockItem';
import { UserAutocomplete } from '../UserAutocomplete'; import { UserAutocomplete } from '../UserAutocomplete';
@@ -237,7 +237,130 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({}); const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({});
const isLockedByOther = !!(activeLock && activeLock.userId !== currentUser?.id); 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<Map<string, { title: string; icon: string; tagsJson: string; isPinned: boolean }>>(new Map());
const syncedBlocksRef = useRef<Map<string, Map<string, DocumentBlock>>>(new Map());
const syncedOrderRef = useRef<Map<string, string[]>>(new Map());
const localDirtyBlocksRef = useRef<Map<string, Map<string, DocumentBlock>>>(new Map());
const localDeletedBlocksRef = useRef<Map<string, Set<string>>>(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<string, DocumentBlock>();
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<string, DocumentBlock>();
currentBlocks.forEach((b) => srvMap!.set(b.id, { ...b }));
syncedBlocksRef.current.set(docId, srvMap);
}
const deletedSet = new Set<string>(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<string, DocumentBlock>();
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<string>();
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 = () => { const stopHeartbeat = () => {
if (heartbeatTimerRef.current) { if (heartbeatTimerRef.current) {
@@ -282,7 +405,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const ensureLock = async (force = false): Promise<boolean> => { const ensureLock = async (force = false): Promise<boolean> => {
if (!selectedDoc || selectedDoc.permission === 'read') return false; if (!selectedDoc || selectedDoc.permission === 'read') return false;
if (isLockedByOther) return false;
if (holdsLockRef.current && !force) { if (holdsLockRef.current && !force) {
return true; return true;
} }
@@ -296,8 +418,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return true; return true;
} else if (res.lock) { } else if (res.lock) {
setActiveLock(res.lock); setActiveLock(res.lock);
holdsLockRef.current = false;
setHoldsLock(false);
return false; return false;
} }
} catch (e) { } catch (e) {
@@ -312,7 +432,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
clearTimeout(idleUnlockTimerRef.current); clearTimeout(idleUnlockTimerRef.current);
idleUnlockTimerRef.current = null; idleUnlockTimerRef.current = null;
} }
if (!isReadOnly && !isLockedByOther) { if (!isReadOnly) {
ensureLock(); ensureLock();
} }
}; };
@@ -322,11 +442,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (idleUnlockTimerRef.current) { if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current); clearTimeout(idleUnlockTimerRef.current);
} }
// If a block is currently being dragged, do not schedule an unlock
if (isDraggingBlockRef.current) { if (isDraggingBlockRef.current) {
return; return;
} }
// Generous grace period: if user clicked into another block in the doc or paused typing
idleUnlockTimerRef.current = setTimeout(async () => { idleUnlockTimerRef.current = setTimeout(async () => {
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
await flushPendingSave(); await flushPendingSave();
@@ -335,7 +453,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}, 8000); }, 8000);
}; };
// Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD) // Debounced auto-save refs (500ms delay)
const debounceSaveTimerRef = useRef<any>(null); const debounceSaveTimerRef = useRef<any>(null);
const pendingSaveDocRef = useRef<Document | null>(null); const pendingSaveDocRef = useRef<Document | null>(null);
@@ -343,27 +461,86 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (isReadOnly) return; if (isReadOnly) return;
let saveSucceeded = false; let saveSucceeded = false;
try { try {
const saved = await api.updateDocument(docToSave.id, { 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<string, DocumentBlock>();
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<string, DocumentBlock>();
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, title: docToSave.title,
icon: docToSave.icon, icon: docToSave.icon,
folderPath: docToSave.folderPath, tags: cleanTags,
blocks: docToSave.blocks,
tags: docToSave.tags,
isPinned: docToSave.isPinned, isPinned: docToSave.isPinned,
}); });
saveSucceeded = true; syncedDocMetaRef.current.set(docId, {
setSelectedDoc((prev) => title: docToSave.title,
prev && prev.id === saved.id icon: docToSave.icon,
? { tagsJson: cleanTagsJson,
...prev, isPinned: !!docToSave.isPinned,
...saved, });
permission: prev.permission,
isOwner: prev.isOwner,
ownerName: prev.ownerName,
sharedWith: prev.sharedWith,
} }
: prev
); saveSucceeded = true;
onStatsChange(); onStatsChange();
api.getWorkspaceTags().then(setWorkspaceTags).catch(() => {}); api.getWorkspaceTags().then(setWorkspaceTags).catch(() => {});
if (selectedTag) { if (selectedTag) {
@@ -371,11 +548,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
} }
} catch (e: any) { } catch (e: any) {
console.error('Failed to save document to backend', e); 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 { } finally {
if (saveSucceeded) { if (saveSucceeded) {
if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) { if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) {
@@ -383,7 +555,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setSaveState('saved'); setSaveState('saved');
} }
} else { } else {
// Keep pendingSaveDocRef so changes are preserved and not silently marked as saved!
setSaveState('saving'); setSaveState('saving');
} }
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
@@ -413,7 +584,40 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const persistDocument = async (updatedDoc: Document, immediate = false) => { const persistDocument = async (updatedDoc: Document, immediate = false) => {
if (isReadOnly) return; 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<string, DocumentBlock>();
localDirtyBlocksRef.current.set(docId, dirtyMap);
}
let deletedSet = localDeletedBlocksRef.current.get(docId);
if (!deletedSet) {
deletedSet = new Set<string>();
localDeletedBlocksRef.current.set(docId, deletedSet);
}
const srvBlocks = syncedBlocksRef.current.get(docId);
const prevLocalBlocks =
selectedDoc && selectedDoc.id === docId ? selectedDoc.blocks || [] : [];
const nextIds = new Set<string>();
(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 // 1. Update local UI state immediately for responsive, instant typing
setSelectedDoc(updatedDoc); setSelectedDoc(updatedDoc);
@@ -430,7 +634,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (immediate) { if (immediate) {
await flushPendingSave(); await flushPendingSave();
} else { } else {
// 500 ms debounced delay after last keystroke so disk/SSD is only written after typing pauses
debounceSaveTimerRef.current = setTimeout(async () => { debounceSaveTimerRef.current = setTimeout(async () => {
debounceSaveTimerRef.current = null; debounceSaveTimerRef.current = null;
if (pendingSaveDocRef.current) { if (pendingSaveDocRef.current) {
@@ -438,7 +641,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
pendingSaveDocRef.current = null; pendingSaveDocRef.current = null;
await executeSaveToBackend(doc); await executeSaveToBackend(doc);
} }
}, 500); }, 350);
} }
}; };
@@ -450,6 +653,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
api.getWorkspaceTags().catch(() => []), api.getWorkspaceTags().catch(() => []),
api.getSharedFolders().catch(() => []), api.getSharedFolders().catch(() => []),
]); ]);
list.forEach(recordSyncedDoc);
setDocs(list); setDocs(list);
setCustomFolders(serverFolders); setCustomFolders(serverFolders);
setWorkspaceTags(tags); setWorkspaceTags(tags);
@@ -616,7 +820,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}; };
}, []); }, []);
// Real-time WebSocket listener for Document & Lock events // Real-time WebSocket listener for Document, Block Delta & Folder events
useEffect(() => { useEffect(() => {
const handleWs = (e: any) => { const handleWs = (e: any) => {
const msg = e.detail; const msg = e.detail;
@@ -634,30 +838,85 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id; const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id;
if (unlockedDocId && unlockedDocId === selectedDocIdRef.current) { if (unlockedDocId && unlockedDocId === selectedDocIdRef.current) {
setActiveLock(null); 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)));
} }
}).catch(() => {}); } 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 };
}
return nextDoc;
});
} }
} else if (msg.event === 'DOC_CREATED') { } else if (msg.event === 'DOC_CREATED') {
const created = msg.payload as Document; const created = msg.payload as Document;
if (created && created.id) { if (created && created.id) {
recordSyncedDoc(created);
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev])); setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
onStatsChange(); onStatsChange();
} }
} else if (msg.event === 'DOC_UPDATED') { } else if (msg.event === 'DOC_UPDATED') {
const updated = msg.payload as Document; const updated = msg.payload as Document;
if (updated && updated.id) { if (updated && updated.id) {
const incomingOrder = (updated.blocks || []).map((b) => b.id);
setDocs((prev) => 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 (selectedDocIdRef.current === updated.id) {
if (!holdsLockRef.current) { setSelectedDoc((prev) => {
setSelectedDoc((prev) => (prev ? { ...prev, ...updated, isPinned: prev.isPinned } : updated)); 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(); onStatsChange();
} }
@@ -673,7 +932,28 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') { } else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
api.getFolders().then(setCustomFolders).catch(() => {}); api.getFolders().then(setCustomFolders).catch(() => {});
api.getSharedFolders().then(setSharedFolders).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<WorkspaceViewProps> = ({
const getDocFolder = (doc: Document): string => { const getDocFolder = (doc: Document): string => {
if (doc.folderPath) return doc.folderPath; return doc.folderPath || '';
const tag = doc.tags?.find((t) => t.startsWith('folder:'));
return tag ? tag.replace('folder:', '') : '';
}; };
// Compute all unique folder paths // Compute all unique folder paths
@@ -894,9 +1172,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
triggerHaptic(15); triggerHaptic(15);
const tags = ['notiz']; const tags = ['notiz'];
const path = folderPath || ''; const path = folderPath || '';
if (path) {
tags.push(`folder:${path}`);
}
const created = await api.createDocument({ const created = await api.createDocument({
title: 'Neue Notiz', title: 'Neue Notiz',
icon: '📄', icon: '📄',
@@ -945,16 +1220,37 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const handleChangeDocFolder = async (folderPath: string) => { const handleChangeDocFolder = async (folderPath: string) => {
if (!selectedDoc) return; if (!selectedDoc) return;
const cleanPath = folderPath.trim(); const cleanPath = folderPath.trim();
const updatedTags = (selectedDoc.tags || []).filter((t) => !t.startsWith('folder:')); try {
if (cleanPath) { const updatedDoc = await api.moveDocument(selectedDoc.id, cleanPath);
updatedTags.push(`folder:${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 // 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<WorkspaceViewProps> = ({
const updatedDocsList = docs.map((d) => { const updatedDocsList = docs.map((d) => {
const f = getDocFolder(d); const f = getDocFolder(d);
if (f === cleanSource) { if (f === cleanSource) {
const nextFolder = newFolderPath; return { ...d, folderPath: newFolderPath };
const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:'));
if (nextFolder) updatedTags.push(`folder:${nextFolder}`);
return { ...d, folderPath: nextFolder, tags: updatedTags };
} else if (f.startsWith(cleanSource + '/')) { } else if (f.startsWith(cleanSource + '/')) {
const suffix = f.slice(cleanSource.length); const suffix = f.slice(cleanSource.length);
const nextFolder = `${newFolderPath}${suffix}`; return { ...d, folderPath: `${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; return d;
}); });
@@ -1040,25 +1330,32 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (found) setSelectedDoc(found); if (found) setSelectedDoc(found);
} }
// Persist all affected docs to backend using api.moveDocument try {
for (const d of docsToUpdate) { 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}`);
}
// Persist all affected docs to backend BEFORE deleting the old empty folder
await Promise.all(
docsToUpdate.map((d) => {
const f = getDocFolder(d); const f = getDocFolder(d);
let nextFolder = newFolderPath; let nextFolder = newFolderPath;
if (f.startsWith(cleanSource + '/')) { if (f.startsWith(cleanSource + '/')) {
const suffix = f.slice(cleanSource.length); const suffix = f.slice(cleanSource.length);
nextFolder = `${newFolderPath}${suffix}`; nextFolder = `${newFolderPath}${suffix}`;
} }
api.moveDocument(d.id, nextFolder).catch((err) => console.error('Failed to move doc', err)); 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 // Expand the new folder path and its parent
setExpandedFolders((prev) => { setExpandedFolders((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -2887,10 +3184,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}; };
}); });
// Persist immediately to backend & disk // Persist immediately via atomic block patch
await api.updateDocument(docId, { const toggledBlock = updatedBlocks.find((b) => b.id === blockId);
blocks: updatedBlocks, if (toggledBlock) {
await api.patchDocumentBlocks(docId, {
upserted: [toggledBlock],
}); });
}
}; };
// Jump directly to document from rollup // Jump directly to document from rollup
@@ -3916,16 +4216,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{isLockedByOther && ( {isLockedByOther && (
<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300 rounded-card flex items-center gap-2.5 text-xs font-medium animate-pulse"> <div className="p-2.5 bg-emerald-500/10 border border-emerald-500/30 text-emerald-700 dark:text-emerald-300 rounded-card flex items-center gap-2.5 text-xs font-medium">
<Lock className="w-4 h-4 text-amber-500 flex-shrink-0" /> <Radio className="w-4 h-4 text-emerald-500 animate-pulse flex-shrink-0" />
<div> <div>
<span>Diese Notiz wird aktuell von <strong>{activeLock?.userName || 'einem anderen Benutzer'}</strong> bearbeitet.</span> <span><strong>{activeLock?.userName || 'Ein anderer Benutzer'}</strong> bearbeitet diese Notiz gerade ebenfalls.</span>
<p className="text-[11px] opacity-80 mt-0.5">Die Bearbeitung ist vorübergehend gesperrt, um Zeilen- und Datenverlust zu vermeiden.</p> <p className="text-[11px] opacity-80 mt-0.5">Live-Zusammenarbeit aktiv – Änderungen werden automatisch Punkt für Punkt synchronisiert.</p>
</div> </div>
</div> </div>
)} )}
{!isLockedByOther && selectedDoc.permission === 'read' && ( {selectedDoc.permission === 'read' && (
<div className="p-2.5 bg-blue-500/10 border border-blue-500/30 text-blue-700 dark:text-blue-300 rounded-card flex items-center gap-2 text-xs"> <div className="p-2.5 bg-blue-500/10 border border-blue-500/30 text-blue-700 dark:text-blue-300 rounded-card flex items-center gap-2 text-xs">
<Eye className="w-4 h-4 text-blue-500 flex-shrink-0" /> <Eye className="w-4 h-4 text-blue-500 flex-shrink-0" />
<span>Du hast <strong>Nur-Lesezugriff</strong> auf diese Notiz. Änderungen können nicht vorgenommen werden.</span> <span>Du hast <strong>Nur-Lesezugriff</strong> auf diese Notiz. Änderungen können nicht vorgenommen werden.</span>
+19
View File
@@ -576,6 +576,25 @@ export const api = {
}; };
}, },
async patchDocumentBlocks(
id: string,
payload: {
upserted?: DocumentBlock[];
deletedIds?: string[];
blockOrder?: string[];
}
): Promise<Document> {
const d = await request<Document>(`/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<void> { async deleteDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}`, { method: 'DELETE' }); await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
}, },
+15
View File
@@ -165,6 +165,21 @@ export interface DocumentBlock {
collapsed?: boolean; 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 { export interface DocShare {
id: string; id: string;
docId: string; docId: string;
+56 -23
View File
@@ -124,6 +124,7 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/documents", s.handleCreateDocument) r.Post("/api/v1/documents", s.handleCreateDocument)
r.Get("/api/v1/documents/{id}", s.handleGetDocument) r.Get("/api/v1/documents/{id}", s.handleGetDocument)
r.Put("/api/v1/documents/{id}", s.handleUpdateDocument) 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.Put("/api/v1/documents/{id}/move", s.handleMoveDocument)
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument) r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
@@ -772,6 +773,18 @@ func (s *Server) handlePurgeDiscarded(w http.ResponseWriter, r *http.Request) {
// --- DOCUMENTS --- // --- 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) { func (s *Server) handleGetDocuments(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
docs, err := s.store.GetDocuments(userID) 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()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.wsHub.BroadcastDocEvent(doc.ID, "DOC_CREATED", doc) s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED")
writeJSON(w, http.StatusCreated, doc) writeJSON(w, http.StatusCreated, doc)
} }
@@ -822,16 +835,6 @@ func (s *Server) handleUpdateDocument(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")
// 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 { var req struct {
Title *string `json:"title"` Title *string `json:"title"`
Icon *string `json:"icon"` Icon *string `json:"icon"`
@@ -851,11 +854,44 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
return return
} }
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.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
} }
writeJSON(w, http.StatusOK, doc) 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) { 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")
@@ -990,7 +1026,7 @@ func (s *Server) handleUpdateFolderMount(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return 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}) 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()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc) s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", nil) s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", nil)
writeJSON(w, http.StatusOK, doc) 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()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
members, _ := s.store.GetTeamMembers(req.TeamID) s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
for _, tm := range members { s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
s.wsHub.BroadcastToUser(tm.UserID, "DOC_SHARED", share)
}
s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share)
writeJSON(w, http.StatusCreated, share) writeJSON(w, http.StatusCreated, share)
return return
} }
@@ -1176,8 +1209,8 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.wsHub.BroadcastToUser(share.UserID, "DOC_SHARED", share) s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share) s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
writeJSON(w, http.StatusCreated, share) 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()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.wsHub.BroadcastToUser(targetUserID, "DOC_UNSHARED", map[string]string{"docId": docID}) s.wsHub.BroadcastToUser(targetUserID, "DOC_DELETED", map[string]string{"id": docID})
s.wsHub.BroadcastDocEvent(docID, "DOC_UNSHARED", map[string]string{"docId": docID, "userId": targetUserID}) s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
} }
+19
View File
@@ -269,6 +269,25 @@ type DocumentBlock struct {
Collapsed bool `json:"collapsed,omitempty"` 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 { type DocShare struct {
ID string `json:"id"` ID string `json:"id"`
DocID string `json:"docId"` DocID string `json:"docId"`
+684 -210
View File
File diff suppressed because it is too large. Load diff
+155
View File
@@ -512,3 +512,158 @@ func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
t.Fatalf("expected vorrat and kühlregal, got %v", tagMapAfter) 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)
}
}