feat(workspace): live synchronisation, verbindungsindikator, leere ordner persistenz und sperrkonfliktschutz
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
This commit is contained in:
1 parent
35fd682fcd
commit
2f92e1f49a
4 files changed
+234
-14
No files matched your search
@@ -47,9 +47,10 @@ import {
|
|||||||
Edit3,
|
Edit3,
|
||||||
Hash,
|
Hash,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
|
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, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types';
|
||||||
import { api, getToken, triggerHaptic } 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';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
@@ -128,6 +129,21 @@ 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
|
||||||
|
const [wsConnected, setWsConnected] = useState<boolean>(() => isWebSocketConnected());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -266,6 +282,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -295,7 +312,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
clearTimeout(idleUnlockTimerRef.current);
|
clearTimeout(idleUnlockTimerRef.current);
|
||||||
idleUnlockTimerRef.current = null;
|
idleUnlockTimerRef.current = null;
|
||||||
}
|
}
|
||||||
if (!isReadOnly) {
|
if (!isReadOnly && !isLockedByOther) {
|
||||||
ensureLock();
|
ensureLock();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -309,14 +326,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
if (isDraggingBlockRef.current) {
|
if (isDraggingBlockRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Short grace period: if user clicked into another block in the doc, focus will cancel this.
|
// Generous grace period: if user clicked into another block in the doc or paused typing
|
||||||
// If user clicked outside or stopped editing, save and release the lock immediately!
|
|
||||||
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();
|
||||||
await releaseMyLock();
|
await releaseMyLock();
|
||||||
}
|
}
|
||||||
}, 1500);
|
}, 8000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD)
|
// Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD)
|
||||||
@@ -325,6 +341,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
const executeSaveToBackend = async (docToSave: Document) => {
|
const executeSaveToBackend = async (docToSave: Document) => {
|
||||||
if (isReadOnly) return;
|
if (isReadOnly) return;
|
||||||
|
let saveSucceeded = false;
|
||||||
try {
|
try {
|
||||||
const saved = await api.updateDocument(docToSave.id, {
|
const saved = await api.updateDocument(docToSave.id, {
|
||||||
title: docToSave.title,
|
title: docToSave.title,
|
||||||
@@ -334,6 +351,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
tags: docToSave.tags,
|
tags: docToSave.tags,
|
||||||
isPinned: docToSave.isPinned,
|
isPinned: docToSave.isPinned,
|
||||||
});
|
});
|
||||||
|
saveSucceeded = true;
|
||||||
setSelectedDoc((prev) =>
|
setSelectedDoc((prev) =>
|
||||||
prev && prev.id === saved.id
|
prev && prev.id === saved.id
|
||||||
? {
|
? {
|
||||||
@@ -352,15 +370,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {});
|
api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
console.error('Failed to save document to backend', e);
|
||||||
if (e.message && e.message.includes('423')) {
|
if (e.message && e.message.includes('423')) {
|
||||||
api.getDocLock(docToSave.id).then((res) => {
|
api.getDocLock(docToSave.id).then((res) => {
|
||||||
if (res.lock) setActiveLock(res.lock);
|
if (res.lock) setActiveLock(res.lock);
|
||||||
});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) {
|
if (saveSucceeded) {
|
||||||
pendingSaveDocRef.current = null;
|
if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) {
|
||||||
setSaveState('saved');
|
pendingSaveDocRef.current = null;
|
||||||
|
setSaveState('saved');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Keep pendingSaveDocRef so changes are preserved and not silently marked as saved!
|
||||||
|
setSaveState('saving');
|
||||||
}
|
}
|
||||||
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
||||||
if (idleUnlockTimerRef.current) {
|
if (idleUnlockTimerRef.current) {
|
||||||
@@ -370,7 +394,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
||||||
await releaseMyLock();
|
await releaseMyLock();
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 8000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -3677,6 +3701,52 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<div
|
||||||
|
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
|
||||||
|
wsConnected
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
||||||
|
: 'bg-canvas border-border text-muted'
|
||||||
|
}`}
|
||||||
|
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline - Verbindung wird wiederhergestellt'}
|
||||||
|
>
|
||||||
|
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Compact Header Bar (Live-Sync Status, Title, Drawer Switcher) */}
|
||||||
|
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMobileFileDrawer(true)}
|
||||||
|
className="flex items-center gap-1.5 text-sm font-semibold text-main truncate hover:text-accent transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<FolderTree className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<span className="truncate">{selectedDoc ? selectedDoc.title || 'Unbenannte Notiz' : 'Dateibaum & Ordner'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{selectedDoc && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openShareModal}
|
||||||
|
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
|
||||||
|
title="Notiz teilen"
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4 text-accent" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`h-9 w-9 flex items-center justify-center rounded-btn border transition-colors ${
|
||||||
|
wsConnected
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
||||||
|
: 'bg-canvas border-border text-muted'
|
||||||
|
}`}
|
||||||
|
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline - Verbindung wird wiederhergestellt'}
|
||||||
|
>
|
||||||
|
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3845,6 +3915,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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isLockedByOther && 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" />
|
||||||
|
|||||||
@@ -1019,6 +1019,8 @@ export function getOfflineQueue(): Thought[] {
|
|||||||
let activeGlobalWS: WebSocket | null = null;
|
let activeGlobalWS: WebSocket | null = null;
|
||||||
let wsReconnectTimer: any = null;
|
let wsReconnectTimer: any = null;
|
||||||
let wsListeners: ((msg: WSMessage) => void)[] = [];
|
let wsListeners: ((msg: WSMessage) => void)[] = [];
|
||||||
|
let wsStatusListeners: ((connected: boolean) => void)[] = [];
|
||||||
|
let isWSConnected = false;
|
||||||
let manualDisconnect = false;
|
let manualDisconnect = false;
|
||||||
|
|
||||||
export function buildWebSocketURL(listId?: string): string {
|
export function buildWebSocketURL(listId?: string): string {
|
||||||
@@ -1030,6 +1032,30 @@ export function buildWebSocketURL(listId?: string): string {
|
|||||||
return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
|
return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isWebSocketConnected(): boolean {
|
||||||
|
return isWSConnected && activeGlobalWS !== null && activeGlobalWS.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onWebSocketStatus(callback: (connected: boolean) => void): () => void {
|
||||||
|
wsStatusListeners.push(callback);
|
||||||
|
callback(isWebSocketConnected());
|
||||||
|
return () => {
|
||||||
|
wsStatusListeners = wsStatusListeners.filter((fn) => fn !== callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyStatusChange(connected: boolean) {
|
||||||
|
isWSConnected = connected;
|
||||||
|
wsStatusListeners.forEach((fn) => {
|
||||||
|
try {
|
||||||
|
fn(connected);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('WS status listener error', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected } }));
|
||||||
|
}
|
||||||
|
|
||||||
export function connectWebSocket(): void {
|
export function connectWebSocket(): void {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -1044,6 +1070,10 @@ export function connectWebSocket(): void {
|
|||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
activeGlobalWS = ws;
|
activeGlobalWS = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
notifyStatusChange(true);
|
||||||
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data) as WSMessage;
|
const msg = JSON.parse(event.data) as WSMessage;
|
||||||
@@ -1062,6 +1092,7 @@ export function connectWebSocket(): void {
|
|||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
activeGlobalWS = null;
|
activeGlobalWS = null;
|
||||||
|
notifyStatusChange(false);
|
||||||
if (!manualDisconnect && getToken()) {
|
if (!manualDisconnect && getToken()) {
|
||||||
clearTimeout(wsReconnectTimer);
|
clearTimeout(wsReconnectTimer);
|
||||||
wsReconnectTimer = setTimeout(() => {
|
wsReconnectTimer = setTimeout(() => {
|
||||||
@@ -1071,15 +1102,31 @@ export function connectWebSocket(): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
|
notifyStatusChange(false);
|
||||||
try {
|
try {
|
||||||
ws.close();
|
ws.close();
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to connect WebSocket', e);
|
console.error('Failed to connect WebSocket', e);
|
||||||
|
notifyStatusChange(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure reconnect when browser recovers from sleep or tab visibility
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
if (getToken() && !isWebSocketConnected()) {
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible' && getToken() && !isWebSocketConnected()) {
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function disconnectWebSocket(): void {
|
export function disconnectWebSocket(): void {
|
||||||
manualDisconnect = true;
|
manualDisconnect = true;
|
||||||
clearTimeout(wsReconnectTimer);
|
clearTimeout(wsReconnectTimer);
|
||||||
@@ -1089,6 +1136,7 @@ export function disconnectWebSocket(): void {
|
|||||||
} catch {}
|
} catch {}
|
||||||
activeGlobalWS = null;
|
activeGlobalWS = null;
|
||||||
}
|
}
|
||||||
|
notifyStatusChange(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendWebSocketMessage(msg: any): void {
|
export function sendWebSocketMessage(msg: any): void {
|
||||||
|
|||||||
@@ -1226,7 +1226,7 @@ func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
|
|||||||
userName = user.DisplayName
|
userName = user.DisplayName
|
||||||
}
|
}
|
||||||
|
|
||||||
info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 12*time.Second)
|
info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 20*time.Second)
|
||||||
if !acquired {
|
if !acquired {
|
||||||
w.WriteHeader(http.StatusLocked)
|
w.WriteHeader(http.StatusLocked)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
@@ -1249,7 +1249,7 @@ func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHeartbeatDocLock(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")
|
||||||
ok := s.wsHub.HeartbeatDocLock(docID, userID, 12*time.Second)
|
ok := s.wsHub.HeartbeatDocLock(docID, userID, 20*time.Second)
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"extended": ok})
|
writeJSON(w, http.StatusOK, map[string]bool{"extended": ok})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -362,6 +362,10 @@ func (s *Store) migrate() error {
|
|||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS doc_user_folders (user_id TEXT NOT NULL, doc_id TEXT NOT NULL, folder_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, doc_id));",
|
"CREATE TABLE IF NOT EXISTS doc_user_folders (user_id TEXT NOT NULL, doc_id TEXT NOT NULL, folder_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, doc_id));",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_doc_user_folders_user ON doc_user_folders(user_id);",
|
"CREATE INDEX IF NOT EXISTS idx_doc_user_folders_user ON doc_user_folders(user_id);",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS workspace_folders (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, shared_folder_id TEXT NOT NULL DEFAULT '', path TEXT NOT NULL, sub_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL);",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_workspace_folders_user ON workspace_folders(user_id);",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_workspace_folders_sf ON workspace_folders(shared_folder_id);",
|
||||||
}
|
}
|
||||||
for _, q := range columnMigrations {
|
for _, q := range columnMigrations {
|
||||||
_, _ = s.db.Exec(q)
|
_, _ = s.db.Exec(q)
|
||||||
@@ -2101,14 +2105,39 @@ func (s *Store) GetFolders(userID string) ([]string, error) {
|
|||||||
// 2. Add mounted shared folders (so even empty shared folders appear in the tree)
|
// 2. Add mounted shared folders (so even empty shared folders appear in the tree)
|
||||||
mounts, _, err := s.getUserFolderMounts(userID)
|
mounts, _, err := s.getUserFolderMounts(userID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, mPath := range mounts {
|
for sfID, mPath := range mounts {
|
||||||
if mPath != "" {
|
if mPath != "" {
|
||||||
addFolderSegments(mPath, folderSet)
|
addFolderSegments(mPath, folderSet)
|
||||||
|
|
||||||
|
// 2b. Add all subfolders created in this shared folder
|
||||||
|
sRows, err := s.db.Query(`SELECT sub_path FROM workspace_folders WHERE shared_folder_id = ?`, sfID)
|
||||||
|
if err == nil {
|
||||||
|
for sRows.Next() {
|
||||||
|
var sp string
|
||||||
|
if err := sRows.Scan(&sp); err == nil && sp != "" {
|
||||||
|
fullP := mPath + "/" + sp
|
||||||
|
addFolderSegments(fullP, folderSet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = sRows.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Scan physical directories on disk in docDir/userID
|
// 3. Add personal empty folders created by this user
|
||||||
|
pRows, err := s.db.Query(`SELECT path FROM workspace_folders WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID)
|
||||||
|
if err == nil {
|
||||||
|
for pRows.Next() {
|
||||||
|
var p string
|
||||||
|
if err := pRows.Scan(&p); err == nil && p != "" {
|
||||||
|
addFolderSegments(p, folderSet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = pRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Scan physical directories on disk in docDir/userID
|
||||||
if s.docDir != "" {
|
if s.docDir != "" {
|
||||||
userDir := filepath.Join(s.docDir, userID)
|
userDir := filepath.Join(s.docDir, userID)
|
||||||
if fi, err := os.Stat(userDir); err == nil && fi.IsDir() {
|
if fi, err := os.Stat(userDir); err == nil && fi.IsDir() {
|
||||||
@@ -2158,6 +2187,31 @@ func (s *Store) CreateFolder(userID, folderPath string) (string, error) {
|
|||||||
return "", fmt.Errorf("ungültiger Ordnerpfad")
|
return "", fmt.Errorf("ungültiger Ordnerpfad")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mounts, _, err := s.getUserFolderMounts(userID)
|
||||||
|
var sfID, subPath string
|
||||||
|
if err == nil {
|
||||||
|
sfID, subPath, _ = matchMountPath(clean, mounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
id := domain.NewID("wfld")
|
||||||
|
|
||||||
|
if sfID != "" {
|
||||||
|
var existsID string
|
||||||
|
err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE shared_folder_id = ? AND sub_path = ?`, sfID, subPath).Scan(&existsID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
_, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, userID, sfID, clean, subPath, now)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var existsID string
|
||||||
|
err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID, clean).Scan(&existsID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
_, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`,
|
||||||
|
id, userID, clean, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if s.docDir != "" {
|
if s.docDir != "" {
|
||||||
targetDir := filepath.Join(s.docDir, userID, clean)
|
targetDir := filepath.Join(s.docDir, userID, clean)
|
||||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||||
@@ -2201,6 +2255,21 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
|
|||||||
_, _ = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, id, userID)
|
_, _ = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, id, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up from workspace_folders
|
||||||
|
mounts, _, err := s.getUserFolderMounts(userID)
|
||||||
|
if err == nil {
|
||||||
|
sfID, subPath, isMatch := matchMountPath(clean, mounts)
|
||||||
|
if isMatch && sfID != "" {
|
||||||
|
if subPath == "" {
|
||||||
|
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, sfID)
|
||||||
|
} else {
|
||||||
|
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ? AND (sub_path = ? OR sub_path LIKE ?)`,
|
||||||
|
sfID, subPath, subPath+"/%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%")
|
||||||
|
|
||||||
// Remove physical folder and subdirectories from disk
|
// Remove physical folder and subdirectories from disk
|
||||||
if s.docDir != "" {
|
if s.docDir != "" {
|
||||||
targetDir := filepath.Join(s.docDir, userID, clean)
|
targetDir := filepath.Join(s.docDir, userID, clean)
|
||||||
@@ -2367,6 +2436,28 @@ func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrate any existing workspace_folders created by this user in cleanMount
|
||||||
|
fRows, err := s.db.Query(`SELECT id, path FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, cleanMount, cleanMount+"/%")
|
||||||
|
if err == nil {
|
||||||
|
type wfEntry struct{ id, path string }
|
||||||
|
var wfEntries []wfEntry
|
||||||
|
for fRows.Next() {
|
||||||
|
var wfe wfEntry
|
||||||
|
if err := fRows.Scan(&wfe.id, &wfe.path); err == nil {
|
||||||
|
wfEntries = append(wfEntries, wfe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = fRows.Close()
|
||||||
|
|
||||||
|
for _, wfe := range wfEntries {
|
||||||
|
subP := ""
|
||||||
|
if strings.HasPrefix(wfe.path, cleanMount+"/") {
|
||||||
|
subP = strings.TrimPrefix(wfe.path, cleanMount+"/")
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, folderID, subP, wfe.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var ownerName string
|
var ownerName string
|
||||||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName)
|
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName)
|
||||||
|
|
||||||
@@ -2646,6 +2737,7 @@ func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error {
|
|||||||
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id = ?`, folderID)
|
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id = ?`, folderID)
|
||||||
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ?`, folderID)
|
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ?`, folderID)
|
||||||
_, _ = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ?`, folderID)
|
_, _ = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ?`, folderID)
|
||||||
|
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, folderID)
|
||||||
_, err = s.db.Exec(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID)
|
_, err = s.db.Exec(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user