Dev #5
No files matched your search
@@ -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<WorkspaceViewProps> = ({
|
||||
const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({});
|
||||
|
||||
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 = () => {
|
||||
if (heartbeatTimerRef.current) {
|
||||
@@ -282,7 +405,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
const ensureLock = async (force = false): Promise<boolean> => {
|
||||
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<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
idleUnlockTimerRef.current = null;
|
||||
}
|
||||
if (!isReadOnly && !isLockedByOther) {
|
||||
if (!isReadOnly) {
|
||||
ensureLock();
|
||||
}
|
||||
};
|
||||
@@ -322,11 +442,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
}, 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 pendingSaveDocRef = useRef<Document | null>(null);
|
||||
|
||||
@@ -343,27 +461,86 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
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<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,
|
||||
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<WorkspaceViewProps> = ({
|
||||
}
|
||||
} 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<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
|
||||
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<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
|
||||
setSelectedDoc(updatedDoc);
|
||||
@@ -430,7 +634,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
pendingSaveDocRef.current = null;
|
||||
await executeSaveToBackend(doc);
|
||||
}
|
||||
}, 500);
|
||||
}, 350);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -450,6 +653,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
api.getWorkspaceTags().catch(() => []),
|
||||
api.getSharedFolders().catch(() => []),
|
||||
]);
|
||||
list.forEach(recordSyncedDoc);
|
||||
setDocs(list);
|
||||
setCustomFolders(serverFolders);
|
||||
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(() => {
|
||||
const handleWs = (e: any) => {
|
||||
const msg = e.detail;
|
||||
@@ -634,30 +838,85 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
} 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<WorkspaceViewProps> = ({
|
||||
|
||||
|
||||
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<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
};
|
||||
});
|
||||
|
||||
// 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<WorkspaceViewProps> = ({
|
||||
|
||||
|
||||
{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">
|
||||
<Lock className="w-4 h-4 text-amber-500 flex-shrink-0" />
|
||||
<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">
|
||||
<Radio className="w-4 h-4 text-emerald-500 animate-pulse flex-shrink-0" />
|
||||
<div>
|
||||
<span>Diese Notiz wird aktuell von <strong>{activeLock?.userName || 'einem anderen Benutzer'}</strong> bearbeitet.</span>
|
||||
<p className="text-[11px] opacity-80 mt-0.5">Die Bearbeitung ist vorübergehend gesperrt, um Zeilen- und Datenverlust zu vermeiden.</p>
|
||||
<span><strong>{activeLock?.userName || 'Ein anderer Benutzer'}</strong> bearbeitet diese Notiz gerade ebenfalls.</span>
|
||||
<p className="text-[11px] opacity-80 mt-0.5">Live-Zusammenarbeit aktiv – Änderungen werden automatisch Punkt für Punkt synchronisiert.</p>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -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> {
|
||||
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
+56
-23
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+692
-218
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user