Dev #5
No files matched your search
@@ -48,7 +48,7 @@ import {
|
|||||||
Hash,
|
Hash,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, 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 } from '../../lib/api';
|
||||||
import { CodeBlockItem } from './CodeBlockItem';
|
import { CodeBlockItem } from './CodeBlockItem';
|
||||||
import { UserAutocomplete } from '../UserAutocomplete';
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
@@ -69,6 +69,9 @@ interface FolderTreeItem {
|
|||||||
depth: number;
|
depth: number;
|
||||||
subfolders: FolderTreeItem[];
|
subfolders: FolderTreeItem[];
|
||||||
docs: Document[];
|
docs: Document[];
|
||||||
|
sharedFolder?: SharedFolder;
|
||||||
|
isShared?: boolean;
|
||||||
|
isSharedRoot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SlashCommandItem {
|
interface SlashCommandItem {
|
||||||
@@ -199,6 +202,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [sharingLoading, setSharingLoading] = useState(false);
|
const [sharingLoading, setSharingLoading] = useState(false);
|
||||||
const [shareError, setShareError] = useState<string | null>(null);
|
const [shareError, setShareError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// --- FOLDER SHARING STATE ---
|
||||||
|
const [sharedFolders, setSharedFolders] = useState<SharedFolder[]>([]);
|
||||||
|
const [sharingFolder, setSharingFolder] = useState<SharedFolder | null>(null);
|
||||||
|
const [showFolderShareModal, setShowFolderShareModal] = useState(false);
|
||||||
|
const [folderShares, setFolderShares] = useState<FolderShare[]>([]);
|
||||||
|
|
||||||
// --- DOCUMENT LOCKING STATE ---
|
// --- DOCUMENT LOCKING STATE ---
|
||||||
const [activeLock, setActiveLock] = useState<DocLockInfo | null>(null);
|
const [activeLock, setActiveLock] = useState<DocLockInfo | null>(null);
|
||||||
const [holdsLock, setHoldsLock] = useState(false);
|
const [holdsLock, setHoldsLock] = useState(false);
|
||||||
@@ -411,14 +420,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
const loadDocs = async (selectId?: string | null) => {
|
const loadDocs = async (selectId?: string | null) => {
|
||||||
try {
|
try {
|
||||||
const [list, serverFolders, tags] = await Promise.all([
|
const [list, serverFolders, tags, sFolders] = await Promise.all([
|
||||||
api.getDocuments(),
|
api.getDocuments(),
|
||||||
api.getFolders(),
|
api.getFolders(),
|
||||||
api.getWorkspaceTags().catch(() => []),
|
api.getWorkspaceTags().catch(() => []),
|
||||||
|
api.getSharedFolders().catch(() => []),
|
||||||
]);
|
]);
|
||||||
setDocs(list);
|
setDocs(list);
|
||||||
setCustomFolders(serverFolders);
|
setCustomFolders(serverFolders);
|
||||||
setWorkspaceTags(tags);
|
setWorkspaceTags(tags);
|
||||||
|
setSharedFolders(sFolders);
|
||||||
if (selectId) {
|
if (selectId) {
|
||||||
const found = list.find((d) => d.id === selectId);
|
const found = list.find((d) => d.id === selectId);
|
||||||
if (found) {
|
if (found) {
|
||||||
@@ -637,6 +648,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
|
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
|
||||||
api.getFolders().then(setCustomFolders).catch(() => {});
|
api.getFolders().then(setCustomFolders).catch(() => {});
|
||||||
|
api.getSharedFolders().then(setSharedFolders).catch(() => {});
|
||||||
|
api.getDocuments().then(setDocs).catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -665,6 +678,30 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openFolderShareModal = async (node: FolderTreeItem) => {
|
||||||
|
try {
|
||||||
|
let sf = node.sharedFolder;
|
||||||
|
if (!sf) {
|
||||||
|
sf = await api.createSharedFolder({ name: node.name, mountPath: node.path });
|
||||||
|
const sfList = await api.getSharedFolders();
|
||||||
|
setSharedFolders(sfList);
|
||||||
|
}
|
||||||
|
const [shares, usersList, teamsList] = await Promise.all([
|
||||||
|
api.getFolderShares(sf.id).catch(() => []),
|
||||||
|
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
|
||||||
|
api.getUserTeams().catch(() => []),
|
||||||
|
]);
|
||||||
|
setFolderShares(shares);
|
||||||
|
setCollaborators(usersList);
|
||||||
|
setShareTeams(teamsList);
|
||||||
|
setSharingFolder(sf);
|
||||||
|
setShowFolderShareModal(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to open folder share modal', err);
|
||||||
|
alert('Fehler beim Öffnen der Ordnerfreigabe');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddShare = async (e: React.FormEvent) => {
|
const handleAddShare = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!selectedDoc) return;
|
if (!selectedDoc) return;
|
||||||
@@ -796,6 +833,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
// Compute all unique folder paths
|
// Compute all unique folder paths
|
||||||
const allFolderPaths = useMemo(() => {
|
const allFolderPaths = useMemo(() => {
|
||||||
const set = new Set<string>(customFolders);
|
const set = new Set<string>(customFolders);
|
||||||
|
sharedFolders.forEach((sf) => {
|
||||||
|
if (sf.mountPath) {
|
||||||
|
const parts = sf.mountPath.split('/');
|
||||||
|
let cur = '';
|
||||||
|
for (const p of parts) {
|
||||||
|
cur = cur ? `${cur}/${p}` : p;
|
||||||
|
set.add(cur);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
docs.forEach((doc) => {
|
docs.forEach((doc) => {
|
||||||
const f = getDocFolder(doc);
|
const f = getDocFolder(doc);
|
||||||
if (f) {
|
if (f) {
|
||||||
@@ -808,7 +855,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||||
}, [customFolders, docs]);
|
}, [customFolders, sharedFolders, docs]);
|
||||||
|
|
||||||
// Keep folders expanded by default
|
// Keep folders expanded by default
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -919,6 +966,27 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
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 {
|
||||||
|
await api.updateFolderMount(matchingSF.id, newFolderPath);
|
||||||
|
const sfList = await api.getSharedFolders();
|
||||||
|
setSharedFolders(sfList);
|
||||||
|
await loadDocs(selectedDoc?.id);
|
||||||
|
setExpandedFolders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(newFolderPath);
|
||||||
|
if (cleanTarget) next.add(cleanTarget);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to move shared folder mount', err);
|
||||||
|
alert(err.message || 'Fehler beim Verschieben des geteilten Ordners');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Update all documents that are in cleanSource or any subfolder of cleanSource
|
// 1. Update all documents that are in cleanSource or any subfolder of cleanSource
|
||||||
const docsToUpdate = docs.filter((d) => {
|
const docsToUpdate = docs.filter((d) => {
|
||||||
const f = getDocFolder(d);
|
const f = getDocFolder(d);
|
||||||
@@ -948,7 +1016,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
if (found) setSelectedDoc(found);
|
if (found) setSelectedDoc(found);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist all affected docs to backend
|
// Persist all affected docs to backend using api.moveDocument
|
||||||
for (const d of docsToUpdate) {
|
for (const d of docsToUpdate) {
|
||||||
const f = getDocFolder(d);
|
const f = getDocFolder(d);
|
||||||
let nextFolder = newFolderPath;
|
let nextFolder = newFolderPath;
|
||||||
@@ -956,15 +1024,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const suffix = f.slice(cleanSource.length);
|
const suffix = f.slice(cleanSource.length);
|
||||||
nextFolder = `${newFolderPath}${suffix}`;
|
nextFolder = `${newFolderPath}${suffix}`;
|
||||||
}
|
}
|
||||||
const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:'));
|
api.moveDocument(d.id, nextFolder).catch((err) => console.error('Failed to move doc', err));
|
||||||
if (nextFolder) updatedTags.push(`folder:${nextFolder}`);
|
|
||||||
|
|
||||||
api
|
|
||||||
.updateDocument(d.id, {
|
|
||||||
folderPath: nextFolder,
|
|
||||||
tags: updatedTags,
|
|
||||||
})
|
|
||||||
.catch((err) => console.error('Failed to update doc folder', err));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Sync folder structure with server
|
// 2. Sync folder structure with server
|
||||||
@@ -995,19 +1055,42 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
if (getDocFolder(doc) === cleanPath) return;
|
if (getDocFolder(doc) === cleanPath) return;
|
||||||
|
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
const updatedTags = (doc.tags || []).filter((t) => !t.startsWith('folder:'));
|
try {
|
||||||
if (cleanPath) {
|
const updatedDoc = await api.moveDocument(doc.id, cleanPath);
|
||||||
updatedTags.push(`folder:${cleanPath}`);
|
setDocs((prev) =>
|
||||||
|
prev.map((d) =>
|
||||||
|
d.id === doc.id
|
||||||
|
? {
|
||||||
|
...d,
|
||||||
|
folderPath: updatedDoc.folderPath,
|
||||||
|
sharedFolderId: updatedDoc.sharedFolderId,
|
||||||
|
subFolderPath: updatedDoc.subFolderPath,
|
||||||
|
isSharedFolder: updatedDoc.isSharedFolder,
|
||||||
|
}
|
||||||
|
: d
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (selectedDoc?.id === doc.id) {
|
||||||
|
setSelectedDoc((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
folderPath: updatedDoc.folderPath,
|
||||||
|
sharedFolderId: updatedDoc.sharedFolderId,
|
||||||
|
subFolderPath: updatedDoc.subFolderPath,
|
||||||
|
isSharedFolder: updatedDoc.isSharedFolder,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const updatedDoc: Document = {
|
|
||||||
...doc,
|
|
||||||
folderPath: cleanPath,
|
|
||||||
tags: updatedTags,
|
|
||||||
};
|
|
||||||
await persistDocument(updatedDoc, true);
|
|
||||||
if (cleanPath) {
|
if (cleanPath) {
|
||||||
setExpandedFolders((prev) => new Set([...prev, cleanPath]));
|
setExpandedFolders((prev) => new Set([...prev, cleanPath]));
|
||||||
}
|
}
|
||||||
|
api.getFolders().then(setCustomFolders).catch(() => {});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to move doc', err);
|
||||||
|
alert(err.message || 'Fehler beim Verschieben der Notiz');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartCreateFolder = (parentPath?: string | null) => {
|
const handleStartCreateFolder = (parentPath?: string | null) => {
|
||||||
@@ -1043,6 +1126,30 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteFolder = async (folderPath: string, folderName: string) => {
|
const handleDeleteFolder = async (folderPath: string, folderName: string) => {
|
||||||
|
const matchingSF = sharedFolders.find((sf) => sf.mountPath === folderPath);
|
||||||
|
if (matchingSF) {
|
||||||
|
if (matchingSF.isOwner) {
|
||||||
|
if (!window.confirm(`Möchtest du den geteilten Ordner "${folderName}" wirklich löschen? Die Freigabe wird für alle Teilnehmer aufgehoben.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!window.confirm(`Möchtest du den geteilten Ordner "${folderName}" aus deinem Workspace entfernen? (Die Quell-Dateien des Eigentümers bleiben erhalten).`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.deleteSharedFolder(matchingSF.id);
|
||||||
|
const sfList = await api.getSharedFolders();
|
||||||
|
setSharedFolders(sfList);
|
||||||
|
await loadDocs(selectedDoc?.id);
|
||||||
|
return;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to delete shared folder', err);
|
||||||
|
alert(err.message || 'Fehler beim Löschen des geteilten Ordners');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Find all documents in this folder and subfolders
|
// Find all documents in this folder and subfolders
|
||||||
const docsInFolder = docs.filter((d) => {
|
const docsInFolder = docs.filter((d) => {
|
||||||
const f = getDocFolder(d);
|
const f = getDocFolder(d);
|
||||||
@@ -2787,12 +2894,30 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
allFolderPaths.forEach((path) => {
|
allFolderPaths.forEach((path) => {
|
||||||
const parts = path.split('/');
|
const parts = path.split('/');
|
||||||
const name = parts[parts.length - 1];
|
const name = parts[parts.length - 1];
|
||||||
|
|
||||||
|
let matchingSF: SharedFolder | undefined;
|
||||||
|
let isRoot = false;
|
||||||
|
for (const sf of sharedFolders) {
|
||||||
|
if (sf.mountPath === path) {
|
||||||
|
matchingSF = sf;
|
||||||
|
isRoot = true;
|
||||||
|
break;
|
||||||
|
} else if (path.startsWith(sf.mountPath + '/')) {
|
||||||
|
matchingSF = sf;
|
||||||
|
isRoot = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
folderMap.set(path, {
|
folderMap.set(path, {
|
||||||
path,
|
path,
|
||||||
name,
|
name,
|
||||||
depth: parts.length - 1,
|
depth: parts.length - 1,
|
||||||
subfolders: [],
|
subfolders: [],
|
||||||
docs: [],
|
docs: [],
|
||||||
|
sharedFolder: matchingSF,
|
||||||
|
isShared: !!matchingSF,
|
||||||
|
isSharedRoot: isRoot,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2822,7 +2947,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
const rootOnlyDocs = filteredDocs.filter((d) => !getDocFolder(d));
|
const rootOnlyDocs = filteredDocs.filter((d) => !getDocFolder(d));
|
||||||
return { rootTree: rootFolders, rootDocs: rootOnlyDocs };
|
return { rootTree: rootFolders, rootDocs: rootOnlyDocs };
|
||||||
}, [allFolderPaths, filteredDocs]);
|
}, [allFolderPaths, filteredDocs, sharedFolders]);
|
||||||
|
|
||||||
const pinnedDocs = useMemo(() => {
|
const pinnedDocs = useMemo(() => {
|
||||||
return docs.filter((d) => d.isPinned);
|
return docs.filter((d) => d.isPinned);
|
||||||
@@ -3091,10 +3216,29 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<Folder className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-muted flex-shrink-0" />
|
<Folder className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-muted flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="font-medium text-sm sm:text-xs text-main truncate">{node.name}</span>
|
<span className="font-medium text-sm sm:text-xs text-main truncate">{node.name}</span>
|
||||||
|
{node.isShared && (
|
||||||
|
<span
|
||||||
|
title={node.sharedFolder?.isOwner ? 'Geteilter Ordner (Eigentümer)' : 'Geteilter Ordner'}
|
||||||
|
className="text-accent flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-[10px] text-faint font-mono">({totalItems})</span>
|
<span className="text-[10px] text-faint font-mono">({totalItems})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 flex items-center gap-1 transition-opacity">
|
<div className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 flex items-center gap-1 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openFolderShareModal(node);
|
||||||
|
}}
|
||||||
|
className="p-1.5 sm:p-1 hover:bg-panel-elevated text-muted hover:text-accent rounded-btn"
|
||||||
|
title="Ordner freigeben / teilen"
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -4926,6 +5070,57 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* UNIFIED FOLDER SHARING MODAL */}
|
||||||
|
{showFolderShareModal && sharingFolder && (
|
||||||
|
<UnifiedShareModal
|
||||||
|
isOpen={showFolderShareModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowFolderShareModal(false);
|
||||||
|
setSharingFolder(null);
|
||||||
|
}}
|
||||||
|
title={`Ordner teilen: ${sharingFolder.name}`}
|
||||||
|
resourceName={sharingFolder.name}
|
||||||
|
resourceIcon="📁"
|
||||||
|
isOwner={sharingFolder.isOwner}
|
||||||
|
currentUserId={currentUser?.id}
|
||||||
|
shares={(folderShares || []).map((sh) => ({
|
||||||
|
id: sh.id,
|
||||||
|
targetType: sh.targetType,
|
||||||
|
targetId: sh.targetId,
|
||||||
|
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
|
||||||
|
targetEmail: sh.email,
|
||||||
|
avatarUrl: sh.avatarUrl,
|
||||||
|
permission: sh.permission === 'read' ? ('viewer' as const) : ('editor' as const),
|
||||||
|
role: sh.permission === 'read' ? 'viewer' : 'editor',
|
||||||
|
createdAt: sh.createdAt,
|
||||||
|
}))}
|
||||||
|
teams={shareTeams}
|
||||||
|
collaborators={collaborators}
|
||||||
|
allowRoleSelection={true}
|
||||||
|
onAddShare={async (targetType, targetId, permission) => {
|
||||||
|
const perm = permission === 'viewer' ? 'read' : 'write';
|
||||||
|
await api.createFolderShare(sharingFolder.id, {
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
permission: perm,
|
||||||
|
});
|
||||||
|
const updated = await api.getFolderShares(sharingFolder.id);
|
||||||
|
setFolderShares(updated);
|
||||||
|
const sfList = await api.getSharedFolders();
|
||||||
|
setSharedFolders(sfList);
|
||||||
|
await loadDocs(selectedDoc?.id);
|
||||||
|
}}
|
||||||
|
onRemoveShare={async (shareId) => {
|
||||||
|
await api.deleteFolderShare(sharingFolder.id, shareId);
|
||||||
|
const updated = await api.getFolderShares(sharingFolder.id);
|
||||||
|
setFolderShares(updated);
|
||||||
|
const sfList = await api.getSharedFolders();
|
||||||
|
setSharedFolders(sfList);
|
||||||
|
await loadDocs(selectedDoc?.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* LINK INSERTION MODAL (/link, /notiz, /web) */}
|
{/* LINK INSERTION MODAL (/link, /notiz, /web) */}
|
||||||
{linkModalBlockId && (
|
{linkModalBlockId && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
Organization,
|
Organization,
|
||||||
PriorityLevel,
|
PriorityLevel,
|
||||||
SharedList,
|
SharedList,
|
||||||
|
SharedFolder,
|
||||||
|
FolderShare,
|
||||||
Space,
|
Space,
|
||||||
SpaceMember,
|
SpaceMember,
|
||||||
SpaceShare,
|
SpaceShare,
|
||||||
@@ -432,6 +434,53 @@ export const api = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- WORKSPACE SHARED FOLDERS ---
|
||||||
|
async getSharedFolders(): Promise<SharedFolder[]> {
|
||||||
|
const res = await request<SharedFolder[]>('/api/v1/workspace/folders/shared');
|
||||||
|
return ensureArray(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createSharedFolder(payload: { name: string; mountPath?: string }): Promise<SharedFolder> {
|
||||||
|
return request<SharedFolder>('/api/v1/workspace/folders/shared', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSharedFolder(id: string): Promise<SharedFolder> {
|
||||||
|
return request<SharedFolder>(`/api/v1/workspace/folders/shared/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteSharedFolder(id: string): Promise<void> {
|
||||||
|
await request(`/api/v1/workspace/folders/shared/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateFolderMount(id: string, mountPath: string): Promise<void> {
|
||||||
|
await request(`/api/v1/workspace/folders/shared/${id}/mount`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ mountPath }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFolderShares(folderId: string): Promise<FolderShare[]> {
|
||||||
|
const res = await request<FolderShare[]>(`/api/v1/workspace/folders/shared/${folderId}/shares`);
|
||||||
|
return ensureArray(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createFolderShare(
|
||||||
|
folderId: string,
|
||||||
|
payload: { targetType?: 'team' | 'user'; targetId?: string; userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' }
|
||||||
|
): Promise<FolderShare> {
|
||||||
|
return request<FolderShare>(`/api/v1/workspace/folders/shared/${folderId}/shares`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteFolderShare(folderId: string, shareId: string): Promise<void> {
|
||||||
|
await request(`/api/v1/workspace/folders/shared/${folderId}/shares/${shareId}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
|
||||||
// --- WORKSPACE TAGS ---
|
// --- WORKSPACE TAGS ---
|
||||||
async getWorkspaceTags(): Promise<TagSummary[]> {
|
async getWorkspaceTags(): Promise<TagSummary[]> {
|
||||||
const res = await request<TagSummary[]>('/api/v1/workspace/tags');
|
const res = await request<TagSummary[]>('/api/v1/workspace/tags');
|
||||||
@@ -531,6 +580,18 @@ export const api = {
|
|||||||
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async moveDocument(id: string, folderPath: string): Promise<Document> {
|
||||||
|
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ folderPath }),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
blocks: ensureArray(d.blocks),
|
||||||
|
tags: ensureArray(d.tags),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
// --- DOCUMENT SHARING ---
|
// --- DOCUMENT SHARING ---
|
||||||
async getDocShares(docId: string): Promise<DocShare[]> {
|
async getDocShares(docId: string): Promise<DocShare[]> {
|
||||||
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
|
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
|
||||||
|
|||||||
@@ -207,6 +207,36 @@ export interface Document {
|
|||||||
isOwner?: boolean;
|
isOwner?: boolean;
|
||||||
ownerName?: string;
|
ownerName?: string;
|
||||||
sharedWith?: DocShare[];
|
sharedWith?: DocShare[];
|
||||||
|
sharedFolderId?: string;
|
||||||
|
subFolderPath?: string;
|
||||||
|
isSharedFolder?: boolean;
|
||||||
|
folderPermission?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedFolder {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
ownerName?: string;
|
||||||
|
name: string;
|
||||||
|
mountPath: string;
|
||||||
|
isOwner: boolean;
|
||||||
|
permission: 'owner' | 'write' | 'read';
|
||||||
|
shares?: FolderShare[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FolderShare {
|
||||||
|
id: string;
|
||||||
|
folderId: string;
|
||||||
|
targetType: 'user' | 'team';
|
||||||
|
targetId: string;
|
||||||
|
targetName?: string;
|
||||||
|
email?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
permission: 'read' | 'write';
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TagSummary {
|
export interface TagSummary {
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Post("/api/v1/folders", s.handleCreateFolder)
|
r.Post("/api/v1/folders", s.handleCreateFolder)
|
||||||
r.Delete("/api/v1/folders", s.handleDeleteFolder)
|
r.Delete("/api/v1/folders", s.handleDeleteFolder)
|
||||||
|
|
||||||
|
r.Get("/api/v1/workspace/folders/shared", s.handleGetSharedFolders)
|
||||||
|
r.Post("/api/v1/workspace/folders/shared", s.handleCreateSharedFolder)
|
||||||
|
r.Get("/api/v1/workspace/folders/shared/{id}", s.handleGetSharedFolderByID)
|
||||||
|
r.Delete("/api/v1/workspace/folders/shared/{id}", s.handleDeleteSharedFolder)
|
||||||
|
r.Put("/api/v1/workspace/folders/shared/{id}/mount", s.handleUpdateFolderMount)
|
||||||
|
r.Get("/api/v1/workspace/folders/shared/{id}/shares", s.handleGetFolderShares)
|
||||||
|
r.Post("/api/v1/workspace/folders/shared/{id}/shares", s.handleCreateFolderShare)
|
||||||
|
r.Delete("/api/v1/workspace/folders/shared/{id}/shares/{shareId}", s.handleDeleteFolderShare)
|
||||||
|
|
||||||
// Workspace Tags & Pages
|
// Workspace Tags & Pages
|
||||||
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
|
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
|
||||||
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
|
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
|
||||||
@@ -115,6 +124,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Post("/api/v1/documents", s.handleCreateDocument)
|
r.Post("/api/v1/documents", s.handleCreateDocument)
|
||||||
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
|
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
|
||||||
r.Put("/api/v1/documents/{id}", s.handleUpdateDocument)
|
r.Put("/api/v1/documents/{id}", s.handleUpdateDocument)
|
||||||
|
r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument)
|
||||||
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
|
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
|
||||||
|
|
||||||
// Document Sharing & Collaboration
|
// Document Sharing & Collaboration
|
||||||
@@ -913,6 +923,159 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- WORKSPACE SHARED FOLDERS ---
|
||||||
|
|
||||||
|
func (s *Server) handleGetSharedFolders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
folders, err := s.store.GetSharedFolders(userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, folders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateSharedFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MountPath string `json:"mountPath"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
folder, err := s.store.CreateSharedFolder(userID, req.Name, req.MountPath)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", folder)
|
||||||
|
writeJSON(w, http.StatusCreated, folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetSharedFolderByID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
folderID := chi.URLParam(r, "id")
|
||||||
|
folder, err := s.store.GetSharedFolderByID(userID, folderID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteSharedFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
folderID := chi.URLParam(r, "id")
|
||||||
|
if err := s.store.DeleteSharedFolder(userID, folderID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateFolderMount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
folderID := chi.URLParam(r, "id")
|
||||||
|
var req struct {
|
||||||
|
MountPath string `json:"mountPath"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.UpdateFolderMount(userID, folderID, req.MountPath); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "mountPath": req.MountPath})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetFolderShares(w http.ResponseWriter, r *http.Request) {
|
||||||
|
folderID := chi.URLParam(r, "id")
|
||||||
|
shares, err := s.store.GetFolderShares(folderID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, shares)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
folderID := chi.URLParam(r, "id")
|
||||||
|
var req struct {
|
||||||
|
TargetType string `json:"targetType"` // team | user
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
UserId string `json:"userId"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
TeamId string `json:"teamId"`
|
||||||
|
Permission string `json:"permission"` // read | write
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetType := req.TargetType
|
||||||
|
targetIdentifier := req.TargetID
|
||||||
|
if targetIdentifier == "" {
|
||||||
|
if req.TeamId != "" {
|
||||||
|
targetType = "team"
|
||||||
|
targetIdentifier = req.TeamId
|
||||||
|
} else if req.UserId != "" {
|
||||||
|
targetType = "user"
|
||||||
|
targetIdentifier = req.UserId
|
||||||
|
} else if req.Email != "" {
|
||||||
|
targetType = "user"
|
||||||
|
targetIdentifier = req.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
share, err := s.store.CreateFolderShare(userID, folderID, targetType, targetIdentifier, req.Permission)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", share)
|
||||||
|
writeJSON(w, http.StatusCreated, share)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteFolderShare(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
folderID := chi.URLParam(r, "id")
|
||||||
|
shareID := chi.URLParam(r, "shareId")
|
||||||
|
if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "shareId": shareID})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMoveDocument(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
var req struct {
|
||||||
|
FolderPath string `json:"folderPath"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc, err := s.store.MoveDocument(userID, docID, req.FolderPath)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
|
||||||
|
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", nil)
|
||||||
|
writeJSON(w, http.StatusOK, doc)
|
||||||
|
}
|
||||||
|
|
||||||
// --- WORKSPACE TAGS & PAGES ---
|
// --- WORKSPACE TAGS & PAGES ---
|
||||||
|
|
||||||
func (s *Server) handleGetWorkspaceTags(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetWorkspaceTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -310,6 +310,10 @@ type Document struct {
|
|||||||
IsOwner bool `json:"isOwner"`
|
IsOwner bool `json:"isOwner"`
|
||||||
OwnerName string `json:"ownerName,omitempty"`
|
OwnerName string `json:"ownerName,omitempty"`
|
||||||
SharedWith []DocShare `json:"sharedWith,omitempty"`
|
SharedWith []DocShare `json:"sharedWith,omitempty"`
|
||||||
|
SharedFolderID string `json:"sharedFolderId,omitempty"`
|
||||||
|
SubFolderPath string `json:"subFolderPath,omitempty"`
|
||||||
|
IsSharedFolder bool `json:"isSharedFolder,omitempty"`
|
||||||
|
FolderPermission string `json:"folderPermission,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TagSummary struct {
|
type TagSummary struct {
|
||||||
@@ -421,3 +425,29 @@ type FullExport struct {
|
|||||||
Lists []SharedList `json:"lists"`
|
Lists []SharedList `json:"lists"`
|
||||||
TaskLists []TaskList `json:"taskLists,omitempty"`
|
TaskLists []TaskList `json:"taskLists,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SharedFolder struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OwnerID string `json:"ownerId"`
|
||||||
|
OwnerName string `json:"ownerName,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MountPath string `json:"mountPath"`
|
||||||
|
IsOwner bool `json:"isOwner"`
|
||||||
|
Permission string `json:"permission"` // owner | write | read
|
||||||
|
Shares []FolderShare `json:"shares,omitempty"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FolderShare struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FolderID string `json:"folderId"`
|
||||||
|
TargetType string `json:"targetType"` // team | user
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
TargetName string `json:"targetName,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||||
|
Permission string `json:"permission"` // read | write
|
||||||
|
CreatedBy string `json:"createdBy"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
+700
-59
@@ -344,6 +344,24 @@ func (s *Store) migrate() error {
|
|||||||
"CREATE TABLE IF NOT EXISTS list_shares (id TEXT PRIMARY KEY, list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(list_id, target_type, target_id));",
|
"CREATE TABLE IF NOT EXISTS list_shares (id TEXT PRIMARY KEY, list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(list_id, target_type, target_id));",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id);",
|
"CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id);",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id);",
|
"CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id);",
|
||||||
|
|
||||||
|
// Workspace Shared Folders & User Folder Placements
|
||||||
|
"ALTER TABLE documents ADD COLUMN shared_folder_id TEXT NOT NULL DEFAULT '';",
|
||||||
|
"ALTER TABLE documents ADD COLUMN sub_folder_path TEXT NOT NULL DEFAULT '';",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_documents_shared_folder ON documents(shared_folder_id);",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS workspace_shared_folders (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_workspace_shared_folders_owner ON workspace_shared_folders(owner_id);",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS workspace_folder_shares (id TEXT PRIMARY KEY, folder_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_by TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(folder_id, target_type, target_id));",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_workspace_folder_shares_folder ON workspace_folder_shares(folder_id);",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_workspace_folder_shares_target ON workspace_folder_shares(target_type, target_id);",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS workspace_folder_mounts (user_id TEXT NOT NULL, folder_id TEXT NOT NULL, mount_path TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, folder_id));",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_workspace_folder_mounts_user ON workspace_folder_mounts(user_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);",
|
||||||
}
|
}
|
||||||
for _, q := range columnMigrations {
|
for _, q := range columnMigrations {
|
||||||
_, _ = s.db.Exec(q)
|
_, _ = s.db.Exec(q)
|
||||||
@@ -1290,6 +1308,13 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string,
|
|||||||
pinnedInt = 1
|
pinnedInt = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mounts, _, _ := s.getUserFolderMounts(userID)
|
||||||
|
var sharedFolderID, subFolderPath string
|
||||||
|
if fID, sub, ok := matchMountPath(folderPath, mounts); ok {
|
||||||
|
sharedFolderID = fID
|
||||||
|
subFolderPath = sub
|
||||||
|
}
|
||||||
|
|
||||||
doc := &domain.Document{
|
doc := &domain.Document{
|
||||||
ID: domain.NewID("doc"),
|
ID: domain.NewID("doc"),
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -1301,14 +1326,16 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string,
|
|||||||
Tags: tags,
|
Tags: tags,
|
||||||
FolderPath: folderPath,
|
FolderPath: folderPath,
|
||||||
IsPinned: isPinned,
|
IsPinned: isPinned,
|
||||||
|
SharedFolderID: sharedFolderID,
|
||||||
|
SubFolderPath: subFolderPath,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO documents (id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at)
|
`INSERT INTO documents (id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at, shared_folder_id, sub_folder_path)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`,
|
||||||
doc.ID, doc.UserID, doc.SpaceID, doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.CreatedAt, doc.UpdatedAt,
|
doc.ID, doc.UserID, doc.SpaceID, doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.CreatedAt, doc.UpdatedAt, doc.SharedFolderID, doc.SubFolderPath,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1321,6 +1348,110 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string,
|
|||||||
return doc, nil
|
return doc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getUserFolderMounts returns map[folder_id]mount_path and map[folder_id]folder_name for all shared folders accessible to userID
|
||||||
|
func (s *Store) getUserFolderMounts(userID string) (map[string]string, map[string]string, error) {
|
||||||
|
mounts := make(map[string]string)
|
||||||
|
names := make(map[string]string)
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT f.id, f.name, COALESCE(m.mount_path, f.name) as mount_path
|
||||||
|
FROM workspace_shared_folders f
|
||||||
|
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
||||||
|
LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND (
|
||||||
|
(s.target_type = 'user' AND s.target_id = ?) OR
|
||||||
|
(s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||||
|
)
|
||||||
|
WHERE f.owner_id = ? OR s.id IS NOT NULL
|
||||||
|
GROUP BY f.id
|
||||||
|
`, userID, userID, userID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return mounts, names, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var fID, fName, mPath string
|
||||||
|
if err := rows.Scan(&fID, &fName, &mPath); err == nil {
|
||||||
|
names[fID] = fName
|
||||||
|
if mPath == "" {
|
||||||
|
mPath = fName
|
||||||
|
}
|
||||||
|
mounts[fID] = mPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mounts, names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchMountPath checks if folderPath falls within any mounted shared folder.
|
||||||
|
// Returns (sharedFolderID, relativeSubPath, isMatch).
|
||||||
|
func matchMountPath(folderPath string, mounts map[string]string) (string, string, bool) {
|
||||||
|
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
|
||||||
|
if clean == "." {
|
||||||
|
clean = ""
|
||||||
|
}
|
||||||
|
if clean == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestFolderID string
|
||||||
|
var bestSubPath string
|
||||||
|
var longestMountLen int = -1
|
||||||
|
|
||||||
|
for fID, mPath := range mounts {
|
||||||
|
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mPath)))
|
||||||
|
if cleanMount == "." {
|
||||||
|
cleanMount = ""
|
||||||
|
}
|
||||||
|
if cleanMount == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if clean == cleanMount {
|
||||||
|
if len(cleanMount) > longestMountLen {
|
||||||
|
longestMountLen = len(cleanMount)
|
||||||
|
bestFolderID = fID
|
||||||
|
bestSubPath = ""
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(clean, cleanMount+"/") {
|
||||||
|
sub := strings.TrimPrefix(clean, cleanMount+"/")
|
||||||
|
if len(cleanMount) > longestMountLen {
|
||||||
|
longestMountLen = len(cleanMount)
|
||||||
|
bestFolderID = fID
|
||||||
|
bestSubPath = sub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if longestMountLen >= 0 {
|
||||||
|
return bestFolderID, bestSubPath, true
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDocumentPath(userID string, doc *domain.Document, mounts map[string]string, names map[string]string, userFolders map[string]string) {
|
||||||
|
if doc.SharedFolderID != "" {
|
||||||
|
mountPath, ok := mounts[doc.SharedFolderID]
|
||||||
|
if !ok || mountPath == "" {
|
||||||
|
mountPath = names[doc.SharedFolderID]
|
||||||
|
if mountPath == "" {
|
||||||
|
mountPath = "Geteilter Ordner"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doc.IsSharedFolder = true
|
||||||
|
if doc.SubFolderPath != "" {
|
||||||
|
doc.FolderPath = mountPath + "/" + doc.SubFolderPath
|
||||||
|
} else {
|
||||||
|
doc.FolderPath = mountPath
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if uf, ok := userFolders[doc.ID]; ok {
|
||||||
|
doc.FolderPath = uf
|
||||||
|
} else if !doc.IsOwner {
|
||||||
|
doc.FolderPath = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func scanDocument(rows interface {
|
func scanDocument(rows interface {
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
}) (*domain.Document, error) {
|
}) (*domain.Document, error) {
|
||||||
@@ -1330,6 +1461,7 @@ func scanDocument(rows interface {
|
|||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||||||
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||||||
|
&d.SharedFolderID, &d.SubFolderPath,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1344,11 +1476,26 @@ func scanDocument(rows interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
||||||
|
mounts, names, _ := s.getUserFolderMounts(userID)
|
||||||
|
|
||||||
|
userFolders := make(map[string]string)
|
||||||
|
ufRows, err := s.db.Query(`SELECT doc_id, folder_path FROM doc_user_folders WHERE user_id = ?`, userID)
|
||||||
|
if err == nil {
|
||||||
|
for ufRows.Next() {
|
||||||
|
var did, fp string
|
||||||
|
if err := ufRows.Scan(&did, &fp); err == nil {
|
||||||
|
userFolders[did] = fp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = ufRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Fetch user's own documents (with per-user is_pinned from doc_pins)
|
// 1. Fetch user's own documents (with per-user is_pinned from doc_pins)
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||||||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||||||
d.is_archived, d.created_at, d.updated_at
|
d.is_archived, d.created_at, d.updated_at,
|
||||||
|
d.shared_folder_id, d.sub_folder_path
|
||||||
FROM documents d
|
FROM documents d
|
||||||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||||||
WHERE d.user_id = ? AND d.is_archived = 0
|
WHERE d.user_id = ? AND d.is_archived = 0
|
||||||
@@ -1365,17 +1512,24 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
d.Permission = "owner"
|
d.Permission = "owner"
|
||||||
d.IsOwner = true
|
d.IsOwner = true
|
||||||
|
resolveDocumentPath(userID, d, mounts, names, userFolders)
|
||||||
docs = append(docs, *d)
|
docs = append(docs, *d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = rows.Close()
|
_ = rows.Close()
|
||||||
|
|
||||||
// 2. Fetch documents shared with the user directly or via teams (with per-user is_pinned from doc_pins)
|
// 2. Fetch documents shared with the user directly, via teams, or via shared folders
|
||||||
sharedRows, err := s.db.Query(
|
sharedRows, err := s.db.Query(
|
||||||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||||||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||||||
d.is_archived, d.created_at, d.updated_at,
|
d.is_archived, d.created_at, d.updated_at,
|
||||||
COALESCE(s.permission, dts.permission, 'read') AS permission, u.display_name
|
d.shared_folder_id, d.sub_folder_path,
|
||||||
|
CASE
|
||||||
|
WHEN wsf.owner_id = ? THEN 'owner'
|
||||||
|
WHEN COALESCE(s.permission, '') = 'write' OR COALESCE(dts.permission, '') = 'write' OR COALESCE(wfs.permission, '') = 'write' THEN 'write'
|
||||||
|
ELSE 'read'
|
||||||
|
END AS permission,
|
||||||
|
u.display_name
|
||||||
FROM documents d
|
FROM documents d
|
||||||
JOIN users u ON d.user_id = u.id
|
JOIN users u ON d.user_id = u.id
|
||||||
LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ?
|
LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ?
|
||||||
@@ -1385,11 +1539,20 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
|||||||
JOIN team_members tm ON tm.team_id = dts2.team_id
|
JOIN team_members tm ON tm.team_id = dts2.team_id
|
||||||
WHERE tm.user_id = ?
|
WHERE tm.user_id = ?
|
||||||
) dts ON d.id = dts.doc_id
|
) dts ON d.id = dts.doc_id
|
||||||
|
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT wfs2.folder_id, wfs2.permission
|
||||||
|
FROM workspace_folder_shares wfs2
|
||||||
|
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
|
||||||
|
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||||
|
) wfs ON d.shared_folder_id = wfs.folder_id
|
||||||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||||||
WHERE (s.user_id = ? OR dts.doc_id IS NOT NULL) AND d.is_archived = 0 AND d.user_id != ?
|
WHERE d.is_archived = 0 AND d.user_id != ? AND (
|
||||||
|
s.user_id IS NOT NULL OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL
|
||||||
|
)
|
||||||
GROUP BY d.id
|
GROUP BY d.id
|
||||||
ORDER BY is_pinned DESC, d.updated_at DESC`,
|
ORDER BY is_pinned DESC, d.updated_at DESC`,
|
||||||
userID, userID, userID, userID, userID,
|
userID, userID, userID, userID, userID, userID, userID, userID,
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for sharedRows.Next() {
|
for sharedRows.Next() {
|
||||||
@@ -1400,6 +1563,7 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
|||||||
if scanErr := sharedRows.Scan(
|
if scanErr := sharedRows.Scan(
|
||||||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||||||
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||||||
|
&d.SharedFolderID, &d.SubFolderPath,
|
||||||
&perm, &ownerName,
|
&perm, &ownerName,
|
||||||
); scanErr == nil {
|
); scanErr == nil {
|
||||||
d.IsPinned = isPinned != 0
|
d.IsPinned = isPinned != 0
|
||||||
@@ -1409,8 +1573,9 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
|||||||
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
|
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
|
||||||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||||||
d.Permission = perm
|
d.Permission = perm
|
||||||
d.IsOwner = false
|
d.IsOwner = (perm == "owner")
|
||||||
d.OwnerName = ownerName
|
d.OwnerName = ownerName
|
||||||
|
resolveDocumentPath(userID, d, mounts, names, userFolders)
|
||||||
docs = append(docs, *d)
|
docs = append(docs, *d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1430,11 +1595,19 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) {
|
func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) {
|
||||||
|
mounts, names, _ := s.getUserFolderMounts(userID)
|
||||||
|
userFolders := make(map[string]string)
|
||||||
|
var personalFp string
|
||||||
|
if err := s.db.QueryRow(`SELECT folder_path FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID).Scan(&personalFp); err == nil {
|
||||||
|
userFolders[docID] = personalFp
|
||||||
|
}
|
||||||
|
|
||||||
// Try owned document first (with per-user is_pinned)
|
// Try owned document first (with per-user is_pinned)
|
||||||
row := s.db.QueryRow(
|
row := s.db.QueryRow(
|
||||||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||||||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||||||
d.is_archived, d.created_at, d.updated_at
|
d.is_archived, d.created_at, d.updated_at,
|
||||||
|
d.shared_folder_id, d.sub_folder_path
|
||||||
FROM documents d
|
FROM documents d
|
||||||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||||||
WHERE d.id = ? AND d.user_id = ?`,
|
WHERE d.id = ? AND d.user_id = ?`,
|
||||||
@@ -1444,18 +1617,25 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
doc.Permission = "owner"
|
doc.Permission = "owner"
|
||||||
doc.IsOwner = true
|
doc.IsOwner = true
|
||||||
|
resolveDocumentPath(userID, doc, mounts, names, userFolders)
|
||||||
if shares, err := s.GetDocShares(doc.ID); err == nil {
|
if shares, err := s.GetDocShares(doc.ID); err == nil {
|
||||||
doc.SharedWith = shares
|
doc.SharedWith = shares
|
||||||
}
|
}
|
||||||
return doc, nil
|
return doc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try shared document (direct or team share, with per-user is_pinned)
|
// Try shared document (direct, via team, or via shared folder)
|
||||||
sharedRow := s.db.QueryRow(
|
sharedRow := s.db.QueryRow(
|
||||||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||||||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||||||
d.is_archived, d.created_at, d.updated_at,
|
d.is_archived, d.created_at, d.updated_at,
|
||||||
COALESCE(s.permission, dts.permission, 'read') AS permission, u.display_name
|
d.shared_folder_id, d.sub_folder_path,
|
||||||
|
CASE
|
||||||
|
WHEN wsf.owner_id = ? THEN 'owner'
|
||||||
|
WHEN COALESCE(s.permission, '') = 'write' OR COALESCE(dts.permission, '') = 'write' OR COALESCE(wfs.permission, '') = 'write' THEN 'write'
|
||||||
|
ELSE 'read'
|
||||||
|
END AS permission,
|
||||||
|
u.display_name
|
||||||
FROM documents d
|
FROM documents d
|
||||||
JOIN users u ON d.user_id = u.id
|
JOIN users u ON d.user_id = u.id
|
||||||
LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ?
|
LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ?
|
||||||
@@ -1465,10 +1645,17 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
|
|||||||
JOIN team_members tm ON tm.team_id = dts2.team_id
|
JOIN team_members tm ON tm.team_id = dts2.team_id
|
||||||
WHERE tm.user_id = ?
|
WHERE tm.user_id = ?
|
||||||
) dts ON d.id = dts.doc_id
|
) dts ON d.id = dts.doc_id
|
||||||
|
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT wfs2.folder_id, wfs2.permission
|
||||||
|
FROM workspace_folder_shares wfs2
|
||||||
|
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
|
||||||
|
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||||
|
) wfs ON d.shared_folder_id = wfs.folder_id
|
||||||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||||||
WHERE d.id = ? AND (s.user_id = ? OR dts.doc_id IS NOT NULL)
|
WHERE d.id = ? AND (s.user_id = ? OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
userID, userID, userID, docID, userID,
|
userID, userID, userID, userID, userID, userID, docID, userID, userID,
|
||||||
)
|
)
|
||||||
d := &domain.Document{}
|
d := &domain.Document{}
|
||||||
var blocksStr, tagsStr string
|
var blocksStr, tagsStr string
|
||||||
@@ -1477,6 +1664,7 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
|
|||||||
err = sharedRow.Scan(
|
err = sharedRow.Scan(
|
||||||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||||||
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||||||
|
&d.SharedFolderID, &d.SubFolderPath,
|
||||||
&perm, &ownerName,
|
&perm, &ownerName,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1489,8 +1677,9 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
|
|||||||
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
|
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
|
||||||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||||||
d.Permission = perm
|
d.Permission = perm
|
||||||
d.IsOwner = false
|
d.IsOwner = (perm == "owner")
|
||||||
d.OwnerName = ownerName
|
d.OwnerName = ownerName
|
||||||
|
resolveDocumentPath(userID, d, mounts, names, userFolders)
|
||||||
if shares, err := s.GetDocShares(d.ID); err == nil {
|
if shares, err := s.GetDocShares(d.ID); err == nil {
|
||||||
d.SharedWith = shares
|
d.SharedWith = shares
|
||||||
}
|
}
|
||||||
@@ -1504,8 +1693,9 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
|||||||
}
|
}
|
||||||
|
|
||||||
isOnlyPinChange := isPinned != nil && title == nil && icon == nil && folderPath == nil && blocks == nil && tags == nil
|
isOnlyPinChange := isPinned != nil && title == nil && icon == nil && folderPath == nil && blocks == nil && tags == nil
|
||||||
|
isOnlyFolderMove := folderPath != nil && title == nil && icon == nil && blocks == nil && tags == nil && isPinned == nil
|
||||||
|
|
||||||
if !doc.IsOwner && doc.Permission != "write" && !isOnlyPinChange {
|
if !doc.IsOwner && doc.Permission != "write" && !isOnlyPinChange && !isOnlyFolderMove {
|
||||||
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1528,6 +1718,21 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
|||||||
return doc, nil
|
return doc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if folderPath != nil {
|
||||||
|
movedDoc, moveErr := s.MoveDocument(userID, docID, *folderPath)
|
||||||
|
if moveErr != nil {
|
||||||
|
return nil, moveErr
|
||||||
|
}
|
||||||
|
doc.FolderPath = movedDoc.FolderPath
|
||||||
|
doc.SharedFolderID = movedDoc.SharedFolderID
|
||||||
|
doc.SubFolderPath = movedDoc.SubFolderPath
|
||||||
|
doc.IsSharedFolder = movedDoc.IsSharedFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
if isOnlyFolderMove {
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
if title != nil {
|
if title != nil {
|
||||||
doc.Title = strings.TrimSpace(*title)
|
doc.Title = strings.TrimSpace(*title)
|
||||||
}
|
}
|
||||||
@@ -1883,61 +2088,37 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
|
|||||||
func (s *Store) GetFolders(userID string) ([]string, error) {
|
func (s *Store) GetFolders(userID string) ([]string, error) {
|
||||||
folderSet := make(map[string]bool)
|
folderSet := make(map[string]bool)
|
||||||
|
|
||||||
// 1. Collect from SQLite documents (owned by user or shared with user)
|
// 1. Collect from resolved documents for this user
|
||||||
rows, err := s.db.Query(
|
docs, err := s.GetDocuments(userID)
|
||||||
`SELECT DISTINCT folder_path FROM documents
|
|
||||||
WHERE (user_id = ? OR id IN (SELECT doc_id FROM doc_shares WHERE user_id = ?))
|
|
||||||
AND is_archived = 0 AND folder_path != ''`,
|
|
||||||
userID, userID,
|
|
||||||
)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for rows.Next() {
|
for _, doc := range docs {
|
||||||
var fp string
|
if doc.FolderPath != "" {
|
||||||
if err := rows.Scan(&fp); err == nil && fp != "" {
|
addFolderSegments(doc.FolderPath, folderSet)
|
||||||
clean := filepath.ToSlash(filepath.Clean(fp))
|
|
||||||
parts := strings.Split(clean, "/")
|
|
||||||
var cur string
|
|
||||||
for _, p := range parts {
|
|
||||||
if p != "" && p != "." {
|
|
||||||
if cur == "" {
|
|
||||||
cur = p
|
|
||||||
} else {
|
|
||||||
cur = cur + "/" + p
|
|
||||||
}
|
|
||||||
folderSet[cur] = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
_ = rows.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Scan physical directories on disk in docDir/userID
|
// 2. Add mounted shared folders (so even empty shared folders appear in the tree)
|
||||||
|
mounts, _, err := s.getUserFolderMounts(userID)
|
||||||
|
if err == nil {
|
||||||
|
for _, mPath := range mounts {
|
||||||
|
if mPath != "" {
|
||||||
|
addFolderSegments(mPath, folderSet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 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() {
|
||||||
_ = filepath.Walk(userDir, func(path string, info os.FileInfo, err error) error {
|
_ = filepath.Walk(userDir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil || !info.IsDir() {
|
if err != nil || !info.IsDir() || path == userDir {
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if path == userDir {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rel, err := filepath.Rel(userDir, path)
|
rel, err := filepath.Rel(userDir, path)
|
||||||
if err == nil && rel != "" && rel != "." {
|
if err == nil && rel != "" && rel != "." {
|
||||||
clean := filepath.ToSlash(filepath.Clean(rel))
|
addFolderSegments(rel, folderSet)
|
||||||
parts := strings.Split(clean, "/")
|
|
||||||
var cur string
|
|
||||||
for _, p := range parts {
|
|
||||||
if p != "" && p != "." {
|
|
||||||
if cur == "" {
|
|
||||||
cur = p
|
|
||||||
} else {
|
|
||||||
cur = cur + "/" + p
|
|
||||||
}
|
|
||||||
folderSet[cur] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -1952,6 +2133,25 @@ func (s *Store) GetFolders(userID string) ([]string, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addFolderSegments(fp string, set map[string]bool) {
|
||||||
|
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(fp)))
|
||||||
|
if clean == "." || clean == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.Split(clean, "/")
|
||||||
|
var cur string
|
||||||
|
for _, p := range parts {
|
||||||
|
if p != "" && p != "." {
|
||||||
|
if cur == "" {
|
||||||
|
cur = p
|
||||||
|
} else {
|
||||||
|
cur = cur + "/" + p
|
||||||
|
}
|
||||||
|
set[cur] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) CreateFolder(userID, folderPath string) (string, error) {
|
func (s *Store) CreateFolder(userID, folderPath string) (string, error) {
|
||||||
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
|
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
|
||||||
if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
|
if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
|
||||||
@@ -2010,6 +2210,446 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
|
|||||||
return deletedDocIDs, nil
|
return deletedDocIDs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Document, error) {
|
||||||
|
doc, err := s.GetDocumentByID(userID, docID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetFolderPath)))
|
||||||
|
if cleanTarget == "." {
|
||||||
|
cleanTarget = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
mounts, _, err := s.getUserFolderMounts(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := domain.NowISO()
|
||||||
|
|
||||||
|
// Check if target folder falls within any mounted shared folder
|
||||||
|
targetFolderID, subPath, isTargetShared := matchMountPath(cleanTarget, mounts)
|
||||||
|
|
||||||
|
if isTargetShared {
|
||||||
|
// Moving into / within a shared folder
|
||||||
|
shFolder, err := s.GetSharedFolderByID(userID, targetFolderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !shFolder.IsOwner && shFolder.Permission != "write" {
|
||||||
|
return nil, fmt.Errorf("keine Schreibberechtigung für diesen geteilten Ordner")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update document to belong to targetSharedFolderID
|
||||||
|
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
targetFolderID, subPath, now, docID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove personal override if any
|
||||||
|
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
|
||||||
|
|
||||||
|
doc.SharedFolderID = targetFolderID
|
||||||
|
doc.SubFolderPath = subPath
|
||||||
|
doc.IsSharedFolder = true
|
||||||
|
doc.FolderPath = cleanTarget
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target is NOT inside a shared folder (personal / standalone)
|
||||||
|
if doc.SharedFolderID != "" {
|
||||||
|
// Moving OUT of a shared folder into a personal folder
|
||||||
|
if !doc.IsOwner && doc.Permission != "write" {
|
||||||
|
return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben")
|
||||||
|
}
|
||||||
|
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
cleanTarget, now, docID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
doc.SharedFolderID = ""
|
||||||
|
doc.SubFolderPath = ""
|
||||||
|
doc.IsSharedFolder = false
|
||||||
|
doc.FolderPath = cleanTarget
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone note
|
||||||
|
if doc.IsOwner {
|
||||||
|
// Owner moves their own document -> updates documents.folder_path
|
||||||
|
_, err = s.db.Exec(`UPDATE documents SET folder_path = ?, updated_at = ? WHERE id = ?`, cleanTarget, now, docID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
doc.FolderPath = cleanTarget
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient moves an individually shared note -> updates doc_user_folders ONLY!
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
INSERT INTO doc_user_folders (user_id, doc_id, folder_path, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, doc_id) DO UPDATE SET folder_path = excluded.folder_path, updated_at = excluded.updated_at
|
||||||
|
`, userID, docID, cleanTarget, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.FolderPath = cleanTarget
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) (*domain.SharedFolder, error) {
|
||||||
|
cleanName := strings.TrimSpace(folderName)
|
||||||
|
if cleanName == "" {
|
||||||
|
return nil, fmt.Errorf("ordnername darf nicht leer sein")
|
||||||
|
}
|
||||||
|
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(initialMountPath)))
|
||||||
|
if cleanMount == "." || cleanMount == "" {
|
||||||
|
cleanMount = cleanName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already shared by this user at this mount path
|
||||||
|
var existingID string
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
SELECT f.id FROM workspace_shared_folders f
|
||||||
|
JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
||||||
|
WHERE f.owner_id = ? AND (m.mount_path = ? OR f.name = ?)
|
||||||
|
`, userID, userID, cleanMount, cleanName).Scan(&existingID)
|
||||||
|
if err == nil && existingID != "" {
|
||||||
|
return s.GetSharedFolderByID(userID, existingID)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := domain.NowISO()
|
||||||
|
folderID := domain.NewID("fld")
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
INSERT INTO workspace_shared_folders (id, owner_id, name, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`, folderID, userID, cleanName, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`, userID, folderID, cleanMount, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link all existing documents in cleanMount to this shared folder
|
||||||
|
rows, err := s.db.Query(`SELECT id, folder_path FROM documents WHERE user_id = ? AND is_archived = 0 AND (folder_path = ? OR folder_path LIKE ?)`,
|
||||||
|
userID, cleanMount, cleanMount+"/%")
|
||||||
|
if err == nil {
|
||||||
|
type docEntry struct{ id, fp string }
|
||||||
|
var entries []docEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var de docEntry
|
||||||
|
if err := rows.Scan(&de.id, &de.fp); err == nil {
|
||||||
|
entries = append(entries, de)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
|
||||||
|
for _, de := range entries {
|
||||||
|
var subPath string
|
||||||
|
if de.fp == cleanMount {
|
||||||
|
subPath = ""
|
||||||
|
} else if strings.HasPrefix(de.fp, cleanMount+"/") {
|
||||||
|
subPath = strings.TrimPrefix(de.fp, cleanMount+"/")
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ? WHERE id = ?`,
|
||||||
|
folderID, subPath, de.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ownerName string
|
||||||
|
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName)
|
||||||
|
|
||||||
|
return &domain.SharedFolder{
|
||||||
|
ID: folderID,
|
||||||
|
OwnerID: userID,
|
||||||
|
OwnerName: ownerName,
|
||||||
|
Name: cleanName,
|
||||||
|
MountPath: cleanMount,
|
||||||
|
IsOwner: true,
|
||||||
|
Permission: "owner",
|
||||||
|
Shares: []domain.FolderShare{},
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetSharedFolders(userID string) ([]domain.SharedFolder, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT f.id, f.owner_id, u.display_name, f.name,
|
||||||
|
COALESCE(m.mount_path, f.name) as mount_path,
|
||||||
|
CASE
|
||||||
|
WHEN f.owner_id = ? THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END as is_owner,
|
||||||
|
CASE
|
||||||
|
WHEN f.owner_id = ? THEN 'owner'
|
||||||
|
WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write'
|
||||||
|
ELSE 'read'
|
||||||
|
END as permission,
|
||||||
|
f.created_at, f.updated_at
|
||||||
|
FROM workspace_shared_folders f
|
||||||
|
JOIN users u ON f.owner_id = u.id
|
||||||
|
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
||||||
|
LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND (
|
||||||
|
(s.target_type = 'user' AND s.target_id = ?) OR
|
||||||
|
(s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||||
|
)
|
||||||
|
WHERE f.owner_id = ? OR s.id IS NOT NULL
|
||||||
|
GROUP BY f.id
|
||||||
|
ORDER BY f.created_at ASC
|
||||||
|
`, userID, userID, userID, userID, userID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
folders := make([]domain.SharedFolder, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var f domain.SharedFolder
|
||||||
|
var isOwner int
|
||||||
|
if err := rows.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err == nil {
|
||||||
|
f.IsOwner = isOwner != 0
|
||||||
|
if f.IsOwner {
|
||||||
|
if shares, err := s.GetFolderShares(f.ID); err == nil {
|
||||||
|
f.Shares = shares
|
||||||
|
}
|
||||||
|
}
|
||||||
|
folders = append(folders, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return folders, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetSharedFolderByID(userID, folderID string) (*domain.SharedFolder, error) {
|
||||||
|
row := s.db.QueryRow(`
|
||||||
|
SELECT f.id, f.owner_id, u.display_name, f.name,
|
||||||
|
COALESCE(m.mount_path, f.name) as mount_path,
|
||||||
|
CASE
|
||||||
|
WHEN f.owner_id = ? THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END as is_owner,
|
||||||
|
CASE
|
||||||
|
WHEN f.owner_id = ? THEN 'owner'
|
||||||
|
WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write'
|
||||||
|
ELSE 'read'
|
||||||
|
END as permission,
|
||||||
|
f.created_at, f.updated_at
|
||||||
|
FROM workspace_shared_folders f
|
||||||
|
JOIN users u ON f.owner_id = u.id
|
||||||
|
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
||||||
|
LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND (
|
||||||
|
(s.target_type = 'user' AND s.target_id = ?) OR
|
||||||
|
(s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||||
|
)
|
||||||
|
WHERE f.id = ? AND (f.owner_id = ? OR s.id IS NOT NULL)
|
||||||
|
GROUP BY f.id
|
||||||
|
`, userID, userID, userID, userID, userID, folderID, userID)
|
||||||
|
|
||||||
|
var f domain.SharedFolder
|
||||||
|
var isOwner int
|
||||||
|
if err := row.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("geteilter Ordner nicht gefunden")
|
||||||
|
}
|
||||||
|
f.IsOwner = isOwner != 0
|
||||||
|
if f.IsOwner {
|
||||||
|
if shares, err := s.GetFolderShares(f.ID); err == nil {
|
||||||
|
f.Shares = shares
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateFolderMount(userID, folderID, mountPath string) error {
|
||||||
|
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mountPath)))
|
||||||
|
if cleanMount == "." || cleanMount == "" {
|
||||||
|
return fmt.Errorf("ungültiger Mount-Pfad")
|
||||||
|
}
|
||||||
|
now := domain.NowISO()
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, folder_id) DO UPDATE SET mount_path = excluded.mount_path, updated_at = excluded.updated_at
|
||||||
|
`, userID, folderID, cleanMount, now, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetFolderShares(folderID string) ([]domain.FolderShare, error) {
|
||||||
|
shares := make([]domain.FolderShare, 0)
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT s.id, s.folder_id, s.target_type, s.target_id,
|
||||||
|
CASE
|
||||||
|
WHEN s.target_type = 'user' THEN u.display_name
|
||||||
|
WHEN s.target_type = 'team' THEN t.name
|
||||||
|
ELSE ''
|
||||||
|
END as target_name,
|
||||||
|
COALESCE(u.email, '') as email,
|
||||||
|
COALESCE(u.avatar_url, '') as avatar_url,
|
||||||
|
s.permission, s.created_by, s.created_at
|
||||||
|
FROM workspace_folder_shares s
|
||||||
|
LEFT JOIN users u ON s.target_type = 'user' AND s.target_id = u.id
|
||||||
|
LEFT JOIN teams t ON s.target_type = 'team' AND s.target_id = t.id
|
||||||
|
WHERE s.folder_id = ?
|
||||||
|
ORDER BY s.created_at ASC
|
||||||
|
`, folderID)
|
||||||
|
if err != nil {
|
||||||
|
return shares, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var sh domain.FolderShare
|
||||||
|
if err := rows.Scan(&sh.ID, &sh.FolderID, &sh.TargetType, &sh.TargetID, &sh.TargetName, &sh.Email, &sh.AvatarURL, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||||
|
shares = append(shares, sh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shares, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIdentifier, permission string) (*domain.FolderShare, error) {
|
||||||
|
f, err := s.GetSharedFolderByID(currentUserID, folderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !f.IsOwner && f.Permission != "write" {
|
||||||
|
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Ordner-Freigaben verwalten")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanType := strings.TrimSpace(targetType)
|
||||||
|
cleanTarget := strings.TrimSpace(targetIdentifier)
|
||||||
|
if cleanType == "" {
|
||||||
|
if strings.HasPrefix(cleanTarget, "team_") {
|
||||||
|
cleanType = "team"
|
||||||
|
} else {
|
||||||
|
cleanType = "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if permission != "read" && permission != "write" {
|
||||||
|
permission = "read"
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetID, targetName, email, avatarURL string
|
||||||
|
if cleanType == "team" {
|
||||||
|
err = s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gruppe nicht gefunden")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var u *domain.User
|
||||||
|
if strings.HasPrefix(cleanTarget, "usr_") {
|
||||||
|
u, _ = s.GetUserByID(cleanTarget)
|
||||||
|
}
|
||||||
|
if u == nil {
|
||||||
|
u, _ = s.GetUserByLogin(cleanTarget)
|
||||||
|
}
|
||||||
|
if u == nil {
|
||||||
|
u, err = s.GetUserByID(cleanTarget)
|
||||||
|
}
|
||||||
|
if err != nil || u == nil {
|
||||||
|
if s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName) == nil {
|
||||||
|
cleanType = "team"
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("benutzer nicht gefunden")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if u.ID == f.OwnerID {
|
||||||
|
return nil, fmt.Errorf("ordner gehört bereits diesem Benutzer")
|
||||||
|
}
|
||||||
|
targetID = u.ID
|
||||||
|
targetName = u.DisplayName
|
||||||
|
email = u.Email
|
||||||
|
avatarURL = u.AvatarURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := domain.NowISO()
|
||||||
|
shareID := domain.NewID("fsh")
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
INSERT INTO workspace_folder_shares (id, folder_id, target_type, target_id, permission, created_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(folder_id, target_type, target_id) DO UPDATE SET permission = excluded.permission
|
||||||
|
`, shareID, folderID, cleanType, targetID, permission, currentUserID, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cleanType == "user" {
|
||||||
|
_, _ = s.db.Exec(`
|
||||||
|
INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`, targetID, folderID, f.Name, now, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.FolderShare{
|
||||||
|
ID: shareID,
|
||||||
|
FolderID: folderID,
|
||||||
|
TargetType: cleanType,
|
||||||
|
TargetID: targetID,
|
||||||
|
TargetName: targetName,
|
||||||
|
Email: email,
|
||||||
|
AvatarURL: avatarURL,
|
||||||
|
Permission: permission,
|
||||||
|
CreatedBy: currentUserID,
|
||||||
|
CreatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteFolderShare(currentUserID, folderID, shareID string) error {
|
||||||
|
f, err := s.GetSharedFolderByID(currentUserID, folderID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cleanID := strings.TrimSpace(shareID)
|
||||||
|
|
||||||
|
if !f.IsOwner && f.Permission != "write" {
|
||||||
|
_, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user' AND target_id = ? AND (id = ? OR target_id = ?)`,
|
||||||
|
folderID, currentUserID, cleanID, cleanID)
|
||||||
|
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, currentUserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetType, targetID string
|
||||||
|
_ = s.db.QueryRow(`SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`,
|
||||||
|
folderID, cleanID, cleanID).Scan(&targetType, &targetID)
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, folderID, cleanID, cleanID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if targetType == "user" && targetID != "" {
|
||||||
|
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, targetID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error {
|
||||||
|
f, err := s.GetSharedFolderByID(currentUserID, folderID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !f.IsOwner {
|
||||||
|
return s.DeleteFolderShare(currentUserID, folderID, currentUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = 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_shares WHERE folder_id = ?`, folderID)
|
||||||
|
_, err = s.db.Exec(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
|
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
|
||||||
shares := make([]domain.DocShare, 0)
|
shares := make([]domain.DocShare, 0)
|
||||||
|
|
||||||
@@ -2781,7 +3421,8 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu
|
|||||||
dRows, err := s.db.Query(
|
dRows, err := s.db.Query(
|
||||||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||||||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||||||
d.is_archived, d.created_at, d.updated_at
|
d.is_archived, d.created_at, d.updated_at,
|
||||||
|
d.shared_folder_id, d.sub_folder_path
|
||||||
FROM documents d
|
FROM documents d
|
||||||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||||||
WHERE d.user_id = ? AND (LOWER(d.title) LIKE ? OR LOWER(d.content) LIKE ?) ORDER BY is_pinned DESC, d.updated_at DESC LIMIT 20`,
|
WHERE d.user_id = ? AND (LOWER(d.title) LIKE ? OR LOWER(d.content) LIKE ?) ORDER BY is_pinned DESC, d.updated_at DESC LIMIT 20`,
|
||||||
|
|||||||
Reference in new issue
Block a user