Dev #2
No files matched your search
@@ -46,8 +46,9 @@ import {
|
|||||||
Edit3,
|
Edit3,
|
||||||
Share,
|
Share,
|
||||||
Share2,
|
Share2,
|
||||||
|
Hash,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, User } from '../../lib/types';
|
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, User } from '../../lib/types';
|
||||||
import { api, getToken, triggerHaptic } from '../../lib/api';
|
import { api, getToken, triggerHaptic } from '../../lib/api';
|
||||||
|
|
||||||
interface WorkspaceViewProps {
|
interface WorkspaceViewProps {
|
||||||
@@ -91,8 +92,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
initialDocId,
|
initialDocId,
|
||||||
onStatsChange,
|
onStatsChange,
|
||||||
}) => {
|
}) => {
|
||||||
// Top Workspace Tab Mode: 'editor' vs 'todos' (Global To-Do Rollup Dashboard)
|
// Top Workspace Tab Mode: 'editor' vs 'todos' vs 'tag'
|
||||||
const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos'>('editor');
|
const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos' | 'tag'>('editor');
|
||||||
|
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||||
|
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
|
||||||
|
const [workspaceTags, setWorkspaceTags] = useState<TagSummary[]>([]);
|
||||||
|
const [isLoadingTag, setIsLoadingTag] = useState(false);
|
||||||
|
|
||||||
const [docs, setDocs] = useState<Document[]>([]);
|
const [docs, setDocs] = useState<Document[]>([]);
|
||||||
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
||||||
@@ -281,6 +286,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
: prev
|
: prev
|
||||||
);
|
);
|
||||||
onStatsChange();
|
onStatsChange();
|
||||||
|
api.getWorkspaceTags().then(setWorkspaceTags).catch(() => {});
|
||||||
|
if (selectedTag) {
|
||||||
|
api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {});
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
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) => {
|
||||||
@@ -343,12 +352,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
const loadDocs = async (selectId?: string | null) => {
|
const loadDocs = async (selectId?: string | null) => {
|
||||||
try {
|
try {
|
||||||
const [list, serverFolders] = await Promise.all([
|
const [list, serverFolders, tags] = await Promise.all([
|
||||||
api.getDocuments(),
|
api.getDocuments(),
|
||||||
api.getFolders(),
|
api.getFolders(),
|
||||||
|
api.getWorkspaceTags().catch(() => []),
|
||||||
]);
|
]);
|
||||||
setDocs(list);
|
setDocs(list);
|
||||||
setCustomFolders(serverFolders);
|
setCustomFolders(serverFolders);
|
||||||
|
setWorkspaceTags(tags);
|
||||||
if (selectId) {
|
if (selectId) {
|
||||||
const found = list.find((d) => d.id === selectId);
|
const found = list.find((d) => d.id === selectId);
|
||||||
if (found) {
|
if (found) {
|
||||||
@@ -367,6 +378,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openTagPage = async (tag: string) => {
|
||||||
|
const clean = tag.replace(/^#/, '').toLowerCase();
|
||||||
|
setSelectedTag(clean);
|
||||||
|
setWorkspaceMode('tag');
|
||||||
|
setIsLoadingTag(true);
|
||||||
|
try {
|
||||||
|
const details = await api.getTagDetails(clean);
|
||||||
|
setTagDetails(details);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load tag details', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingTag(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDocs(initialDocId);
|
loadDocs(initialDocId);
|
||||||
}, [initialDocId]);
|
}, [initialDocId]);
|
||||||
@@ -1397,9 +1423,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to render text with clickable Wiki-Links
|
// Helper to render text with clickable Wiki-Links and Hashtags
|
||||||
const renderTextWithWikiLinks = (text: string) => {
|
const renderFormattedText = (text: string) => {
|
||||||
const parts = text.split(/(\[\[.*?\]\])/g);
|
const parts = text.split(/(\[\[.*?\]\]|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||||
return parts.map((part, i) => {
|
return parts.map((part, i) => {
|
||||||
if (part.startsWith('[[') && part.endsWith(']]')) {
|
if (part.startsWith('[[') && part.endsWith(']]')) {
|
||||||
const linkTitle = part.slice(2, -2).trim();
|
const linkTitle = part.slice(2, -2).trim();
|
||||||
@@ -1412,6 +1438,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
if (target) {
|
if (target) {
|
||||||
setSelectedDoc(target);
|
setSelectedDoc(target);
|
||||||
setZoomedBlockId(null);
|
setZoomedBlockId(null);
|
||||||
|
setWorkspaceMode('editor');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`inline-flex items-center gap-1 px-1.5 py-0.2 mx-0.5 border text-xs font-medium cursor-pointer rounded-btn transition-colors ${
|
className={`inline-flex items-center gap-1 px-1.5 py-0.2 mx-0.5 border text-xs font-medium cursor-pointer rounded-btn transition-colors ${
|
||||||
@@ -1427,6 +1454,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (part.startsWith('#') && part.length > 1) {
|
||||||
|
const tagWord = part.slice(1);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openTagPage(tagWord);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-0.5 px-1.5 py-0.2 mx-0.5 bg-blue-500/10 border border-blue-500/30 text-blue-500 dark:text-blue-400 hover:bg-blue-500/20 text-xs font-medium cursor-pointer rounded-btn transition-colors"
|
||||||
|
title={`Öffne Hashtag-Seite: #${tagWord}`}
|
||||||
|
>
|
||||||
|
<Hash className="w-2.5 h-2.5 opacity-80" />
|
||||||
|
<span>{tagWord}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
return <span key={i}>{part}</span>;
|
return <span key={i}>{part}</span>;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1496,6 +1540,29 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setSelectedDoc(updatedDoc);
|
setSelectedDoc(updatedDoc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also update tagDetails if currently viewing tag page
|
||||||
|
setTagDetails((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
documents: prev.documents.map((dg) => {
|
||||||
|
if (dg.docId !== docId) return dg;
|
||||||
|
return {
|
||||||
|
...dg,
|
||||||
|
matchedBlocks: dg.matchedBlocks.map((mb) => ({
|
||||||
|
rootBlock:
|
||||||
|
mb.rootBlock.id === blockId
|
||||||
|
? { ...mb.rootBlock, checked: !mb.rootBlock.checked }
|
||||||
|
: mb.rootBlock,
|
||||||
|
children: mb.children.map((c) =>
|
||||||
|
c.id === blockId ? { ...c, checked: !c.checked } : c
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Persist immediately to backend & disk
|
// Persist immediately to backend & disk
|
||||||
await api.updateDocument(docId, {
|
await api.updateDocument(docId, {
|
||||||
blocks: updatedBlocks,
|
blocks: updatedBlocks,
|
||||||
@@ -1823,6 +1890,26 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{allWorkspaceTodos.filter((t) => !t.block.checked).length}
|
{allWorkspaceTodos.filter((t) => !t.block.checked).length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{selectedTag && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWorkspaceMode('tag')}
|
||||||
|
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
|
||||||
|
workspaceMode === 'tag'
|
||||||
|
? 'bg-panel border-accent text-main'
|
||||||
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Hash className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>#{selectedTag}</span>
|
||||||
|
{tagDetails && (
|
||||||
|
<span className="badge-neutral font-mono text-[10px]">
|
||||||
|
{tagDetails.totalMatches}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted">
|
<div className="flex items-center gap-2 text-xs text-muted">
|
||||||
@@ -2047,6 +2134,37 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
{rootTree.map(renderFolder)}
|
{rootTree.map(renderFolder)}
|
||||||
{rootDocs.map((doc) => renderDocItem(doc, 0))}
|
{rootDocs.map((doc) => renderDocItem(doc, 0))}
|
||||||
|
|
||||||
|
{/* Workspace Hashtags Section */}
|
||||||
|
{workspaceTags.length > 0 && !searchQuery && (
|
||||||
|
<div className="mt-3 pt-2.5 border-t border-border">
|
||||||
|
<div className="px-2.5 py-1 text-[10px] font-bold text-muted uppercase tracking-wider flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5 text-accent">
|
||||||
|
<Hash className="w-3 h-3" />
|
||||||
|
<span>Themen & Hashtags ({workspaceTags.length})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1 px-1.5 py-1">
|
||||||
|
{workspaceTags.map((wt) => (
|
||||||
|
<button
|
||||||
|
key={wt.tag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => openTagPage(wt.tag)}
|
||||||
|
className={`px-2 py-0.5 rounded-pill text-[11px] font-medium border flex items-center gap-1 transition-colors ${
|
||||||
|
selectedTag === wt.tag
|
||||||
|
? 'bg-accent/20 border-accent text-accent font-semibold'
|
||||||
|
: 'bg-canvas border-border text-muted hover:border-accent/40 hover:text-main'
|
||||||
|
}`}
|
||||||
|
title={`#${wt.tag} (${wt.count} Abschnitte)`}
|
||||||
|
>
|
||||||
|
<span>#{wt.tag}</span>
|
||||||
|
<span className="text-[9px] opacity-60">({wt.count})</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{filteredDocs.length === 0 && searchQuery && (
|
{filteredDocs.length === 0 && searchQuery && (
|
||||||
<div className="p-4 text-center text-xs text-faint">
|
<div className="p-4 text-center text-xs text-faint">
|
||||||
Keine Dateien gefunden für "{searchQuery}"
|
Keine Dateien gefunden für "{searchQuery}"
|
||||||
@@ -2430,6 +2548,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{(block.content.includes('[[') || block.content.includes('#')) && (
|
||||||
|
<div className="text-xs text-muted flex flex-wrap items-center gap-1 pt-0.5 pl-6">
|
||||||
|
{renderFormattedText(block.content)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Progress bar for child todos if any exist */}
|
{/* Progress bar for child todos if any exist */}
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -2631,9 +2754,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
isReadOnly ? 'cursor-default' : ''
|
isReadOnly ? 'cursor-default' : ''
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{block.content.includes('[[') && (
|
{(block.content.includes('[[') || block.content.includes('#')) && (
|
||||||
<div className="text-xs text-muted">
|
<div className="text-xs text-muted flex flex-wrap items-center gap-1 pt-0.5">
|
||||||
{renderTextWithWikiLinks(block.content)}
|
{renderFormattedText(block.content)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2898,6 +3021,217 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ===================================================================== */}
|
||||||
|
{/* VIEW MODE 3: HASHTAG / THEMEN-SEITE (LOGSEQ STYLE) */}
|
||||||
|
{/* ===================================================================== */}
|
||||||
|
{workspaceMode === 'tag' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Tag Page Header */}
|
||||||
|
<div className="p-4 panel-surface space-y-2 border border-border rounded-card">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWorkspaceMode('editor')}
|
||||||
|
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
|
||||||
|
title="Zurück zum Editor"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>Notizen</span>
|
||||||
|
</button>
|
||||||
|
<div className="h-4 w-px bg-border" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-500 dark:text-blue-400 font-bold">
|
||||||
|
<Hash className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-main leading-tight flex items-center gap-1.5">
|
||||||
|
<span>#{selectedTag}</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-[11px] text-muted">
|
||||||
|
{isLoadingTag
|
||||||
|
? 'Wird geladen…'
|
||||||
|
: `${tagDetails?.totalMatches || 0} ${
|
||||||
|
tagDetails?.totalMatches === 1 ? 'Abschnitt' : 'Abschnitte'
|
||||||
|
} in ${tagDetails?.documents.length || 0} ${
|
||||||
|
tagDetails?.documents.length === 1 ? 'Notiz' : 'Notizen'
|
||||||
|
}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Other tags pills for fast hopping */}
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{workspaceTags
|
||||||
|
.filter((t) => t.tag !== selectedTag)
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.tag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => openTagPage(t.tag)}
|
||||||
|
className="px-2 py-0.5 rounded-pill text-[11px] badge-neutral hover:border-accent/40 hover:text-main transition-colors"
|
||||||
|
>
|
||||||
|
#{t.tag} ({t.count})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Matched Documents List */}
|
||||||
|
{isLoadingTag ? (
|
||||||
|
<div className="p-12 text-center text-muted text-xs panel-surface">
|
||||||
|
<Sparkles className="w-6 h-6 animate-spin mx-auto mb-2 text-accent" />
|
||||||
|
Hashtag-Verknüpfungen werden geladen…
|
||||||
|
</div>
|
||||||
|
) : !tagDetails || tagDetails.documents.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-muted text-xs panel-surface space-y-2">
|
||||||
|
<Hash className="w-8 h-8 mx-auto text-faint" />
|
||||||
|
<p className="font-medium text-main">Keine Bullet Points mit #{selectedTag} gefunden</p>
|
||||||
|
<p className="text-xs text-faint">
|
||||||
|
Füge einfach in irgendeiner Notiz <code className="text-accent">#{selectedTag}</code> in einen Bullet Point ein.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{tagDetails.documents.map((docGroup) => (
|
||||||
|
<div
|
||||||
|
key={docGroup.docId}
|
||||||
|
className="panel-surface p-4 space-y-3 border border-border rounded-card hover:border-border/80 transition-colors"
|
||||||
|
>
|
||||||
|
{/* Document Header */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 pb-2 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-base flex-shrink-0">{docGroup.icon || '📄'}</span>
|
||||||
|
<span className="font-semibold text-xs text-main truncate">{docGroup.title || 'Unbenannt'}</span>
|
||||||
|
{docGroup.folderPath && (
|
||||||
|
<span className="badge-neutral text-[10px] flex items-center gap-1 font-mono">
|
||||||
|
<Folder className="w-2.5 h-2.5" />
|
||||||
|
<span>{docGroup.folderPath}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-faint">
|
||||||
|
• {docGroup.matchedBlocks.length}{' '}
|
||||||
|
{docGroup.matchedBlocks.length === 1 ? 'Treffer' : 'Treffer'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const found = docs.find((d) => d.id === docGroup.docId);
|
||||||
|
if (found) {
|
||||||
|
setSelectedDoc(found);
|
||||||
|
setZoomedBlockId(null);
|
||||||
|
setWorkspaceMode('editor');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 text-xs font-medium text-accent hover:text-main bg-accent/10 hover:bg-accent/20 rounded-btn flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<span>Notiz öffnen</span>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Matched Bullet Points and their children */}
|
||||||
|
<div className="space-y-3 pl-1">
|
||||||
|
{docGroup.matchedBlocks.map((mb, idx) => (
|
||||||
|
<div
|
||||||
|
key={mb.rootBlock.id || idx}
|
||||||
|
className="space-y-1.5 bg-canvas/40 p-3 rounded-card border border-border/50"
|
||||||
|
>
|
||||||
|
{/* Root Bullet Point */}
|
||||||
|
<div className="flex items-start gap-2 group">
|
||||||
|
{mb.rootBlock.type === 'todo' ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!mb.rootBlock.checked}
|
||||||
|
onChange={() => handleToggleRollupTodo(docGroup.docId, mb.rootBlock.id)}
|
||||||
|
className="w-4 h-4 mt-0.5 checkbox-square cursor-pointer flex-shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-3.5 h-3.5 mt-1 rounded-full bg-accent/20 border border-accent flex items-center justify-center flex-shrink-0">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
const found = docs.find((d) => d.id === docGroup.docId);
|
||||||
|
if (found) {
|
||||||
|
setSelectedDoc(found);
|
||||||
|
setZoomedBlockId(mb.rootBlock.id);
|
||||||
|
setWorkspaceMode('editor');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${
|
||||||
|
mb.rootBlock.checked ? 'line-through text-faint' : 'text-main'
|
||||||
|
}`}
|
||||||
|
title="Klick: Zu diesem Bullet Point in der Notiz zoomen"
|
||||||
|
>
|
||||||
|
{renderFormattedText(mb.rootBlock.content || '(Leerer Bullet Point)')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Children / Sub-bullets */}
|
||||||
|
{mb.children.length > 0 && (
|
||||||
|
<div className="space-y-1 mt-1 pl-4 border-l-2 border-border/60 ml-2">
|
||||||
|
{mb.children.map((child) => {
|
||||||
|
const indentLevel = Math.max(
|
||||||
|
0,
|
||||||
|
(child.level || 0) - (mb.rootBlock.level || 0) - 1
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={child.id}
|
||||||
|
style={{ paddingLeft: `${indentLevel * 14}px` }}
|
||||||
|
className="flex items-start gap-2 py-0.5 text-xs text-muted"
|
||||||
|
>
|
||||||
|
{child.type === 'todo' ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!child.checked}
|
||||||
|
onChange={() =>
|
||||||
|
handleToggleRollupTodo(docGroup.docId, child.id)
|
||||||
|
}
|
||||||
|
className="w-3.5 h-3.5 mt-0.5 checkbox-square cursor-pointer flex-shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="w-1.5 h-1.5 mt-1.5 rounded-full bg-muted/60 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
const found = docs.find((d) => d.id === docGroup.docId);
|
||||||
|
if (found) {
|
||||||
|
setSelectedDoc(found);
|
||||||
|
setZoomedBlockId(child.id);
|
||||||
|
setWorkspaceMode('editor');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${
|
||||||
|
child.checked ? 'line-through text-faint' : 'text-muted'
|
||||||
|
}`}
|
||||||
|
title="Klick: In Notiz fokussieren"
|
||||||
|
>
|
||||||
|
{renderFormattedText(child.content || '')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ===================================================================== */}
|
{/* ===================================================================== */}
|
||||||
{/* DOCUMENT SHARING MODAL */}
|
{/* DOCUMENT SHARING MODAL */}
|
||||||
{/* ===================================================================== */}
|
{/* ===================================================================== */}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
TaskList,
|
TaskList,
|
||||||
Thought,
|
Thought,
|
||||||
SubTask,
|
SubTask,
|
||||||
|
TagDetails,
|
||||||
|
TagSummary,
|
||||||
User,
|
User,
|
||||||
UserInvite,
|
UserInvite,
|
||||||
WSMessage,
|
WSMessage,
|
||||||
@@ -388,6 +390,27 @@ export const api = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- WORKSPACE TAGS ---
|
||||||
|
async getWorkspaceTags(): Promise<TagSummary[]> {
|
||||||
|
const res = await request<TagSummary[]>('/api/v1/workspace/tags');
|
||||||
|
return ensureArray(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTagDetails(tag: string): Promise<TagDetails> {
|
||||||
|
const clean = encodeURIComponent(tag.replace(/^#/, ''));
|
||||||
|
const res = await request<TagDetails>(`/api/v1/workspace/tags/${clean}`);
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
documents: ensureArray(res.documents).map((doc) => ({
|
||||||
|
...doc,
|
||||||
|
matchedBlocks: ensureArray(doc.matchedBlocks).map((mb) => ({
|
||||||
|
rootBlock: mb.rootBlock,
|
||||||
|
children: ensureArray(mb.children),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
// --- WORKSPACE DOCUMENTS ---
|
// --- WORKSPACE DOCUMENTS ---
|
||||||
async getDocuments(): Promise<Document[]> {
|
async getDocuments(): Promise<Document[]> {
|
||||||
const res = await request<Document[]>('/api/v1/documents');
|
const res = await request<Document[]>('/api/v1/documents');
|
||||||
|
|||||||
@@ -170,6 +170,34 @@ export interface Document {
|
|||||||
sharedWith?: DocShare[];
|
sharedWith?: DocShare[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TagSummary {
|
||||||
|
tag: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchedTagBlock {
|
||||||
|
rootBlock: DocumentBlock;
|
||||||
|
children: DocumentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagDocumentGroup {
|
||||||
|
docId: string;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
folderPath: string;
|
||||||
|
updatedAt: string;
|
||||||
|
isOwner: boolean;
|
||||||
|
permission: 'owner' | 'write' | 'read';
|
||||||
|
ownerName?: string;
|
||||||
|
matchedBlocks: MatchedTagBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagDetails {
|
||||||
|
tag: string;
|
||||||
|
documents: TagDocumentGroup[];
|
||||||
|
totalMatches: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SpaceMember {
|
export interface SpaceMember {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ 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)
|
||||||
|
|
||||||
|
// Workspace Tags & Pages
|
||||||
|
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
|
||||||
|
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
|
||||||
|
|
||||||
r.Get("/api/v1/documents", s.handleGetDocuments)
|
r.Get("/api/v1/documents", s.handleGetDocuments)
|
||||||
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)
|
||||||
@@ -871,6 +875,29 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- WORKSPACE TAGS & PAGES ---
|
||||||
|
|
||||||
|
func (s *Server) handleGetWorkspaceTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
tags, err := s.store.GetWorkspaceTags(userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetTagDetails(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
tag := chi.URLParam(r, "tag")
|
||||||
|
details, err := s.store.GetTagDetails(userID, tag)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, details)
|
||||||
|
}
|
||||||
|
|
||||||
// --- DOCUMENT SHARING & COLLABORATION ---
|
// --- DOCUMENT SHARING & COLLABORATION ---
|
||||||
|
|
||||||
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -619,3 +619,95 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
|||||||
t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceTagsAPI(t *testing.T) {
|
||||||
|
srv, store, _ := setupTestServer(t)
|
||||||
|
router := srv.Router()
|
||||||
|
|
||||||
|
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
|
||||||
|
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||||
|
|
||||||
|
// Create document with hashtags
|
||||||
|
blocks := []domain.DocumentBlock{
|
||||||
|
{ID: "b1", Type: "bullet", Content: "Planung #Sprint12", Level: 0},
|
||||||
|
{ID: "b2", Type: "bullet", Content: "Subtask 1", Level: 1},
|
||||||
|
}
|
||||||
|
_, err := store.CreateDocument(user.ID, "", "Sprint Plan", "🚀", "", blocks, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDocument failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. GET /api/v1/workspace/tags
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/workspace/tags", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 OK, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var tagList []domain.TagSummary
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &tagList)
|
||||||
|
if len(tagList) != 1 || tagList[0].Tag != "sprint12" {
|
||||||
|
t.Fatalf("expected tag 'sprint12', got %+v", tagList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. GET /api/v1/workspace/tags/Sprint12
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/workspace/tags/Sprint12", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 OK, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var details domain.TagDetails
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &details)
|
||||||
|
if details.Tag != "sprint12" || details.TotalMatches != 1 || len(details.Documents) != 1 {
|
||||||
|
t.Fatalf("unexpected tag details: %+v", details)
|
||||||
|
}
|
||||||
|
matched := details.Documents[0].MatchedBlocks[0]
|
||||||
|
if matched.RootBlock.ID != "b1" || len(matched.Children) != 1 || matched.Children[0].ID != "b2" {
|
||||||
|
t.Fatalf("unexpected matched blocks or children: %+v", matched)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Folder endpoints test
|
||||||
|
// Create folder
|
||||||
|
createFolderBody := []byte(`{"path":"TestOrdner"}`)
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/folders", bytes.NewReader(createFolderBody))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201 Created from /api/v1/folders, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get folders
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/folders", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 OK from /api/v1/folders, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
var folders []string
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &folders)
|
||||||
|
foundFolder := false
|
||||||
|
for _, f := range folders {
|
||||||
|
if f == "TestOrdner" {
|
||||||
|
foundFolder = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundFolder {
|
||||||
|
t.Fatalf("expected TestOrdner in folders list, got %v", folders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete folder
|
||||||
|
req = httptest.NewRequest(http.MethodDelete, "/api/v1/folders?path=TestOrdner", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 OK from DELETE /api/v1/folders, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -262,6 +262,34 @@ type Document struct {
|
|||||||
SharedWith []DocShare `json:"sharedWith,omitempty"`
|
SharedWith []DocShare `json:"sharedWith,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TagSummary struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchedTagBlock struct {
|
||||||
|
RootBlock DocumentBlock `json:"rootBlock"`
|
||||||
|
Children []DocumentBlock `json:"children"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagDocumentGroup struct {
|
||||||
|
DocID string `json:"docId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
FolderPath string `json:"folderPath"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
IsOwner bool `json:"isOwner"`
|
||||||
|
Permission string `json:"permission"`
|
||||||
|
OwnerName string `json:"ownerName,omitempty"`
|
||||||
|
MatchedBlocks []MatchedTagBlock `json:"matchedBlocks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagDetails struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Documents []TagDocumentGroup `json:"documents"`
|
||||||
|
TotalMatches int `json:"totalMatches"`
|
||||||
|
}
|
||||||
|
|
||||||
type SpaceMember struct {
|
type SpaceMember struct {
|
||||||
SpaceID string `json:"spaceId"`
|
SpaceID string `json:"spaceId"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
|
|||||||
+247
-1
@@ -231,6 +231,22 @@ func (s *Store) migrate() error {
|
|||||||
INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at)
|
INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at)
|
||||||
SELECT user_id, id, updated_at FROM documents WHERE is_pinned = 1;
|
SELECT user_id, id, updated_at FROM documents WHERE is_pinned = 1;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS doc_block_tags (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
doc_id TEXT NOT NULL,
|
||||||
|
block_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(doc_id, block_id, tag),
|
||||||
|
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_tag ON doc_block_tags(tag);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_user_tag ON doc_block_tags(user_id, tag);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_doc ON doc_block_tags(doc_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS lists (
|
CREATE TABLE IF NOT EXISTS lists (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
space_id TEXT NOT NULL,
|
space_id TEXT NOT NULL,
|
||||||
@@ -325,6 +341,13 @@ func (s *Store) migrate() error {
|
|||||||
('smtp_from', ''),
|
('smtp_from', ''),
|
||||||
('smtp_tls', 'true')`)
|
('smtp_tls', 'true')`)
|
||||||
|
|
||||||
|
// Reindex block tags if table is newly added or empty
|
||||||
|
var tagCount int
|
||||||
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM doc_block_tags`).Scan(&tagCount)
|
||||||
|
if tagCount == 0 {
|
||||||
|
_ = s.reindexAllDocBlockTags()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1184,6 +1207,7 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string,
|
|||||||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, doc.ID, doc.CreatedAt)
|
_, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, doc.ID, doc.CreatedAt)
|
||||||
}
|
}
|
||||||
s.saveDocumentToDisk(doc)
|
s.saveDocumentToDisk(doc)
|
||||||
|
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
|
||||||
return doc, nil
|
return doc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1417,6 +1441,9 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.saveDocumentToDisk(doc)
|
s.saveDocumentToDisk(doc)
|
||||||
|
if blocks != nil {
|
||||||
|
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
|
||||||
|
}
|
||||||
return doc, nil
|
return doc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1432,10 +1459,228 @@ func (s *Store) DeleteDocument(userID, docID string) error {
|
|||||||
if doc != nil {
|
if doc != nil {
|
||||||
s.removeDocumentFromDisk(doc)
|
s.removeDocumentFromDisk(doc)
|
||||||
}
|
}
|
||||||
|
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID)
|
||||||
_, err = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, docID, userID)
|
_, err = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, docID, userID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) reindexAllDocBlockTags() error {
|
||||||
|
rows, err := s.db.Query(`SELECT id, user_id, blocks_json FROM documents WHERE is_archived = 0`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type docInfo struct {
|
||||||
|
id, userID, blocksJSON string
|
||||||
|
}
|
||||||
|
var docs []docInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var d docInfo
|
||||||
|
if err := rows.Scan(&d.id, &d.userID, &d.blocksJSON); err == nil {
|
||||||
|
docs = append(docs, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
|
||||||
|
for _, d := range docs {
|
||||||
|
var blocks []domain.DocumentBlock
|
||||||
|
if err := json.Unmarshal([]byte(d.blocksJSON), &blocks); err == nil && len(blocks) > 0 {
|
||||||
|
_ = s.indexDocBlockTags(d.id, d.userID, blocks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) indexDocBlockTags(docID, userID string, blocks []domain.DocumentBlock) error {
|
||||||
|
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID)
|
||||||
|
if len(blocks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO doc_block_tags (id, tag, user_id, doc_id, block_id, created_at) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
now := domain.NowISO()
|
||||||
|
for _, b := range blocks {
|
||||||
|
if b.Content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tags := domain.ExtractTags(b.Content)
|
||||||
|
for _, tag := range tags {
|
||||||
|
tagID := domain.NewID("dbtag")
|
||||||
|
_, _ = stmt.Exec(tagID, tag, userID, docID, b.ID, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT t.tag, COUNT(DISTINCT t.doc_id || ':' || t.block_id) as count
|
||||||
|
FROM doc_block_tags t
|
||||||
|
JOIN documents d ON t.doc_id = d.id
|
||||||
|
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
||||||
|
WHERE (d.user_id = ? OR s.user_id = ?)
|
||||||
|
AND d.is_archived = 0
|
||||||
|
GROUP BY t.tag
|
||||||
|
ORDER BY count DESC, t.tag ASC`,
|
||||||
|
userID, userID, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
results := make([]domain.TagSummary, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var ts domain.TagSummary
|
||||||
|
if err := rows.Scan(&ts.Tag, &ts.Count); err == nil {
|
||||||
|
results = append(results, ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
|
||||||
|
cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#"))
|
||||||
|
if cleanTag == "" {
|
||||||
|
return &domain.TagDetails{Tag: "", Documents: []domain.TagDocumentGroup{}, TotalMatches: 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
|
||||||
|
COALESCE(u.display_name, '') as owner_name,
|
||||||
|
COALESCE(s.permission, '') as shared_permission
|
||||||
|
FROM doc_block_tags t
|
||||||
|
JOIN documents d ON t.doc_id = d.id
|
||||||
|
LEFT JOIN users u ON d.user_id = u.id
|
||||||
|
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
||||||
|
WHERE (d.user_id = ? OR s.user_id = ?)
|
||||||
|
AND t.tag = ?
|
||||||
|
AND d.is_archived = 0
|
||||||
|
ORDER BY d.updated_at DESC`,
|
||||||
|
userID, userID, userID, cleanTag,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type rawMatch struct {
|
||||||
|
blockID string
|
||||||
|
docID string
|
||||||
|
title string
|
||||||
|
icon string
|
||||||
|
folderPath string
|
||||||
|
blocksJSON string
|
||||||
|
updatedAt string
|
||||||
|
docUserID string
|
||||||
|
ownerName string
|
||||||
|
sharedPermission string
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawMatches []rawMatch
|
||||||
|
for rows.Next() {
|
||||||
|
var rm rawMatch
|
||||||
|
if err := rows.Scan(&rm.blockID, &rm.docID, &rm.title, &rm.icon, &rm.folderPath, &rm.blocksJSON, &rm.updatedAt, &rm.docUserID, &rm.ownerName, &rm.sharedPermission); err == nil {
|
||||||
|
rawMatches = append(rawMatches, rm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
|
||||||
|
// Group rawMatches by docID
|
||||||
|
docMap := make(map[string][]rawMatch)
|
||||||
|
var docOrder []string
|
||||||
|
for _, rm := range rawMatches {
|
||||||
|
if _, exists := docMap[rm.docID]; !exists {
|
||||||
|
docOrder = append(docOrder, rm.docID)
|
||||||
|
}
|
||||||
|
docMap[rm.docID] = append(docMap[rm.docID], rm)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalMatches := 0
|
||||||
|
docGroups := make([]domain.TagDocumentGroup, 0, len(docOrder))
|
||||||
|
|
||||||
|
for _, docID := range docOrder {
|
||||||
|
matches := docMap[docID]
|
||||||
|
first := matches[0]
|
||||||
|
|
||||||
|
var blocks []domain.DocumentBlock
|
||||||
|
_ = json.Unmarshal([]byte(first.blocksJSON), &blocks)
|
||||||
|
|
||||||
|
blockIdxMap := make(map[string]int, len(blocks))
|
||||||
|
for i, b := range blocks {
|
||||||
|
blockIdxMap[b.ID] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedBlocks := make([]domain.MatchedTagBlock, 0)
|
||||||
|
processedBlockIDs := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, m := range matches {
|
||||||
|
if processedBlockIDs[m.blockID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx, ok := blockIdxMap[m.blockID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rootBlock := blocks[idx]
|
||||||
|
processedBlockIDs[rootBlock.ID] = true
|
||||||
|
|
||||||
|
children := make([]domain.DocumentBlock, 0)
|
||||||
|
rootLevel := rootBlock.Level
|
||||||
|
for i := idx + 1; i < len(blocks); i++ {
|
||||||
|
if blocks[i].Level > rootLevel {
|
||||||
|
children = append(children, blocks[i])
|
||||||
|
processedBlockIDs[blocks[i].ID] = true
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedBlocks = append(matchedBlocks, domain.MatchedTagBlock{
|
||||||
|
RootBlock: rootBlock,
|
||||||
|
Children: children,
|
||||||
|
})
|
||||||
|
totalMatches++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matchedBlocks) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isOwner := first.docUserID == userID
|
||||||
|
permission := "read"
|
||||||
|
if isOwner {
|
||||||
|
permission = "owner"
|
||||||
|
} else if first.sharedPermission != "" {
|
||||||
|
permission = first.sharedPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
docGroups = append(docGroups, domain.TagDocumentGroup{
|
||||||
|
DocID: first.docID,
|
||||||
|
Title: first.title,
|
||||||
|
Icon: first.icon,
|
||||||
|
FolderPath: first.folderPath,
|
||||||
|
UpdatedAt: first.updatedAt,
|
||||||
|
IsOwner: isOwner,
|
||||||
|
Permission: permission,
|
||||||
|
OwnerName: first.ownerName,
|
||||||
|
MatchedBlocks: matchedBlocks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.TagDetails{
|
||||||
|
Tag: cleanTag,
|
||||||
|
Documents: docGroups,
|
||||||
|
TotalMatches: totalMatches,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -1549,8 +1794,9 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
_ = rows.Close()
|
_ = rows.Close()
|
||||||
|
|
||||||
// Delete from SQLite, pins, shares
|
// Delete from SQLite, pins, shares, tags
|
||||||
for _, id := range deletedDocIDs {
|
for _, id := range deletedDocIDs {
|
||||||
|
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, id)
|
||||||
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, id)
|
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, id)
|
||||||
_, _ = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ?`, id)
|
_, _ = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ?`, id)
|
||||||
_, _ = 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)
|
||||||
|
|||||||
@@ -398,3 +398,107 @@ func TestFolderManagementAndDeletion(t *testing.T) {
|
|||||||
t.Fatalf("expected directory %s to be deleted on disk", diskFolder)
|
t.Fatalf("expected directory %s to be deleted on disk", diskFolder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(tmpDir, "tags_test.db")
|
||||||
|
|
||||||
|
store, err := NewStore(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore failed: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
user, err := store.CreateUser("taguser@example.com", "hash", "Tag User", "member")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create document with nested bullet points and hashtags
|
||||||
|
blocks := []domain.DocumentBlock{
|
||||||
|
{ID: "b0", Type: "bullet", Content: "Einleitung ohne Tag", Level: 0},
|
||||||
|
{ID: "b1", Type: "bullet", Content: "Einkauf planen #Kühlregal #Wocheneinkauf", Level: 0},
|
||||||
|
{ID: "b2", Type: "bullet", Content: "Milch 3.8%", Level: 1},
|
||||||
|
{ID: "b3", Type: "bullet", Content: "Gouda alt", Level: 1},
|
||||||
|
{ID: "b4", Type: "bullet", Content: "Zusatzinfo: Aus Weidemilch", Level: 2},
|
||||||
|
{ID: "b5", Type: "bullet", Content: "Anderes Thema ohne Bezug", Level: 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := store.CreateDocument(user.ID, "", "Einkaufszettel", "🛒", "Haushalt", blocks, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDocument failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check GetWorkspaceTags
|
||||||
|
tags, err := store.GetWorkspaceTags(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetWorkspaceTags failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(tags) != 2 {
|
||||||
|
t.Fatalf("expected 2 tags, got %d (%v)", len(tags), tags)
|
||||||
|
}
|
||||||
|
tagMap := make(map[string]int)
|
||||||
|
for _, tg := range tags {
|
||||||
|
tagMap[tg.Tag] = tg.Count
|
||||||
|
}
|
||||||
|
if tagMap["kühlregal"] != 1 || tagMap["wocheneinkauf"] != 1 {
|
||||||
|
t.Fatalf("unexpected tag counts: %v", tagMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check GetTagDetails for #Kühlregal
|
||||||
|
details, err := store.GetTagDetails(user.ID, "#Kühlregal")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTagDetails failed: %v", err)
|
||||||
|
}
|
||||||
|
if details.Tag != "kühlregal" {
|
||||||
|
t.Fatalf("expected tag 'kühlregal', got %s", details.Tag)
|
||||||
|
}
|
||||||
|
if details.TotalMatches != 1 {
|
||||||
|
t.Fatalf("expected 1 match, got %d", details.TotalMatches)
|
||||||
|
}
|
||||||
|
if len(details.Documents) != 1 {
|
||||||
|
t.Fatalf("expected 1 document, got %d", len(details.Documents))
|
||||||
|
}
|
||||||
|
docGroup := details.Documents[0]
|
||||||
|
if docGroup.DocID != doc.ID || docGroup.Title != "Einkaufszettel" {
|
||||||
|
t.Fatalf("unexpected doc group: %+v", docGroup)
|
||||||
|
}
|
||||||
|
if len(docGroup.MatchedBlocks) != 1 {
|
||||||
|
t.Fatalf("expected 1 matched block, got %d", len(docGroup.MatchedBlocks))
|
||||||
|
}
|
||||||
|
matched := docGroup.MatchedBlocks[0]
|
||||||
|
if matched.RootBlock.ID != "b1" {
|
||||||
|
t.Fatalf("expected root block b1, got %s", matched.RootBlock.ID)
|
||||||
|
}
|
||||||
|
// Children should be b2, b3, b4 (all subsequent blocks with Level > 0 until b5 where Level == 0)
|
||||||
|
if len(matched.Children) != 3 {
|
||||||
|
t.Fatalf("expected 3 children (b2, b3, b4), got %d (%v)", len(matched.Children), matched.Children)
|
||||||
|
}
|
||||||
|
if matched.Children[0].ID != "b2" || matched.Children[1].ID != "b3" || matched.Children[2].ID != "b4" {
|
||||||
|
t.Fatalf("unexpected children IDs: %v", matched.Children)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update document: remove #Wocheneinkauf, add #Vorrat
|
||||||
|
updatedBlocks := []domain.DocumentBlock{
|
||||||
|
{ID: "b1", Type: "bullet", Content: "Einkauf planen #Kühlregal #Vorrat", Level: 0},
|
||||||
|
}
|
||||||
|
_, err = store.UpdateDocument(user.ID, doc.ID, nil, nil, nil, &updatedBlocks, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateDocument failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsAfter, err := store.GetWorkspaceTags(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetWorkspaceTags after update failed: %v", err)
|
||||||
|
}
|
||||||
|
tagMapAfter := make(map[string]int)
|
||||||
|
for _, tg := range tagsAfter {
|
||||||
|
tagMapAfter[tg.Tag] = tg.Count
|
||||||
|
}
|
||||||
|
if tagMapAfter["wocheneinkauf"] != 0 {
|
||||||
|
t.Fatalf("expected wocheneinkauf to be removed, got %v", tagMapAfter)
|
||||||
|
}
|
||||||
|
if tagMapAfter["vorrat"] != 1 || tagMapAfter["kühlregal"] != 1 {
|
||||||
|
t.Fatalf("expected vorrat and kühlregal, got %v", tagMapAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user