Dev #5
No files matched your search
@@ -50,7 +50,7 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { BlockType, Document, DocumentBlock, DocBlocksPatchEvent, 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, buildWebSocketURL } from '../../lib/api';
|
||||||
import { CodeBlockItem } from './CodeBlockItem';
|
import { CodeBlockItem } from './CodeBlockItem';
|
||||||
import { UserAutocomplete } from '../UserAutocomplete';
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
@@ -129,20 +129,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||||
const [showWikiLinkMenu, setShowWikiLinkMenu] = useState(false);
|
const [showWikiLinkMenu, setShowWikiLinkMenu] = useState(false);
|
||||||
|
|
||||||
// WebSocket connection status for live indicator
|
// Dedicated WebSocket connection & status for live indicator
|
||||||
const [wsConnected, setWsConnected] = useState<boolean>(() => isWebSocketConnected());
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const wsReconnectTimerRef = useRef<any>(null);
|
||||||
useEffect(() => {
|
const [wsConnected, setWsConnected] = useState<boolean>(false);
|
||||||
const unsub = onWebSocketStatus((connected) => {
|
|
||||||
setWsConnected(connected);
|
|
||||||
if (connected) {
|
|
||||||
// Fresh re-fetch when reconnecting
|
|
||||||
api.getFolders().then(setCustomFolders).catch(() => {});
|
|
||||||
api.getSharedFolders().then(setSharedFolders).catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return unsub;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Folder state (synchronized with server)
|
// Folder state (synchronized with server)
|
||||||
const [customFolders, setCustomFolders] = useState<string[]>([]);
|
const [customFolders, setCustomFolders] = useState<string[]>([]);
|
||||||
@@ -237,7 +227,7 @@ 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';
|
const isReadOnly = selectedDoc?.permission === 'read' || isLockedByOther;
|
||||||
|
|
||||||
// Track server-synced blocks & metadata per document, plus local unsaved block deltas
|
// 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 syncedDocMetaRef = useRef<Map<string, { title: string; icon: string; tagsJson: string; isPinned: boolean }>>(new Map());
|
||||||
@@ -820,10 +810,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Real-time WebSocket listener for Document, Block Delta & Folder events
|
// Dedicated Real-time WebSocket connection for Document, Block Delta, Lock & Folder events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleWs = (e: any) => {
|
let isMounted = true;
|
||||||
const msg = e.detail;
|
|
||||||
|
const handleWsMsg = (msg: any) => {
|
||||||
if (!msg || !msg.event) return;
|
if (!msg || !msg.event) return;
|
||||||
|
|
||||||
if (msg.event === 'DOC_LOCKED') {
|
if (msg.event === 'DOC_LOCKED') {
|
||||||
@@ -957,8 +948,80 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('mindsift:ws', handleWs);
|
const connect = () => {
|
||||||
return () => window.removeEventListener('mindsift:ws', handleWs);
|
if (!isMounted) return;
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
setWsConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsRef.current) {
|
||||||
|
try {
|
||||||
|
wsRef.current.close();
|
||||||
|
} catch {}
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = buildWebSocketURL();
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setWsConnected(true);
|
||||||
|
api.getFolders().then(setCustomFolders).catch(() => {});
|
||||||
|
api.getSharedFolders().then(setSharedFolders).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setWsConnected(false);
|
||||||
|
clearTimeout(wsReconnectTimerRef.current);
|
||||||
|
wsReconnectTimerRef.current = setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setWsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (evt) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(evt.data);
|
||||||
|
handleWsMsg(msg);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setWsConnected(false);
|
||||||
|
clearTimeout(wsReconnectTimerRef.current);
|
||||||
|
wsReconnectTimerRef.current = setTimeout(connect, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// Fallback: also listen for window custom event 'mindsift:ws'
|
||||||
|
const handleGlobalCustomEvent = (e: any) => {
|
||||||
|
if (e.detail) {
|
||||||
|
handleWsMsg(e.detail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('mindsift:ws', handleGlobalCustomEvent);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
clearTimeout(wsReconnectTimerRef.current);
|
||||||
|
if (wsRef.current) {
|
||||||
|
try {
|
||||||
|
wsRef.current.close();
|
||||||
|
} catch {}
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
window.removeEventListener('mindsift:ws', handleGlobalCustomEvent);
|
||||||
|
};
|
||||||
}, [onStatsChange]);
|
}, [onStatsChange]);
|
||||||
|
|
||||||
const openShareModal = async () => {
|
const openShareModal = async () => {
|
||||||
@@ -1278,91 +1341,24 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name of the folder being moved
|
|
||||||
const folderName = cleanSource.split('/').pop() || cleanSource;
|
|
||||||
const newFolderPath = cleanTarget ? `${cleanTarget}/${folderName}` : folderName;
|
|
||||||
|
|
||||||
if (newFolderPath === cleanSource) return;
|
|
||||||
|
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
|
|
||||||
// Check if sourcePath is the root mount of a shared folder
|
|
||||||
const matchingSF = sharedFolders.find((sf) => sf.mountPath === cleanSource);
|
|
||||||
if (matchingSF) {
|
|
||||||
try {
|
try {
|
||||||
await api.updateFolderMount(matchingSF.id, newFolderPath);
|
const res = await api.moveFolder(cleanSource, cleanTarget);
|
||||||
const sfList = await api.getSharedFolders();
|
await Promise.all([
|
||||||
setSharedFolders(sfList);
|
api.getFolders().then(setCustomFolders),
|
||||||
await loadDocs(selectedDoc?.id);
|
api.getSharedFolders().then(setSharedFolders),
|
||||||
|
loadDocs(selectedDoc?.id),
|
||||||
|
]);
|
||||||
setExpandedFolders((prev) => {
|
setExpandedFolders((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.add(newFolderPath);
|
if (res.newPath) next.add(res.newPath);
|
||||||
if (cleanTarget) next.add(cleanTarget);
|
if (cleanTarget) next.add(cleanTarget);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to move shared folder mount', err);
|
console.error('Failed to move folder', err);
|
||||||
alert(err.message || 'Fehler beim Verschieben des geteilten Ordners');
|
alert(err.message || 'Fehler beim Verschieben des Ordners');
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Update all documents that are in cleanSource or any subfolder of cleanSource
|
|
||||||
const docsToUpdate = docs.filter((d) => {
|
|
||||||
const f = getDocFolder(d);
|
|
||||||
return f === cleanSource || f.startsWith(cleanSource + '/');
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedDocsList = docs.map((d) => {
|
|
||||||
const f = getDocFolder(d);
|
|
||||||
if (f === cleanSource) {
|
|
||||||
return { ...d, folderPath: newFolderPath };
|
|
||||||
} else if (f.startsWith(cleanSource + '/')) {
|
|
||||||
const suffix = f.slice(cleanSource.length);
|
|
||||||
return { ...d, folderPath: `${newFolderPath}${suffix}` };
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
});
|
|
||||||
|
|
||||||
setDocs(updatedDocsList);
|
|
||||||
if (selectedDoc) {
|
|
||||||
const found = updatedDocsList.find((d) => d.id === selectedDoc.id);
|
|
||||||
if (found) setSelectedDoc(found);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand the new folder path and its parent
|
|
||||||
setExpandedFolders((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.add(newFolderPath);
|
|
||||||
if (cleanTarget) next.add(cleanTarget);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDropDocToFolder = async (folderPath: string, e: React.DragEvent) => {
|
const handleDropDocToFolder = async (folderPath: string, e: React.DragEvent) => {
|
||||||
@@ -4216,16 +4212,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
|
|
||||||
{isLockedByOther && (
|
{isLockedByOther && (
|
||||||
<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">
|
<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">
|
||||||
<Radio className="w-4 h-4 text-emerald-500 animate-pulse flex-shrink-0" />
|
<Lock className="w-4 h-4 text-amber-500 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<span><strong>{activeLock?.userName || 'Ein anderer Benutzer'}</strong> bearbeitet diese Notiz gerade ebenfalls.</span>
|
<span>Diese Notiz wird aktuell von <strong>{activeLock?.userName || 'einem anderen Benutzer'}</strong> bearbeitet.</span>
|
||||||
<p className="text-[11px] opacity-80 mt-0.5">Live-Zusammenarbeit aktiv – Änderungen werden automatisch Punkt für Punkt synchronisiert.</p>
|
<p className="text-[11px] opacity-80 mt-0.5">Die Bearbeitung ist vorübergehend gesperrt, um Zeilen- und Datenverlust zu vermeiden.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedDoc.permission === 'read' && (
|
{!isLockedByOther && 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>
|
||||||
|
|||||||
@@ -434,6 +434,13 @@ export const api = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async moveFolder(sourcePath: string, targetParentPath: string): Promise<{ newPath: string; movedDocIds: string[] }> {
|
||||||
|
return request<{ newPath: string; movedDocIds: string[] }>('/api/v1/folders/move', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ sourcePath, targetParentPath }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// --- WORKSPACE SHARED FOLDERS ---
|
// --- WORKSPACE SHARED FOLDERS ---
|
||||||
async getSharedFolders(): Promise<SharedFolder[]> {
|
async getSharedFolders(): Promise<SharedFolder[]> {
|
||||||
const res = await request<SharedFolder[]>('/api/v1/workspace/folders/shared');
|
const res = await request<SharedFolder[]>('/api/v1/workspace/folders/shared');
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
// Workspace Documents & Folders
|
// Workspace Documents & Folders
|
||||||
r.Get("/api/v1/folders", s.handleGetFolders)
|
r.Get("/api/v1/folders", s.handleGetFolders)
|
||||||
r.Post("/api/v1/folders", s.handleCreateFolder)
|
r.Post("/api/v1/folders", s.handleCreateFolder)
|
||||||
|
r.Post("/api/v1/folders/move", s.handleMoveFolder)
|
||||||
r.Delete("/api/v1/folders", s.handleDeleteFolder)
|
r.Delete("/api/v1/folders", s.handleDeleteFolder)
|
||||||
|
|
||||||
r.Get("/api/v1/workspace/folders/shared", s.handleGetSharedFolders)
|
r.Get("/api/v1/workspace/folders/shared", s.handleGetSharedFolders)
|
||||||
@@ -816,6 +817,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")
|
s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED")
|
||||||
writeJSON(w, http.StatusCreated, doc)
|
writeJSON(w, http.StatusCreated, doc)
|
||||||
}
|
}
|
||||||
@@ -835,6 +837,16 @@ 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"`
|
||||||
@@ -854,6 +866,7 @@ 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")
|
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, doc)
|
writeJSON(w, http.StatusOK, doc)
|
||||||
@@ -863,6 +876,16 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
|
|||||||
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 domain.DocBlocksPatchRequest
|
var req domain.DocBlocksPatchRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
@@ -883,6 +906,7 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
|
|||||||
DeletedIDs: req.DeletedIDs,
|
DeletedIDs: req.DeletedIDs,
|
||||||
BlockOrder: mergedOrder,
|
BlockOrder: mergedOrder,
|
||||||
}
|
}
|
||||||
|
s.wsHub.BroadcastDocEvent(docID, "DOC_BLOCKS_PATCHED", patchEvent)
|
||||||
if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil {
|
if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil {
|
||||||
for _, uid := range users {
|
for _, uid := range users {
|
||||||
s.wsHub.BroadcastToUser(uid, "DOC_BLOCKS_PATCHED", patchEvent)
|
s.wsHub.BroadcastToUser(uid, "DOC_BLOCKS_PATCHED", patchEvent)
|
||||||
@@ -959,6 +983,37 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMoveFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
var req struct {
|
||||||
|
SourcePath string `json:"sourcePath"`
|
||||||
|
TargetParentPath string `json:"targetParentPath"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newPath, movedDocIDs, err := s.store.MoveFolder(userID, req.SourcePath, req.TargetParentPath)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range movedDocIDs {
|
||||||
|
if doc, err := s.store.GetDocumentByID(userID, id); err == nil && doc != nil {
|
||||||
|
s.wsHub.BroadcastDocEvent(id, "DOC_UPDATED", doc)
|
||||||
|
s.broadcastDocToAuthorizedUsers(id, "DOC_UPDATED")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{
|
||||||
|
"sourcePath": req.SourcePath,
|
||||||
|
"newPath": newPath,
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"newPath": newPath,
|
||||||
|
"movedDocIds": movedDocIDs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// --- WORKSPACE SHARED FOLDERS ---
|
// --- WORKSPACE SHARED FOLDERS ---
|
||||||
|
|
||||||
func (s *Server) handleGetSharedFolders(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetSharedFolders(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1107,6 +1162,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.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)
|
||||||
|
|||||||
@@ -2807,6 +2807,138 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do
|
|||||||
return doc, nil
|
return doc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveFolder moves a folder (and all its documents/subfolders) to a new target parent path.
|
||||||
|
func (s *Store) MoveFolder(userID, sourcePath, targetParentPath string) (string, []string, error) {
|
||||||
|
cleanSource := filepath.ToSlash(filepath.Clean(strings.TrimSpace(sourcePath)))
|
||||||
|
if cleanSource == "" || cleanSource == "." || strings.HasPrefix(cleanSource, "/") || strings.Contains(cleanSource, "..") {
|
||||||
|
return "", nil, fmt.Errorf("ungültiger Quell-Ordnerpfad")
|
||||||
|
}
|
||||||
|
cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetParentPath)))
|
||||||
|
if cleanTarget == "." {
|
||||||
|
cleanTarget = ""
|
||||||
|
}
|
||||||
|
if cleanTarget != "" && (cleanTarget == cleanSource || strings.HasPrefix(cleanTarget, cleanSource+"/")) {
|
||||||
|
return "", nil, fmt.Errorf("ein Ordner kann nicht in sich selbst verschoben werden")
|
||||||
|
}
|
||||||
|
|
||||||
|
folderName := filepath.Base(cleanSource)
|
||||||
|
var newFolderPath string
|
||||||
|
if cleanTarget == "" {
|
||||||
|
newFolderPath = folderName
|
||||||
|
} else {
|
||||||
|
newFolderPath = cleanTarget + "/" + folderName
|
||||||
|
}
|
||||||
|
|
||||||
|
if newFolderPath == cleanSource {
|
||||||
|
return cleanSource, []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check if sourcePath is the root mount of a shared folder
|
||||||
|
mounts, _, err := s.getUserFolderMounts(userID)
|
||||||
|
if err == nil {
|
||||||
|
for sfID, mPath := range mounts {
|
||||||
|
if mPath == cleanSource {
|
||||||
|
err := s.UpdateFolderMount(userID, sfID, newFolderPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return newFolderPath, []string{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Move all affected documents
|
||||||
|
docs, err := s.GetDocuments(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var movedDocIDs []string
|
||||||
|
for _, doc := range docs {
|
||||||
|
if doc.FolderPath == cleanSource || strings.HasPrefix(doc.FolderPath, cleanSource+"/") {
|
||||||
|
var nextDocFolder string
|
||||||
|
if doc.FolderPath == cleanSource {
|
||||||
|
nextDocFolder = newFolderPath
|
||||||
|
} else {
|
||||||
|
suffix := strings.TrimPrefix(doc.FolderPath, cleanSource)
|
||||||
|
nextDocFolder = newFolderPath + suffix
|
||||||
|
}
|
||||||
|
_, moveErr := s.MoveDocument(userID, doc.ID, nextDocFolder)
|
||||||
|
if moveErr == nil {
|
||||||
|
movedDocIDs = append(movedDocIDs, doc.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Re-map workspace_folders entries
|
||||||
|
targetSFID, targetSub, isTargetSF := matchMountPath(newFolderPath, mounts)
|
||||||
|
sourceSFID, sourceSub, isSourceSF := matchMountPath(cleanSource, mounts)
|
||||||
|
|
||||||
|
wfRows, qErr := s.db.Query(`SELECT id, user_id, shared_folder_id, path, sub_path FROM workspace_folders`)
|
||||||
|
if qErr == nil {
|
||||||
|
type wfRecord struct {
|
||||||
|
id, uid, sfID, path, subPath string
|
||||||
|
}
|
||||||
|
var records []wfRecord
|
||||||
|
for wfRows.Next() {
|
||||||
|
var r wfRecord
|
||||||
|
if err := wfRows.Scan(&r.id, &r.uid, &r.sfID, &r.path, &r.subPath); err == nil {
|
||||||
|
records = append(records, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = wfRows.Close()
|
||||||
|
|
||||||
|
for _, r := range records {
|
||||||
|
if isSourceSF && r.sfID == sourceSFID {
|
||||||
|
matches := false
|
||||||
|
var suffix string
|
||||||
|
if sourceSub == "" {
|
||||||
|
matches = true
|
||||||
|
suffix = "/" + r.subPath
|
||||||
|
} else if r.subPath == sourceSub {
|
||||||
|
matches = true
|
||||||
|
suffix = ""
|
||||||
|
} else if strings.HasPrefix(r.subPath, sourceSub+"/") {
|
||||||
|
matches = true
|
||||||
|
suffix = strings.TrimPrefix(r.subPath, sourceSub)
|
||||||
|
}
|
||||||
|
if matches {
|
||||||
|
var newSub string
|
||||||
|
if targetSub == "" {
|
||||||
|
newSub = strings.TrimPrefix(folderName+suffix, "/")
|
||||||
|
} else {
|
||||||
|
newSub = targetSub + "/" + folderName + suffix
|
||||||
|
}
|
||||||
|
if isTargetSF {
|
||||||
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, targetSFID, newSub, r.id)
|
||||||
|
} else {
|
||||||
|
newP := newFolderPath + suffix
|
||||||
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = '', sub_path = '', path = ? WHERE id = ?`, newP, r.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if r.path == cleanSource || strings.HasPrefix(r.path, cleanSource+"/") {
|
||||||
|
suffix := strings.TrimPrefix(r.path, cleanSource)
|
||||||
|
newP := newFolderPath + suffix
|
||||||
|
if isTargetSF {
|
||||||
|
var newSub string
|
||||||
|
if targetSub == "" {
|
||||||
|
newSub = strings.TrimPrefix(folderName+suffix, "/")
|
||||||
|
} else {
|
||||||
|
newSub = targetSub + "/" + folderName + suffix
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ?, path = ? WHERE id = ?`, targetSFID, newSub, newP, r.id)
|
||||||
|
} else {
|
||||||
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET path = ? WHERE id = ?`, newP, r.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = s.CreateFolder(userID, newFolderPath)
|
||||||
|
|
||||||
|
return newFolderPath, movedDocIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
// syncSharedFolderContents ensures all documents and subfolders under cleanMount owned by ownerID are linked to folderID.
|
// syncSharedFolderContents ensures all documents and subfolders under cleanMount owned by ownerID are linked to folderID.
|
||||||
func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) {
|
func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) {
|
||||||
if ownerID == "" || folderID == "" || cleanMount == "" {
|
if ownerID == "" || folderID == "" || cleanMount == "" {
|
||||||
|
|||||||
@@ -665,5 +665,29 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
|||||||
if !foundSubfolderForB {
|
if !foundSubfolderForB {
|
||||||
t.Fatalf("expected User B to see empty shared subfolder 'ExterneProjekte/TeamProjekt/Konzepte', got folders: %v", foldersB)
|
t.Fatalf("expected User B to see empty shared subfolder 'ExterneProjekte/TeamProjekt/Konzepte', got folders: %v", foldersB)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. MoveFolder inside a shared folder
|
||||||
|
newPath, _, err := store.MoveFolder(userA.ID, "TeamProjekt/Konzepte", "TeamProjekt/Archiv")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MoveFolder failed: %v", err)
|
||||||
|
}
|
||||||
|
if newPath != "TeamProjekt/Archiv/Konzepte" {
|
||||||
|
t.Fatalf("expected newPath 'TeamProjekt/Archiv/Konzepte', got %q", newPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User B must now see "ExterneProjekte/TeamProjekt/Archiv/Konzepte"
|
||||||
|
foldersBAfterMove, err := store.GetFolders(userB.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFolders after MoveFolder failed: %v", err)
|
||||||
|
}
|
||||||
|
foundMovedForB := false
|
||||||
|
for _, f := range foldersBAfterMove {
|
||||||
|
if f == "ExterneProjekte/TeamProjekt/Archiv/Konzepte" {
|
||||||
|
foundMovedForB = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundMovedForB {
|
||||||
|
t.Fatalf("expected User B to see moved shared subfolder 'ExterneProjekte/TeamProjekt/Archiv/Konzepte', got %v", foldersBAfterMove)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user