From 633bd6f1ef19baab8465cf7ea3cb7a554e7209f5 Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 28 Oct 2026 22:44:21 +0100 Subject: [PATCH 1/8] =?UTF-8?q?Paket=20A:=20Vollst=C3=A4ndige=20Emoji-Ausw?= =?UTF-8?q?ahl=20f=C3=BCr=20Notiz-Icons=20mit=20Lazy-Loading=20und=20Mobil?= =?UTF-8?q?e=20Bottom-Sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 22 ++ frontend/package.json | 1 + .../src/features/workspace/DocumentEditor.tsx | 138 +---------- .../features/workspace/EmojiPickerModal.tsx | 226 ++++++++++++++++++ frontend/src/index.css | 27 +++ 5 files changed, 282 insertions(+), 132 deletions(-) create mode 100644 frontend/src/features/workspace/EmojiPickerModal.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4154551..c0f45f8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.0", "dependencies": { "@types/prismjs": "^1.26.6", + "emoji-picker-react": "^4.12.0", "framer-motion": "^11.15.0", "lucide-react": "^0.468.0", "prismjs": "^1.30.0", @@ -2101,6 +2102,21 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-picker-react": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.12.0.tgz", + "integrity": "sha512-q2c8UcZH0eRIMj41bj0k1akTjk69tsu+E7EzkW7giN66iltF6H9LQvQvw6ugscsxdC+1lmt3WZpQkkY65J95tg==", + "license": "MIT", + "dependencies": { + "flairup": "1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -2302,6 +2318,12 @@ "node": ">=8" } }, + "node_modules/flairup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", + "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", + "license": "MIT" + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 935837f..2d22bc8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@types/prismjs": "^1.26.6", + "emoji-picker-react": "4.12.0", "framer-motion": "^11.15.0", "lucide-react": "^0.468.0", "prismjs": "^1.30.0", diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index b88c4e8..19d97ae 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -44,63 +44,7 @@ export interface DocumentEditorProps { onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; } -interface EmojiItem { - emoji: string; - category: string; - keywords: string; -} - -const EMOJI_CATALOG: EmojiItem[] = [ - // Notizen & Dokumente - { emoji: '📝', category: 'Notizen', keywords: 'notiz schreiben memo text note' }, - { emoji: '📄', category: 'Notizen', keywords: 'dokument seite page doc' }, - { emoji: '📋', category: 'Notizen', keywords: 'clipboard klemmbrett liste' }, - { emoji: '📂', category: 'Notizen', keywords: 'ordner mappe folder datei' }, - { emoji: '📁', category: 'Notizen', keywords: 'ordner folder directory' }, - { emoji: '📌', category: 'Notizen', keywords: 'pin anheften bookmark wichtig' }, - { emoji: '📍', category: 'Notizen', keywords: 'standort ort pin location' }, - { emoji: '🏷️', category: 'Notizen', keywords: 'tag etikett label markierung' }, - { emoji: '📚', category: 'Notizen', keywords: 'buch bibliothek lesen studium' }, - { emoji: '📖', category: 'Notizen', keywords: 'buch open book wissen' }, - - // Ideen & Fokus - { emoji: '🧠', category: 'Ideen', keywords: 'gehirn brain denken wissen thought mind' }, - { emoji: '💡', category: 'Ideen', keywords: 'idee inspiration lampe glühbirne idea' }, - { emoji: '🎯', category: 'Ideen', keywords: 'ziel target fokus mission okr' }, - { emoji: '🚀', category: 'Ideen', keywords: 'rakete start launch projekt rocket' }, - { emoji: '⚡', category: 'Ideen', keywords: 'blitz energie schnell quick power' }, - { emoji: '🔥', category: 'Ideen', keywords: 'feuer flame priorität hot wichtig' }, - { emoji: '⭐', category: 'Ideen', keywords: 'stern favoriten star top' }, - { emoji: '✨', category: 'Ideen', keywords: 'funkeln magie neu neuigkeit sparkles' }, - { emoji: '🧩', category: 'Ideen', keywords: 'puzzle teil lösung problem' }, - { emoji: '🔬', category: 'Ideen', keywords: 'mikroskop forschung analyse test' }, - - // Tech & Dev - { emoji: '💻', category: 'Tech', keywords: 'laptop computer dev code programming' }, - { emoji: '🖥️', category: 'Tech', keywords: 'desktop monitor bildschirm workstation' }, - { emoji: '🛠️', category: 'Tech', keywords: 'werkzeug tools optionen settings einstellungen' }, - { emoji: '⚙️', category: 'Tech', keywords: 'zahnrad config settings system' }, - { emoji: '📊', category: 'Tech', keywords: 'diagramm chart daten statistik dashboard' }, - { emoji: '📈', category: 'Tech', keywords: 'wachstum trend kurve business' }, - { emoji: '🔒', category: 'Tech', keywords: 'schloss sicherheit geheim privat secure' }, - { emoji: '🔑', category: 'Tech', keywords: 'schlüssel key passwort access' }, - { emoji: '🌐', category: 'Tech', keywords: 'web internet network welt globus' }, - { emoji: '📦', category: 'Tech', keywords: 'paket box release lieferung package' }, - - // Alltag & Organisation - { emoji: '💬', category: 'Alltag', keywords: 'chat nachricht kommentar call sprechen' }, - { emoji: '✉️', category: 'Alltag', keywords: 'mail email brief post nachricht' }, - { emoji: '📅', category: 'Alltag', keywords: 'kalender datum termin plan event' }, - { emoji: '⏰', category: 'Alltag', keywords: 'wecker uhr zeit deadline alarm' }, - { emoji: '🛒', category: 'Alltag', keywords: 'warenkorb einkauf shopping liste' }, - { emoji: '☕', category: 'Alltag', keywords: 'kaffee pause bistro morning' }, - { emoji: '🏠', category: 'Alltag', keywords: 'haus heim home wohnung' }, - { emoji: '🏢', category: 'Alltag', keywords: 'büro firma arbeit office work' }, - { emoji: '✈️', category: 'Alltag', keywords: 'flugzeug reise urlaub trip travel' }, - { emoji: '🎨', category: 'Alltag', keywords: 'kunst farbe design creative' }, - { emoji: '🏆', category: 'Alltag', keywords: 'pokal erfolg win trophy award' }, - { emoji: '✅', category: 'Alltag', keywords: 'haken check done erledigt' }, -]; +import { EmojiPickerModal } from './EmojiPickerModal'; function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] { if (!blocks || blocks.length === 0) { @@ -250,21 +194,12 @@ export const DocumentEditor: React.FC = ({ const [loading, setLoading] = useState(!initialDoc); const [error, setError] = useState(null); const [showEmojiPicker, setShowEmojiPicker] = useState(false); - const [emojiSearch, setEmojiSearch] = useState(''); const [zoomedBlockId, setZoomedBlockId] = useState(null); // Sync engine (operation log): stores every change locally and syncs it with the server const engineRef = useRef(null); const [engineState, setEngineState] = useState(null); - const filteredEmojis = useMemo(() => { - const q = emojiSearch.trim().toLowerCase(); - if (!q) return EMOJI_CATALOG; - return EMOJI_CATALOG.filter( - (item) => item.keywords.includes(q) || item.emoji.includes(q) || item.category.toLowerCase().includes(q) - ); - }, [emojiSearch]); - const titleInputRef = useRef(null); const outlinerTreeRef = useRef(null); @@ -1044,72 +979,11 @@ export const DocumentEditor: React.FC = ({ {doc?.icon || '📝'} {showEmojiPicker && canEdit && ( -
e.stopPropagation()} - > -
- - setEmojiSearch(e.target.value)} - placeholder="Icon suchen oder Emoji eingeben…" - className="input-field w-full pl-7 pr-2 py-1 text-xs" - /> -
- {doc?.icon && ( - - )} - {emojiSearch.trim() && ( - - )} -
- {filteredEmojis.map((item) => ( - - ))} - {filteredEmojis.length === 0 && ( -
- Kein passendes Standard-Icon -
- )} -
-
+ setShowEmojiPicker(false)} + /> )} diff --git a/frontend/src/features/workspace/EmojiPickerModal.tsx b/frontend/src/features/workspace/EmojiPickerModal.tsx new file mode 100644 index 0000000..9646fbc --- /dev/null +++ b/frontend/src/features/workspace/EmojiPickerModal.tsx @@ -0,0 +1,226 @@ +import React, { Suspense, useEffect, useRef, useState } from 'react'; +import { X, Trash2, Loader2, Clock } from 'lucide-react'; +import type { EmojiClickData } from 'emoji-picker-react'; +import { EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react'; + +// Lazy-load emoji-picker-react so the main bundle size stays small +const EmojiPicker = React.lazy(() => import('emoji-picker-react')); + +const RECENT_EMOJIS_STORAGE_KEY = 'mindsift_recent_emojis'; +const MAX_RECENT_EMOJIS = 10; + +function getStoredRecentEmojis(): string[] { + try { + const raw = localStorage.getItem(RECENT_EMOJIS_STORAGE_KEY); + if (!raw) return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻']; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + return parsed.slice(0, MAX_RECENT_EMOJIS); + } + } catch { + // fallback + } + return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻']; +} + +function saveRecentEmoji(emoji: string): void { + try { + const current = getStoredRecentEmojis(); + const updated = [emoji, ...current.filter((e) => e !== emoji)].slice(0, MAX_RECENT_EMOJIS); + localStorage.setItem(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(updated)); + } catch { + // ignore + } +} + +interface EmojiPickerModalProps { + currentIcon?: string; + onSelectEmoji: (emoji: string) => void; + onClose: () => void; +} + +export const EmojiPickerModal: React.FC = ({ + currentIcon, + onSelectEmoji, + onClose, +}) => { + const [recentEmojis, setRecentEmojis] = useState(getStoredRecentEmojis); + const [isDark, setIsDark] = useState(() => + typeof document !== 'undefined' + ? document.documentElement.classList.contains('dark') + : true + ); + + const containerRef = useRef(null); + + // Sync theme changes + useEffect(() => { + const observer = new MutationObserver(() => { + setIsDark(document.documentElement.classList.contains('dark')); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'data-theme'], + }); + return () => observer.disconnect(); + }, []); + + // Close on Escape or click outside (desktop popover) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + + const handleClickOutside = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + document.addEventListener('mousedown', handleClickOutside); + return () => { + window.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [onClose]); + + const handlePick = (emoji: string) => { + saveRecentEmoji(emoji); + setRecentEmojis(getStoredRecentEmojis()); + onSelectEmoji(emoji); + }; + + const handlePickerEmojiClick = (emojiData: EmojiClickData) => { + handlePick(emojiData.emoji); + }; + + return ( + <> + {/* Mobile Backdrop (< 640px) */} + {/* Quick-Capture Input Bubble directly in Header (above Tabs) */} -
- +
+
+ +
+
{/* User Profile Pill & Dropdown Menu */} @@ -1020,7 +1078,11 @@ export const App: React.FC = () => { )} {activeTab === 'lists' && ( - + )} {activeTab === 'groups' && ( @@ -1555,6 +1617,15 @@ export const App: React.FC = () => {
)} + + {/* Global Command Palette (Cmd+K / Ctrl+K / '/') */} + setShowCommandPalette(false)} + onOpenDocument={handleOpenDocument} + onOpenTask={handleOpenTask} + onOpenList={handleOpenList} + /> ); }; diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx new file mode 100644 index 0000000..ed521c3 --- /dev/null +++ b/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,327 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Search, + FileText, + CheckSquare, + ShoppingCart, + X, + Loader2, + ArrowRight, + CornerDownLeft, +} from 'lucide-react'; +import { SearchHit } from '../lib/types'; +import { api } from '../lib/api'; + +interface CommandPaletteProps { + isOpen: boolean; + onClose: () => void; + onOpenDocument: (docId: string, blockId?: string) => void; + onOpenTask: (taskId: string) => void; + onOpenList: (listId: string) => void; +} + +// Safely render snippet containing ... without dangerouslySetInnerHTML +const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => { + const parts = useMemo(() => { + if (!snippet) return []; + return snippet.split(/([\s\S]*?<\/mark>)/g); + }, [snippet]); + + return ( + + {parts.map((part, i) => { + if (part.startsWith('') && part.endsWith('')) { + const text = part.slice(6, -7); + return ( + + {text} + + ); + } + return {part}; + })} + + ); +}; + +export const CommandPalette: React.FC = ({ + isOpen, + onClose, + onOpenDocument, + onOpenTask, + onOpenList, +}) => { + const [query, setQuery] = useState(''); + const [hits, setHits] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [loading, setLoading] = useState(false); + + const inputRef = useRef(null); + const debounceTimerRef = useRef(null); + + // Group hits by category: Notizen, Aufgaben, Listen + const groupedHits = useMemo(() => { + const docs: SearchHit[] = []; + const tasks: SearchHit[] = []; + const lists: SearchHit[] = []; + + for (const h of hits) { + if (h.type === 'document') { + docs.push(h); + } else if (h.type === 'thought') { + tasks.push(h); + } else if (h.type === 'list_item') { + lists.push(h); + } + } + + return [ + { key: 'documents', label: 'Notizen', icon: FileText, items: docs }, + { key: 'tasks', label: 'Aufgaben', icon: CheckSquare, items: tasks }, + { key: 'lists', label: 'Listen', icon: ShoppingCart, items: lists }, + ].filter((g) => g.items.length > 0); + }, [hits]); + + // Flattened array for simple up/down index navigation + const flatHits = useMemo(() => { + return groupedHits.flatMap((g) => g.items); + }, [groupedHits]); + + // Focus input and reset on open + useEffect(() => { + if (isOpen) { + setQuery(''); + setHits([]); + setSelectedIndex(0); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [isOpen]); + + // 150ms debounce search + useEffect(() => { + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + } + + const trimmed = query.trim(); + if (!trimmed) { + setHits([]); + setLoading(false); + setSelectedIndex(0); + return; + } + + setLoading(true); + debounceTimerRef.current = window.setTimeout(async () => { + try { + const res = await api.searchAll(trimmed); + setHits(res.hits || []); + setSelectedIndex(0); + } catch { + setHits([]); + } finally { + setLoading(false); + } + }, 150); + + return () => { + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + } + }; + }, [query]); + + // Keyboard navigation: ↑, ↓, Enter, Escape + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + return; + } + + if (flatHits.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % flatHits.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => (prev - 1 + flatHits.length) % flatHits.length); + } else if (e.key === 'Enter') { + e.preventDefault(); + const selected = flatHits[selectedIndex]; + if (selected) { + executeSelection(selected); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, flatHits, selectedIndex]); + + const executeSelection = (hit: SearchHit) => { + onClose(); + if (hit.type === 'document') { + onOpenDocument(hit.docId || hit.id, hit.blockId); + } else if (hit.type === 'thought') { + onOpenTask(hit.id); + } else if (hit.type === 'list_item') { + if (hit.listId) { + onOpenList(hit.listId); + } + } + }; + + if (!isOpen) return null; + + return ( +
+ {/* Search Modal Container */} +
e.stopPropagation()} + > + {/* Top Input Bar */} +
+ + setQuery(e.target.value)} + placeholder="Globale Suche in Notizen, Aufgaben und Listen…" + className="flex-1 bg-transparent border-0 outline-none text-main placeholder-muted text-base sm:text-sm focus:ring-0 p-0" + /> + {loading && } + {query && !loading && ( + + )} + +
+ + {/* Results Container */} +
+ {query.trim() === '' ? ( +
+

MindSift Globale Suche

+

Tippe ein Stichwort ein, um alle Inhalte in Sekundenschnelle zu durchsuchen.

+
+ ) : flatHits.length === 0 && !loading ? ( +
+

Keine Treffer gefunden

+

Keine Notizen, Aufgaben oder Listen-Einträge stimmen mit „{query}“ überein.

+
+ ) : ( + groupedHits.map((group) => { + const GroupIcon = group.icon; + return ( +
+
+ + {group.label} +
+ +
+ {group.items.map((item) => { + const overallIndex = flatHits.indexOf(item); + const isSelected = overallIndex === selectedIndex; + return ( +
executeSelection(item)} + onMouseEnter={() => setSelectedIndex(overallIndex)} + className={` + px-3 py-2.5 rounded-btn flex items-start gap-2.5 transition-colors cursor-pointer border + ${ + isSelected + ? 'bg-panel-elevated border-accent text-main' + : 'bg-canvas/50 border-transparent text-main hover:bg-panel-elevated' + } + `} + > +
+ {item.icon || (item.type === 'document' ? '📝' : item.type === 'thought' ? '✅' : '🛒')} +
+ +
+
+ + {item.title || 'Unbenannt'} + + {isSelected && ( + + Öffnen + + + )} +
+ + {item.snippet && ( +
+ +
+ )} +
+
+ ); + })} +
+
+ ); + }) + )} +
+ + {/* Footer Shortcut Bar (Desktop) */} +
+
+ + ↑{' '} + ↓ Navigieren + + + ↵ Auswählen + + + Esc Schließen + +
+ +
+ {flatHits.length} Treffer +
+
+
+
+ ); +}; diff --git a/frontend/src/features/capture/InstantCaptureBar.tsx b/frontend/src/features/capture/InstantCaptureBar.tsx index 5bf73ef..cffc170 100644 --- a/frontend/src/features/capture/InstantCaptureBar.tsx +++ b/frontend/src/features/capture/InstantCaptureBar.tsx @@ -103,7 +103,7 @@ export const InstantCaptureBar: React.FC = ({ el.tagName === 'TEXTAREA' || el.isContentEditable; - if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) { + if (!isEditing && (e.key === 'c' || e.key === 'C')) { e.preventDefault(); inputRef.current?.focus(); } else if (e.key === 'Escape' && document.activeElement === inputRef.current) { diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 1a3dfc5..eb61d47 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -19,6 +19,7 @@ import { useMobileActions } from '../../context/MobileActionContext'; interface SharedListsViewProps { onRefreshGlobal: () => void; currentUser?: User | null; + initialListId?: string | null; } export function parseListItemInput(rawText: string, defaultQty?: string): { @@ -78,10 +79,16 @@ export function deduplicateItems(items: ListItem[]): ListItem[] { return result; } -export const SharedListsView: React.FC = ({ onRefreshGlobal, currentUser }) => { +export const SharedListsView: React.FC = ({ onRefreshGlobal, currentUser, initialListId }) => { const { setGroups, setPrimaryAction } = useMobileActions(); const [lists, setLists] = useState([]); - const [activeListId, setActiveListId] = useState(''); + const [activeListId, setActiveListId] = useState(initialListId || ''); + + useEffect(() => { + if (initialListId) { + setActiveListId(initialListId); + } + }, [initialListId]); const [newItemText, setNewItemText] = useState(''); const [newItemQty, setNewItemQty] = useState(''); @@ -127,7 +134,9 @@ export const SharedListsView: React.FC = ({ onRefreshGloba items: deduplicateItems(l.items || []), })); setLists(deduplicated); - if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) { + if (initialListId && deduplicated.some((l) => l.id === initialListId)) { + setActiveListId(initialListId); + } else if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) { setActiveListId(deduplicated[0].id); } } catch { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 36e0b0d..27e0426 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -24,6 +24,7 @@ import { TaskListShare, Thought, SubTask, + SearchResults, TagDetails, TagSummary, User, @@ -1059,8 +1060,8 @@ export const api = { ); }, - async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> { - return request<{ thoughts: Thought[]; documents: Document[] }>( + async searchAll(q: string): Promise { + return request( `/api/v1/search?q=${encodeURIComponent(q)}` ); }, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index bc54175..a1e652e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -297,6 +297,25 @@ export interface TagDetails { totalMatches: number; } +export interface SearchHit { + type: 'document' | 'thought' | 'list_item'; + id: string; + title: string; + snippet: string; + score?: number; + docId?: string; + blockId?: string; + listId?: string; + icon?: string; +} + +export interface SearchResults { + thoughts: Thought[]; + documents: Document[]; + listItems: ListItem[]; + hits: SearchHit[]; +} + export interface SpaceMember { spaceId: string; userId: string; diff --git a/internal/api/server.go b/internal/api/server.go index 146d355..25f4291 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2039,15 +2039,12 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) q := r.URL.Query().Get("q") - thoughts, docs, err := s.store.SearchAll(userID, q) + res, err := s.store.SearchGlobal(userID, q) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "thoughts": thoughts, - "documents": docs, - }) + writeJSON(w, http.StatusOK, res) } // --- CUSTOM THEMES --- diff --git a/internal/storage/search.go b/internal/storage/search.go new file mode 100644 index 0000000..8c5ce46 --- /dev/null +++ b/internal/storage/search.go @@ -0,0 +1,369 @@ +package storage + +import ( + "database/sql" + "fmt" + "log/slog" + "regexp" + "strings" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// SearchHit represents a single ranked match across documents, tasks/thoughts, and lists. +type SearchHit struct { + Type string `json:"type"` // "document", "thought", "list_item" + ID string `json:"id"` + Title string `json:"title"` + Snippet string `json:"snippet"` + Score float64 `json:"score,omitempty"` + DocID string `json:"docId,omitempty"` + BlockID string `json:"blockId,omitempty"` + ListID string `json:"listId,omitempty"` + Icon string `json:"icon,omitempty"` +} + +// SearchResults encapsulates all results for backwards-compatible API serialization. +type SearchResults struct { + Thoughts []domain.Thought `json:"thoughts"` + Documents []domain.Document `json:"documents"` + ListItems []domain.ListItem `json:"listItems"` + Hits []SearchHit `json:"hits"` +} + +var wordTokenRegex = regexp.MustCompile(`[\p{L}\p{N}]+`) + +// PrepareFTSQuery extracts valid words from user input and formats them as prefix match tokens. +// E.g. "einkau" -> `"einkau"*`, "Hello World!" -> `"Hello"* "World"*`. +func PrepareFTSQuery(raw string) string { + matches := wordTokenRegex.FindAllString(strings.TrimSpace(raw), -1) + if len(matches) == 0 { + return "" + } + tokens := make([]string, 0, len(matches)) + for _, m := range matches { + clean := strings.ReplaceAll(m, `"`, `""`) + tokens = append(tokens, fmt.Sprintf(`"%s"*`, clean)) + } + return strings.Join(tokens, " ") +} + +// migrateSearchFTS creates the virtual FTS5 tables and synchronization triggers. +func (s *Store) migrateSearchFTS() { + tables := []string{ + `CREATE VIRTUAL TABLE IF NOT EXISTS thoughts_fts USING fts5( + thought_id UNINDEXED, + content, + notes, + subtasks, + tokenize='unicode61' + );`, + `CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5( + doc_id UNINDEXED, + block_id UNINDEXED, + title, + content, + tokenize='unicode61' + );`, + `CREATE VIRTUAL TABLE IF NOT EXISTS list_items_fts USING fts5( + item_id UNINDEXED, + list_id UNINDEXED, + content, + category, + tokenize='unicode61' + );`, + } + + for _, stmt := range tables { + if _, err := s.db.Exec(stmt); err != nil { + slog.Error("Failed to create FTS virtual table", "error", err, "statement", stmt) + } + } + + triggers := []string{ + // Thoughts triggers + `CREATE TRIGGER IF NOT EXISTS trg_thoughts_ai AFTER INSERT ON thoughts BEGIN + INSERT INTO thoughts_fts(thought_id, content, notes, subtasks) + VALUES (new.id, new.content, new.notes, new.subtasks); + END;`, + `CREATE TRIGGER IF NOT EXISTS trg_thoughts_au AFTER UPDATE ON thoughts BEGIN + DELETE FROM thoughts_fts WHERE thought_id = old.id; + INSERT INTO thoughts_fts(thought_id, content, notes, subtasks) + VALUES (new.id, new.content, new.notes, new.subtasks); + END;`, + `CREATE TRIGGER IF NOT EXISTS trg_thoughts_ad AFTER DELETE ON thoughts BEGIN + DELETE FROM thoughts_fts WHERE thought_id = old.id; + END;`, + + // Documents triggers + `CREATE TRIGGER IF NOT EXISTS trg_documents_ai AFTER INSERT ON documents BEGIN + INSERT INTO documents_fts(doc_id, block_id, title, content) + VALUES (new.id, '', new.title, ''); + END;`, + `CREATE TRIGGER IF NOT EXISTS trg_documents_au AFTER UPDATE OF title, is_archived ON documents BEGIN + DELETE FROM documents_fts WHERE doc_id = old.id AND block_id = ''; + INSERT INTO documents_fts(doc_id, block_id, title, content) + SELECT new.id, '', new.title, '' WHERE new.is_archived = 0; + END;`, + `CREATE TRIGGER IF NOT EXISTS trg_documents_ad AFTER DELETE ON documents BEGIN + DELETE FROM documents_fts WHERE doc_id = old.id; + END;`, + + // Blocks triggers (excluding tombstones/deleted blocks) + `CREATE TRIGGER IF NOT EXISTS trg_blocks_ai AFTER INSERT ON blocks BEGIN + INSERT INTO documents_fts(doc_id, block_id, title, content) + SELECT new.document_id, new.id, '', new.content + WHERE new.deleted = 0 AND (new.deleted_at = '' OR new.deleted_at IS NULL); + END;`, + `CREATE TRIGGER IF NOT EXISTS trg_blocks_au AFTER UPDATE OF content, deleted, deleted_at, document_id ON blocks BEGIN + DELETE FROM documents_fts WHERE doc_id = old.document_id AND block_id = old.id; + INSERT INTO documents_fts(doc_id, block_id, title, content) + SELECT new.document_id, new.id, '', new.content + WHERE new.deleted = 0 AND (new.deleted_at = '' OR new.deleted_at IS NULL); + END;`, + `CREATE TRIGGER IF NOT EXISTS trg_blocks_ad AFTER DELETE ON blocks BEGIN + DELETE FROM documents_fts WHERE doc_id = old.document_id AND block_id = old.id; + END;`, + + // List items triggers + `CREATE TRIGGER IF NOT EXISTS trg_list_items_ai AFTER INSERT ON list_items BEGIN + INSERT INTO list_items_fts(item_id, list_id, content, category) + VALUES (new.id, new.list_id, new.content, new.category); + END;`, + `CREATE TRIGGER IF NOT EXISTS trg_list_items_au AFTER UPDATE ON list_items BEGIN + DELETE FROM list_items_fts WHERE item_id = old.id; + INSERT INTO list_items_fts(item_id, list_id, content, category) + VALUES (new.id, new.list_id, new.content, new.category); + END;`, + `CREATE TRIGGER IF NOT EXISTS trg_list_items_ad AFTER DELETE ON list_items BEGIN + DELETE FROM list_items_fts WHERE item_id = old.id; + END;`, + } + + for _, trg := range triggers { + if _, err := s.db.Exec(trg); err != nil { + slog.Error("Failed to create FTS trigger", "error", err, "statement", trg) + } + } + + s.backfillSearchFTS() +} + +// backfillSearchFTS populates empty FTS indices once on startup. +func (s *Store) backfillSearchFTS() { + var thoughtCount int + _ = s.db.QueryRow(`SELECT count(*) FROM thoughts_fts`).Scan(&thoughtCount) + if thoughtCount == 0 { + _, err := s.db.Exec(` + INSERT INTO thoughts_fts(thought_id, content, notes, subtasks) + SELECT id, content, notes, subtasks FROM thoughts; + `) + if err != nil { + slog.Error("Failed to backfill thoughts_fts", "error", err) + } + } + + var docCount int + _ = s.db.QueryRow(`SELECT count(*) FROM documents_fts`).Scan(&docCount) + if docCount == 0 { + _, err := s.db.Exec(` + INSERT INTO documents_fts(doc_id, block_id, title, content) + SELECT id, '', title, '' FROM documents WHERE is_archived = 0; + + INSERT INTO documents_fts(doc_id, block_id, title, content) + SELECT document_id, id, '', content FROM blocks + WHERE deleted = 0 AND (deleted_at = '' OR deleted_at IS NULL); + `) + if err != nil { + slog.Error("Failed to backfill documents_fts", "error", err) + } + } + + var listCount int + _ = s.db.QueryRow(`SELECT count(*) FROM list_items_fts`).Scan(&listCount) + if listCount == 0 { + _, err := s.db.Exec(` + INSERT INTO list_items_fts(item_id, list_id, content, category) + SELECT id, list_id, content, category FROM list_items; + `) + if err != nil { + slog.Error("Failed to backfill list_items_fts", "error", err) + } + } +} + +// SearchGlobal executes an FTS5 full-text search across documents, thoughts/tasks and list items, +// filtering results strictly to what the user is authorized to view. +func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) { + ftsQuery := PrepareFTSQuery(query) + res := &SearchResults{ + Thoughts: make([]domain.Thought, 0), + Documents: make([]domain.Document, 0), + ListItems: make([]domain.ListItem, 0), + Hits: make([]SearchHit, 0), + } + + if ftsQuery == "" { + return res, nil + } + + // 1. Search Documents and Blocks + // Weights: 10.0 for title, 1.0 for block content + docRows, err := s.db.Query(` + SELECT f.doc_id, f.block_id, + snippet(documents_fts, -1, '', '', '…', 14) AS snip, + bm25(documents_fts, 10.0, 1.0) AS score, + d.title, d.icon, d.user_id, d.is_archived + FROM documents_fts f + JOIN documents d ON d.id = f.doc_id + WHERE documents_fts MATCH ? AND d.is_archived = 0 + ORDER BY score ASC + LIMIT 40 + `, ftsQuery) + if err == nil { + seenDocIDs := make(map[string]bool) + for docRows.Next() { + var docID, blockID, snip, title, icon, ownerID string + var score float64 + var isArchived int + if scanErr := docRows.Scan(&docID, &blockID, &snip, &score, &title, &icon, &ownerID, &isArchived); scanErr == nil { + // Access check via DocRole + if s.DocRole(userID, docID) == "" { + continue + } + + hitID := docID + if blockID != "" { + hitID = docID + ":" + blockID + } + + res.Hits = append(res.Hits, SearchHit{ + Type: "document", + ID: hitID, + Title: title, + Snippet: snip, + Score: score, + DocID: docID, + BlockID: blockID, + Icon: icon, + }) + + if !seenDocIDs[docID] { + seenDocIDs[docID] = true + if doc, err := s.GetDocumentByID(userID, docID); err == nil && doc != nil { + res.Documents = append(res.Documents, *doc) + } + } + } + } + _ = docRows.Close() + } + + // 2. Search Thoughts & Tasks + // Weights: 5.0 for content, 1.0 for notes, 1.0 for subtasks + thoughtRows, err := s.db.Query(` + SELECT f.thought_id, + snippet(thoughts_fts, -1, '', '', '…', 14) AS snip, + bm25(thoughts_fts, 5.0, 1.0, 1.0) AS score, + t.id, t.user_id, t.content, t.tags, t.priority, t.source, t.status, + t.is_completed, t.converted_type, t.converted_ref_id, t.due_date, + t.task_list_id, t.kanban_status, t.notes, t.alarm_sent, t.created_at, + t.triaged_at, t.subtasks, t.sort_order + FROM thoughts_fts f + JOIN thoughts t ON t.id = f.thought_id + WHERE thoughts_fts MATCH ? + AND (t.user_id = ? OR t.task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) + ORDER BY score ASC + LIMIT 40 + `, ftsQuery, userID, userID, userID, userID) + if err == nil { + for thoughtRows.Next() { + var thoughtID, snip string + var score float64 + var t domain.Thought + var tagsStr, subtasksStr string + var isCompleted, alarmSent int + var triagedAt, dueDate, convertedType, convertedRefId sql.NullString + if scanErr := thoughtRows.Scan( + &thoughtID, &snip, &score, + &t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, &t.Status, + &isCompleted, &convertedType, &convertedRefId, &dueDate, + &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent, &t.CreatedAt, + &triagedAt, &subtasksStr, &t.SortOrder, + ); scanErr == nil { + t.IsCompleted = isCompleted != 0 + t.AlarmSent = alarmSent != 0 + t.DueDate = dueDate.String + t.ConvertedType = convertedType.String + t.ConvertedRefID = convertedRefId.String + t.TriagedAt = triagedAt.String + + res.Hits = append(res.Hits, SearchHit{ + Type: "thought", + ID: t.ID, + Title: t.Content, + Snippet: snip, + Score: score, + }) + res.Thoughts = append(res.Thoughts, t) + } + } + _ = thoughtRows.Close() + } + + // 3. Search List Items + // Weights: 2.0 for content, 1.0 for category + accessibleLists, _ := s.GetAllListsForUser(userID) + accessibleListIDs := make(map[string]bool, len(accessibleLists)) + for _, l := range accessibleLists { + accessibleListIDs[l.ID] = true + } + + if len(accessibleListIDs) > 0 { + itemRows, err := s.db.Query(` + SELECT f.item_id, f.list_id, + snippet(list_items_fts, -1, '', '', '…', 14) AS snip, + bm25(list_items_fts, 2.0, 1.0) AS score, + li.id, li.list_id, li.content, li.category, li.quantity, + li.is_completed, li.completed_by, li.completed_by_name, + li.sort_order, li.created_at, li.completed_at + FROM list_items_fts f + JOIN list_items li ON li.id = f.item_id + WHERE list_items_fts MATCH ? + ORDER BY score ASC + LIMIT 40 + `, ftsQuery) + if err == nil { + for itemRows.Next() { + var itemID, listID, snip string + var score float64 + var li domain.ListItem + var isCompleted int + if scanErr := itemRows.Scan( + &itemID, &listID, &snip, &score, + &li.ID, &li.ListID, &li.Content, &li.Category, &li.Quantity, + &isCompleted, &li.CompletedBy, &li.CompletedByName, + &li.SortOrder, &li.CreatedAt, &li.CompletedAt, + ); scanErr == nil { + if !accessibleListIDs[listID] { + continue + } + li.IsCompleted = isCompleted != 0 + + res.Hits = append(res.Hits, SearchHit{ + Type: "list_item", + ID: li.ID, + Title: li.Content, + Snippet: snip, + Score: score, + ListID: listID, + }) + res.ListItems = append(res.ListItems, li) + } + } + _ = itemRows.Close() + } + } + + return res, nil +} diff --git a/internal/storage/search_test.go b/internal/storage/search_test.go new file mode 100644 index 0000000..412aa08 --- /dev/null +++ b/internal/storage/search_test.go @@ -0,0 +1,214 @@ +package storage + +import ( + "path/filepath" + "testing" + + "github.com/sandrodach/mindsift/internal/domain" +) + +func setupTestStore(t *testing.T) (*Store, *domain.User, *domain.User) { + t.Helper() + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test_search.db") + + store, err := NewStore(dbPath) + if err != nil { + t.Fatalf("NewStore failed: %v", err) + } + openSharing(t, store) + + userA, err := store.CreateUserWithUsername("alice@test.local", "alice", "hash123", "Alice", "member") + if err != nil { + t.Fatalf("CreateUser Alice failed: %v", err) + } + + userB, err := store.CreateUserWithUsername("bob@test.local", "bob", "hash123", "Bob", "member") + if err != nil { + t.Fatalf("CreateUser Bob failed: %v", err) + } + + return store, userA, userB +} + +func TestSearchBlockContent(t *testing.T) { + store, userA, _ := setupTestStore(t) + defer store.Close() + + // Create document with block content + doc, err := store.CreateDocument(userA.ID, "", "Rezeptbuch", "📖", "", nil, nil, false) + if err != nil { + t.Fatalf("CreateDocument failed: %v", err) + } + + blockID := domain.NewID("blk") + pos := 1.0 + _, err = store.db.Exec(` + INSERT INTO blocks (id, document_id, content, level, position, type, deleted, deleted_at) + VALUES (?, ?, ?, 0, ?, 'bullet', 0, '') + `, blockID, doc.ID, "Zutaten: 500g Mehl und Hefe für Pizza", pos) + if err != nil { + t.Fatalf("Insert block failed: %v", err) + } + + // Search for block content + res, err := store.SearchGlobal(userA.ID, "Hefe") + if err != nil { + t.Fatalf("SearchGlobal failed: %v", err) + } + + found := false + for _, hit := range res.Hits { + if hit.DocID == doc.ID && hit.BlockID == blockID { + found = true + if hit.Snippet == "" { + t.Errorf("expected snippet with marked match, got empty string") + } + break + } + } + if !found { + t.Errorf("expected to find hit for block content 'Hefe', hits: %+v", res.Hits) + } +} + +func TestSearchTitleRanking(t *testing.T) { + store, userA, _ := setupTestStore(t) + defer store.Close() + + // Doc 1 has "Architektur" in block content + docContent, err := store.CreateDocument(userA.ID, "", "Notizen zu Systemen", "📝", "", nil, nil, false) + if err != nil { + t.Fatalf("CreateDocument failed: %v", err) + } + _, err = store.db.Exec(` + INSERT INTO blocks (id, document_id, content, level, position, type, deleted, deleted_at) + VALUES (?, ?, 'Hier wird die Architektur im Detail besprochen', 0, 1.0, 'bullet', 0, '') + `, domain.NewID("blk"), docContent.ID) + if err != nil { + t.Fatalf("Insert block failed: %v", err) + } + + // Doc 2 has "Architektur" in title + docTitle, err := store.CreateDocument(userA.ID, "", "Architektur Leitfaden", "🏛️", "", nil, nil, false) + if err != nil { + t.Fatalf("CreateDocument failed: %v", err) + } + + res, err := store.SearchGlobal(userA.ID, "Architektur") + if err != nil { + t.Fatalf("SearchGlobal failed: %v", err) + } + + if len(res.Hits) < 2 { + t.Fatalf("expected at least 2 hits, got %d", len(res.Hits)) + } + + // First hit must be the document with the title match + if res.Hits[0].DocID != docTitle.ID { + t.Errorf("expected title match (doc %s) to rank first, got doc %s", docTitle.ID, res.Hits[0].DocID) + } +} + +func TestSearchPermissionsAndUnshared(t *testing.T) { + store, userA, userB := setupTestStore(t) + defer store.Close() + + // Alice creates private doc, private thought, and private list + privateDoc, err := store.CreateDocument(userA.ID, "", "Geheimes Projekt", "🔒", "", nil, nil, false) + if err != nil { + t.Fatalf("CreateDocument failed: %v", err) + } + + privateThought, err := store.CreateThought(userA.ID, "Geheimer Gedanke für Alice", "high", "manual", "") + if err != nil { + t.Fatalf("CreateThought failed: %v", err) + } + + privateList, err := store.CreateList(userA.ID, "Geheime Einkaufsliste", "🛒", "shopping") + if err != nil { + t.Fatalf("CreateList failed: %v", err) + } + _, err = store.CreateListItem(privateList.ID, "Geheimer Kuchen", "", "", 0, userA.ID) + if err != nil { + t.Fatalf("CreateListItem failed: %v", err) + } + + // Bob searches for "Geheim" + resBob, err := store.SearchGlobal(userB.ID, "Geheim") + if err != nil { + t.Fatalf("SearchGlobal for Bob failed: %v", err) + } + + if len(resBob.Hits) != 0 || len(resBob.Thoughts) != 0 || len(resBob.Documents) != 0 || len(resBob.ListItems) != 0 { + t.Fatalf("expected Bob to find 0 private hits of Alice, got hits=%+v thoughts=%+v docs=%+v", + resBob.Hits, resBob.Thoughts, resBob.Documents) + } + + // Now share privateDoc with Bob + if _, err := store.CreateDocShare(userA.ID, privateDoc.ID, userB.Email, "read"); err != nil { + t.Fatalf("CreateDocShare failed: %v", err) + } + + // Bob searches again + resBobShared, err := store.SearchGlobal(userB.ID, "Geheim") + if err != nil { + t.Fatalf("SearchGlobal for Bob after share failed: %v", err) + } + + if len(resBobShared.Documents) != 1 || resBobShared.Documents[0].ID != privateDoc.ID { + t.Fatalf("expected Bob to find shared document %s, got %+v", privateDoc.ID, resBobShared.Documents) + } + + // Alice searches and finds her own items + resAlice, err := store.SearchGlobal(userA.ID, "Geheim") + if err != nil { + t.Fatalf("SearchGlobal for Alice failed: %v", err) + } + if len(resAlice.Documents) != 1 || len(resAlice.Thoughts) != 1 || len(resAlice.ListItems) != 1 { + t.Fatalf("expected Alice to find her items, got docs=%d thoughts=%d lists=%d", + len(resAlice.Documents), len(resAlice.Thoughts), len(resAlice.ListItems)) + } + _ = privateThought +} + +func TestSearchPrefixAndSpecialCharacters(t *testing.T) { + store, userA, _ := setupTestStore(t) + defer store.Close() + + _, err := store.CreateThought(userA.ID, "Großer Wocheneinkauf im Supermarkt", "normal", "manual", "") + if err != nil { + t.Fatalf("CreateThought failed: %v", err) + } + + // Prefix search: "einkau" should find "Wocheneinkauf" or "Einkauf" + // Note: FTS5 prefix matches word prefixes: "Wochen..." vs "einkauf" + _, err = store.CreateThought(userA.ID, "Einkaufszettel schreiben", "normal", "manual", "") + if err != nil { + t.Fatalf("CreateThought failed: %v", err) + } + + res, err := store.SearchGlobal(userA.ID, "einkau") + if err != nil { + t.Fatalf("SearchGlobal prefix failed: %v", err) + } + if len(res.Hits) == 0 { + t.Errorf("expected prefix match for 'einkau', got 0 hits") + } + + // Special characters should not cause syntax errors + crazyQueries := []string{ + `"unclosed quote`, + `test: foo AND OR NOT (bar)^`, + `!@#$%^&*()_+=-[]{}\|;:'",.<>/?`, + `* * *`, + ``, + ` `, + } + for _, cq := range crazyQueries { + _, err := store.SearchGlobal(userA.ID, cq) + if err != nil { + t.Errorf("query %q caused error: %v", cq, err) + } + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 4d026f4..d3b68fe 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -514,6 +514,7 @@ func (s *Store) migrate() error { _ = s.reindexAllDocBlockTags() } s.migrateAnchorTags() + s.migrateSearchFTS() _, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion)) return err @@ -4122,44 +4123,11 @@ func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) } func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Document, error) { - q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%" - - tRows, err := s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order - FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`, - userID, q, - ) - thoughts := make([]domain.Thought, 0) - if err == nil { - for tRows.Next() { - if t, err := scanThought(tRows); err == nil { - thoughts = append(thoughts, *t) - } - } - _ = tRows.Close() + res, err := s.SearchGlobal(userID, query) + if err != nil { + return nil, nil, err } - - 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, - 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.shared_folder_id, d.sub_folder_path - FROM documents d - 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`, - userID, userID, q, q, - ) - docs := make([]domain.Document, 0) - if err == nil { - for dRows.Next() { - if d, err := scanDocument(dRows); err == nil { - docs = append(docs, *d) - } - } - _ = dRows.Close() - } - - return thoughts, docs, nil + return res.Thoughts, res.Documents, nil } func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) { From 9c6598cb8538d34b905b0a1aeae00e673daa36cf Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 28 Oct 2026 23:04:43 +0100 Subject: [PATCH 3/8] =?UTF-8?q?Paket=20C:=20Demo-Modus=20setzt=20Datenbank?= =?UTF-8?q?=20bei=20jedem=20Start=20zur=C3=BCck=20und=20bef=C3=BCllt=20sie?= =?UTF-8?q?=20neu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 12 ++-- internal/storage/demo.go | 21 +++++++ internal/storage/demo_test.go | 104 ++++++++++++++++++++++++++++++++++ internal/storage/sqlite.go | 9 ++- 4 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 internal/storage/demo.go create mode 100644 internal/storage/demo_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 9333166..e04bf9d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -37,18 +37,18 @@ func main() { } defer store.Close() - // Seed SMTP defaults from environment variables into DB if not yet configured in DB - store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS, cfg.SMTPSkipVerify) - if cfg.DemoMode { - slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...") - if err := store.EnsureDemoAccounts(); err != nil { - slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err) + slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...") + if err := store.ResetAndSeedDemo(); err != nil { + slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err) } else { slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)") } } + // Seed SMTP defaults from environment variables into DB if not yet configured in DB + store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS, cfg.SMTPSkipVerify) + wsHub := ws.NewHub() staticFS, err := fs.Sub(embeddedDist, "dist") diff --git a/internal/storage/demo.go b/internal/storage/demo.go new file mode 100644 index 0000000..bee0bcf --- /dev/null +++ b/internal/storage/demo.go @@ -0,0 +1,21 @@ +package storage + +import ( + "fmt" + "log/slog" +) + +// ResetAndSeedDemo drops all tables, recreates the schema, and seeds demo accounts and demo data. +func (s *Store) ResetAndSeedDemo() error { + if err := s.dropAllTables(); err != nil { + return fmt.Errorf("reset demo: drop all tables: %w", err) + } + if err := s.migrate(); err != nil { + return fmt.Errorf("reset demo: migrate schema: %w", err) + } + if err := s.EnsureDemoAccounts(); err != nil { + return fmt.Errorf("reset demo: ensure demo accounts: %w", err) + } + slog.Info("🧹 Demo-Modus: Datenbank für Testumgebung zurückgesetzt und neu befüllt") + return nil +} diff --git a/internal/storage/demo_test.go b/internal/storage/demo_test.go new file mode 100644 index 0000000..21f637c --- /dev/null +++ b/internal/storage/demo_test.go @@ -0,0 +1,104 @@ +package storage + +import ( + "path/filepath" + "testing" + + "github.com/sandrodach/mindsift/internal/auth" +) + +func TestResetAndSeedDemo(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "demo_test.db") + + store, err := NewStore(dbPath) + if err != nil { + t.Fatalf("NewStore failed: %v", err) + } + defer store.Close() + + // Create custom user and data + hash, _ := auth.HashPassword("secret") + customUser, err := store.CreateUserWithUsername("custom@test.local", "customuser", hash, "Custom", "member") + if err != nil { + t.Fatalf("CreateUser failed: %v", err) + } + _, err = store.CreateThought(customUser.ID, "A personal secret note", "normal", "manual", "") + if err != nil { + t.Fatalf("CreateThought failed: %v", err) + } + + // Call ResetAndSeedDemo + if err := store.ResetAndSeedDemo(); err != nil { + t.Fatalf("ResetAndSeedDemo failed: %v", err) + } + + // Custom user must be wiped + u, err := store.GetUserByID(customUser.ID) + if err == nil && u != nil { + t.Fatalf("expected custom user to be wiped, but still found: %v", u) + } + + // Demo accounts admin and user must exist + admin, err := store.GetUserByLogin("admin") + if err != nil || admin == nil { + t.Fatalf("expected demo admin to exist after reset: %v", err) + } + user, err := store.GetUserByLogin("user") + if err != nil || user == nil { + t.Fatalf("expected demo user to exist after reset: %v", err) + } + + // Resetting again should work cleanly (idempotence on repeated server starts) + if err := store.ResetAndSeedDemo(); err != nil { + t.Fatalf("second ResetAndSeedDemo failed: %v", err) + } +} + +func TestWithoutDemoModeDataIsNotWiped(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "nodemo_test.db") + + // 1. Initial start without demo mode + store1, err := NewStore(dbPath) + if err != nil { + t.Fatalf("first NewStore failed: %v", err) + } + + hash, _ := auth.HashPassword("secret123") + user1, err := store1.CreateUserWithUsername("persistent@example.com", "persistent", hash, "Persistent", "member") + if err != nil { + t.Fatalf("CreateUserWithUsername failed: %v", err) + } + thought1, err := store1.CreateThought(user1.ID, "Important persistent note #keep", "high", "manual", "") + if err != nil { + t.Fatalf("CreateThought failed: %v", err) + } + + // Close store simulating server shutdown + store1.Close() + + // 2. Server restart without demo mode (standard NewStore) + store2, err := NewStore(dbPath) + if err != nil { + t.Fatalf("second NewStore failed: %v", err) + } + defer store2.Close() + + // Verify existing data is preserved + foundUser, err := store2.GetUserByID(user1.ID) + if err != nil || foundUser == nil { + t.Fatalf("expected user %s to be preserved without demo mode, err: %v", user1.ID, err) + } + if foundUser.Username != "persistent" { + t.Fatalf("expected username 'persistent', got '%s'", foundUser.Username) + } + + thoughts, err := store2.GetThoughts(user1.ID, "", 10) + if err != nil { + t.Fatalf("GetThoughts failed: %v", err) + } + if len(thoughts) != 1 || thoughts[0].ID != thought1.ID { + t.Fatalf("expected thought %s to persist, got: %v", thought1.ID, thoughts) + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index d3b68fe..8cdfef9 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -70,6 +70,11 @@ func (s *Store) resetOutdatedSchema() error { if version >= schemaVersion { return nil } + slog.Warn("Veraltetes Datenbankschema: alle Daten werden gelöscht und neu angelegt", "version", version, "neu", schemaVersion) + return s.dropAllTables() +} + +func (s *Store) dropAllTables() error { conn, err := s.db.Conn(context.Background()) if err != nil { return err @@ -88,9 +93,7 @@ func (s *Store) resetOutdatedSchema() error { } } _ = rows.Close() - if len(tables) > 0 { - slog.Warn("Veraltetes Datenbankschema: alle Daten werden gelöscht und neu angelegt", "version", version, "neu", schemaVersion) - } + if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil { return err } From 0e9bfab943fbf9f2734b4949a18462bd036c0fde Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 28 Oct 2026 23:08:05 +0100 Subject: [PATCH 4/8] Doku: REST-API-Suche, Moduldokumentation, FLAW-18 und Hilfe-Seite aktualisiert --- Doku/api/rest.md | 27 ++++++++++++++++- Doku/flaws.md | 7 ++--- Doku/modules/workspace.md | 3 +- frontend/src/features/help/HelpView.tsx | 39 ++++++++++++++++++++++++- 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 835a51d..4676ccc 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -28,12 +28,37 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) | | GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) | | GET | `/stats` | Zähler fürs Dashboard | -| GET | `/search?q=` | Suche in eigenen Gedanken und Notizen | +| GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` | | GET | `/export` | Datenexport als JSON | | GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) | | DELETE | `/themes/{id}` | Eigenes Theme löschen | | GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) | +### Globale Suche (`GET /api/v1/search?q=`) + +Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus. +- Durchsucht Aufgaben/Gedanken (Inhalt, Notizen, Unteraufgaben), Notizen (Titel sowie Inhalte aktiver Blöcke) und Listeneinträge. +- Beachtet strikt alle Zugriffsregeln: Nur eigene Gedanken/Aufgaben und solche in zugänglichen Aufgabenlisten; nur eigene und geteilte Notizen (direkt, über Gruppen oder freigegebene Ordner); nur eigene und geteilte Listen. +- Titel-Treffer werden höher gewichtet als Treffer im Fließtext. +- Liefert rückwärtskompatible Collections (`thoughts`, `documents`, `listItems`) sowie strukturierte `hits` mit Snippet und ``-Hervorhebung: + ```json + { + "thoughts": [...], + "documents": [...], + "listItems": [...], + "hits": [ + { + "type": "document", + "id": "blk_123", + "title": "Projektplanung", + "snippet": "... wichtige Notiz zum Release ...", + "docId": "doc_abc", + "blockId": "blk_123" + } + ] + } + ``` + ## Gedanken, Triage, Aufgaben | Methode | Pfad | Zweck | diff --git a/Doku/flaws.md b/Doku/flaws.md index 9edf419..eaace45 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -27,7 +27,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** | | FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel | | FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** | -| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig | +| FLAW-18 | Suche | ~~Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS~~ | **Behoben** | | FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig | | FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig | | FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig | @@ -147,9 +147,8 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen. ### FLAW-18: Suche -- **Fundstelle:** `Store.SearchAll`. -- **Probleme:** Durchsucht nur eigene Gedanken (30) und eigene Dokumente (20); geteilte Dokumente, Aufgaben aus geteilten Listen, Einkaufslisten und Blöcke bleiben außen vor. `LIKE '%…%'` ohne Escaping von `%`/`_`, keine Indizes, keine Relevanz. -- **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen. +- **Status: Behoben.** SQLite FTS5-Volltexttabellen mit BM25-Ranking (`thoughts_fts`, `documents_fts`, `list_items_fts`), Trigger zur automatischen Synchronisation und initialem Backfill beim Start. Sicheres Query-Escaping, Präfixsuche. Strikte Berechtigungsprüfung über `accessibleTaskListIDsQuery`, `DocRole` (eigene, geteilte Notizen, über Ordner gemountete Notizen) und zugängliche Listen. Rückwärtskompatible API-Antwort mit Snippets (``) und Frontend-Integration über `CommandPalette` (`Cmd+K` / `Ctrl+K` / `/`). +- **Fundstellen:** `internal/storage/search.go`, `internal/api/server.go`, `frontend/src/components/CommandPalette.tsx`. ### FLAW-19: Geringe Frontend-Testabdeckung - **Stand:** Vitest mit jsdom und Testing Library ist eingerichtet (`npm run test:run`) und läuft beim Image-Build. Getestet sind bisher nur Outliner-Funktionen (Backspace-Zusammenführen, Cursor-Position beim Klicken). diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 6127a75..53344ed 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -32,6 +32,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - **Konfliktkopien** sind farbig markiert („Konflikt – Version von …“) mit „Übernehmen“ (Text ersetzt den ursprünglichen Punkt bzw. den Titel, die Kopie verschwindet) und „Verwerfen“. - **Rückgängig:** `Strg+Z`/`Cmd+Z` nimmt die eigene letzte Änderung zurück, `Strg+Umschalt+Z` oder `Strg+Y` wiederholt (auch im Titel und über das Kontextmenü/mobile Tastaturen). Tippen im selben Punkt bis 1 s Pause ist ein Schritt; bis zu 200 Schritte je Tab. Hat jemand anderes den Punkt inzwischen geändert oder hält ihn gesperrt, wird der Schritt mit Hinweis übersprungen. - `PUT /documents/{id}` ändert nur noch Symbol, Anheften und Ordner (Titel wird ins Log übernommen); Inhalte laufen ausschließlich über die Engine. Das To-Do-Rollup hakt per einzelner Operation über denselben Sync-Endpunkt ab. +- **Notiz-Icon (Emoji-Picker):** Vollständiger Emoji-Katalog mit Kategoriereitern, Suche, Verlauf zuletzt verwendeter Symbole (`localStorage`) und dynamischer Theme-Anpassung. Auf Mobilgeräten (< 640 px) als Bottom-Sheet mit touch-freundlichen Zielen (≥ 44 px) und `text-base`-Suchfeld (verhindert iOS-Zoom). ## Explorer, Tags, To-Dos @@ -50,4 +51,4 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie. - Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*. -- Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen ([FLAW-18](../flaws.md)). +- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht ([FLAW-18](../flaws.md)). diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 31d92f4..8e9c04e 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -341,6 +341,42 @@ export const HelpView: React.FC = ({ onBack }) => { ), }, + { + id: 'workspace-search-cmdk', + category: 'workspace', + title: 'Globale Suche & Command Palette (Cmd+K)', + subtitle: 'FTS5-Volltextsuche in Notizen, Blöcken, Aufgaben und Listen', + icon: Command, + keywords: [ + 'suche', + 'search', + 'command', + 'palette', + 'cmdk', + 'find', + 'fts', + 'volltext', + 'tastatur', + ], + content: ( +
+

+ Drücke Cmd + K / Strg + K oder / (wenn kein Textfeld aktiv ist), um die globale Suche zu öffnen: +

+
    +
  • + Volltextsuche (FTS5): Durchsucht Notiz-Titel und Blöcke, Aufgaben und Listeneinträge blitzschnell mit Relevanz-Ranking (BM25) und Präfixsuche. +
  • +
  • + Sicherheit: Zeigt nur Inhalte, auf die du tatsächlich Zugriff hast (eigene sowie freigegebene Notizen, Aufgabenlisten und Listen). +
  • +
  • + Tastaturnavigation: Mit ↑ und ↓ blättern, mit Enter Treffer direkt öffnen (Notizen direkt am gefundenen Block). +
  • +
+
+ ), + }, // TRIAGE & QUICK DUMP { @@ -656,7 +692,8 @@ export const HelpView: React.FC = ({ onBack }) => { // Keyboard shortcuts table data const shortcuts = useMemo( () => [ - { key: 'C oder /', scope: 'Global', desc: 'Fokus sofort in die Schnellerfassungsleiste setzen' }, + { key: 'Cmd + K / Strg + K oder /', scope: 'Global', desc: 'Globale Volltextsuche (Command Palette) öffnen' }, + { key: 'C', scope: 'Global', desc: 'Fokus sofort in die Schnellerfassungsleiste setzen' }, { key: 'Enter', scope: 'Schnellerfassung', desc: 'Gedanken blitzschnell speichern' }, { key: 'Esc', scope: 'Schnellerfassung', desc: 'Eingabe leeren & Optionen schließen' }, { key: 'Enter', scope: 'Workspace Outliner', desc: 'Neuen Bullet-Point anlegen (oder leeren Block ausrücken)' }, From d6c70d7a8ea8b8d15e55dab8a15b37cecd0430aa Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 28 Oct 2026 23:10:27 +0100 Subject: [PATCH 5/8] =?UTF-8?q?feat(legal):=20imprint=20&=20privacy=20noti?= =?UTF-8?q?ces,=20"=C3=9Cber=20MindSift"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LEGAL_IMPRINT_/LEGAL_PRIVACY_ URL and Markdown (inline or *_FILE); only http(s) URLs accepted - /auth/status exposes legal info; public GET /legal/{imprint|privacy} - Links on login/setup screen and in the settings footer: URL opens a new tab, Markdown a reading modal; hidden when nothing is configured - "Über MindSift" modal (version, license, repository) in settings and help - Docs and compose/env examples Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 ++ Doku/api/rest.md | 3 +- Doku/architecture.md | 2 + README.md | 2 + compose.yaml | 5 ++ docker-compose.yml | 5 ++ frontend/src/components/AboutModal.tsx | 49 +++++++++++++++ frontend/src/components/LegalLinks.tsx | 63 +++++++++++++++++++ frontend/src/components/ReadingModal.tsx | 37 +++++++++++ .../src/features/auth/SetupAndAuthView.tsx | 3 + frontend/src/features/help/HelpView.tsx | 9 +++ .../features/settings/UserSettingsView.tsx | 17 ++++- frontend/src/lib/api.ts | 6 ++ frontend/src/lib/types.ts | 7 +++ internal/api/server.go | 24 +++++++ internal/api/server_test.go | 39 ++++++++++++ internal/config/config.go | 43 +++++++++++++ 17 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/AboutModal.tsx create mode 100644 frontend/src/components/LegalLinks.tsx create mode 100644 frontend/src/components/ReadingModal.tsx diff --git a/.env.example b/.env.example index 029e508..7ad4c2a 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,9 @@ SMTP_FROM=noreply@mindsift.local SMTP_TLS=true # Only for internal mail servers with self-signed certificates SMTP_SKIP_VERIFY=false + +# Optional legal notices: external link and/or Markdown (inline or as file path) +LEGAL_IMPRINT_URL= +LEGAL_PRIVACY_URL= +LEGAL_IMPRINT_CONTENT_FILE= +LEGAL_PRIVACY_CONTENT_FILE= diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 835a51d..cb29a6f 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -7,7 +7,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | Methode | Pfad | Zweck | |---|---|---| | GET | `/health` | Lebenszeichen | -| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode` | +| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) | +| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist | | POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt | | POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben | | POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` | diff --git a/Doku/architecture.md b/Doku/architecture.md index 01ddc03..77464ac 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -65,6 +65,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar | | `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) | +| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | +| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. | Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert. diff --git a/README.md b/README.md index 7d85945..852c948 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,8 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) | | `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten | +| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | +| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. | | `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** | * **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md). diff --git a/compose.yaml b/compose.yaml index 8c45312..ff56f61 100644 --- a/compose.yaml +++ b/compose.yaml @@ -27,3 +27,8 @@ services: - SMTP_FROM=${SMTP_FROM:-} - SMTP_TLS=${SMTP_TLS:-true} - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} + # Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden) + - LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-} + - LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-} + - LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-} + - LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-} diff --git a/docker-compose.yml b/docker-compose.yml index 8c45312..ff56f61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,3 +27,8 @@ services: - SMTP_FROM=${SMTP_FROM:-} - SMTP_TLS=${SMTP_TLS:-true} - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} + # Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden) + - LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-} + - LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-} + - LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-} + - LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-} diff --git a/frontend/src/components/AboutModal.tsx b/frontend/src/components/AboutModal.tsx new file mode 100644 index 0000000..f1b24d5 --- /dev/null +++ b/frontend/src/components/AboutModal.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Brain, ExternalLink } from 'lucide-react'; +import { APP_VERSION } from '../version'; +import { ReadingModal } from './ReadingModal'; + +// Project facts shown in "Über MindSift"; change here when the repository or license changes +export const PROJECT_REPO_URL = 'https://git.dsit.me/sandrodach/MindSift'; +export const PROJECT_LICENSE = 'Noch nicht festgelegt (keine LICENSE-Datei im Repository)'; + +export const AboutModal: React.FC<{ onClose: () => void }> = ({ onClose }) => ( + +
+
+
+ +
+
+

MindSift

+

Der Kopf frei in drei Sekunden.

+
+
+ +

+ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment. MindSift trennt deshalb{' '} + Erfassen und Organisieren: Gedanken + landen sofort im Quick Dump und werden später mit einem Tastendruck zu Aufgabe, Notiz oder Listeneintrag. Alles läuft + selbst gehostet in einem Container mit einer SQLite-Datei – deine Daten bleiben bei dir. +

+ +
+
Version
+
{APP_VERSION}
+
Lizenz
+
{PROJECT_LICENSE}
+
Quellcode
+
+ + {PROJECT_REPO_URL.replace(/^https:\/\//, '')} + + +
+
+ +

+ Impressum und Datenschutz dieser Instanz verantwortet ihr Betreiber; die Links stehen in den Kontoeinstellungen und auf der Anmeldeseite. +

+
+
+); diff --git a/frontend/src/components/LegalLinks.tsx b/frontend/src/components/LegalLinks.tsx new file mode 100644 index 0000000..f464fac --- /dev/null +++ b/frontend/src/components/LegalLinks.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { AuthStatus } from '../lib/types'; +import { api } from '../lib/api'; +import { MarkdownViewer } from '../lib/markdown'; +import { ReadingModal } from './ReadingModal'; + +type LegalType = 'imprint' | 'privacy'; +const TITLES: Record = { imprint: 'Impressum', privacy: 'Datenschutz' }; + +interface LegalLinksProps { + legal?: AuthStatus['legal']; + className?: string; + children?: React.ReactNode; // further footer links, e.g. "Über MindSift" +} + +// Impressum / Datenschutz links: an external URL opens in a new tab, Markdown in a reading modal. +// Links without URL or text are not shown at all. +export const LegalLinks: React.FC = ({ legal, className = '', children }) => { + const [open, setOpen] = useState<{ type: LegalType; content: string } | null>(null); + const [error, setError] = useState(null); + + const entries = (['imprint', 'privacy'] as LegalType[]) + .map((type) => ({ + type, + url: type === 'imprint' ? legal?.imprintUrl : legal?.privacyUrl, + hasContent: type === 'imprint' ? legal?.hasImprintContent : legal?.hasPrivacyContent, + })) + .filter((e) => e.url || e.hasContent); + + if (entries.length === 0 && !children) return null; + + const openText = async (type: LegalType) => { + setError(null); + try { + setOpen({ type, content: await api.getLegalText(type) }); + } catch (err: any) { + setError(err.message || 'Text konnte nicht geladen werden'); + } + }; + + return ( +
+ {entries.map((e) => + e.url ? ( + + {TITLES[e.type]} + + ) : ( + + ) + )} + {children} + {error && {error}} + {open && ( + setOpen(null)}> + + + )} +
+ ); +}; diff --git a/frontend/src/components/ReadingModal.tsx b/frontend/src/components/ReadingModal.tsx new file mode 100644 index 0000000..fd57830 --- /dev/null +++ b/frontend/src/components/ReadingModal.tsx @@ -0,0 +1,37 @@ +import React, { useEffect } from 'react'; +import { X } from 'lucide-react'; + +interface ReadingModalProps { + title: string; + onClose: () => void; + children: React.ReactNode; +} + +// Slim modal for reading text (legal notices, about); Escape or backdrop click closes it +export const ReadingModal: React.FC = ({ title, onClose, children }) => { + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
e.target === e.currentTarget && onClose()} + role="dialog" + aria-modal="true" + aria-label={title} + > +
+
+

{title}

+ +
+
{children}
+
+
+ ); +}; diff --git a/frontend/src/features/auth/SetupAndAuthView.tsx b/frontend/src/features/auth/SetupAndAuthView.tsx index 07f28b8..ed54997 100644 --- a/frontend/src/features/auth/SetupAndAuthView.tsx +++ b/frontend/src/features/auth/SetupAndAuthView.tsx @@ -14,6 +14,7 @@ import { import { AuthStatus, SharingMode, User } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; import { ToggleSwitch } from '../../components/ToggleSwitch'; +import { LegalLinks } from '../../components/LegalLinks'; interface SetupAndAuthViewProps { authStatus: AuthStatus; @@ -330,6 +331,7 @@ export const SetupAndAuthView: React.FC = ({ + ); } @@ -574,6 +576,7 @@ export const SetupAndAuthView: React.FC = ({ )} + ); }; diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 31d92f4..4e14b14 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -28,6 +28,7 @@ import { Flame, Check, } from 'lucide-react'; +import { AboutModal } from '../../components/AboutModal'; interface HelpViewProps { onBack?: () => void; @@ -55,6 +56,7 @@ interface DocCard { export const HelpView: React.FC = ({ onBack }) => { const [activeCategory, setActiveCategory] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); + const [showAbout, setShowAbout] = useState(false); // Documentation sections & cards data const cards: DocCard[] = useMemo( @@ -876,6 +878,13 @@ export const HelpView: React.FC = ({ onBack }) => { )} + +
+ +
+ {showAbout && setShowAbout(false)} />} ); }; diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 745e7b4..daf5e0a 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -20,11 +20,13 @@ import { Pencil, Copy, } from 'lucide-react'; -import { APIKey, User, UserTheme } from '../../lib/types'; +import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme'; import themeTemplateCss from '../../themes/theme-template.css?raw'; import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar'; +import { LegalLinks } from '../../components/LegalLinks'; +import { AboutModal } from '../../components/AboutModal'; interface UserSettingsViewProps { currentUser: User; @@ -50,6 +52,11 @@ export const UserSettingsView: React.FC = ({ onBack, }) => { const [section, setSection] = useState('profile'); + const [legal, setLegal] = useState(); + const [showAbout, setShowAbout] = useState(false); + useEffect(() => { + api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {}); + }, []); // --- Custom Theme Editor State --- const [themeEditId, setThemeEditId] = useState(null); // null = new theme @@ -1227,6 +1234,14 @@ export const UserSettingsView: React.FC = ({ )} + + {/* Footer: legal notices of this instance and project info */} + + + + {showAbout && setShowAbout(false)} />} ); }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 36e0b0d..a51c502 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -141,6 +141,12 @@ export const api = { return st; }, + // Public, works before login + async getLegalText(type: 'imprint' | 'privacy'): Promise { + const res = await request<{ content: string }>(`/api/v1/legal/${type}`); + return res.content || ''; + }, + async logout(): Promise { try { await request<{ success: boolean }>('/api/v1/auth/logout', { method: 'POST' }); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index bc54175..9488eff 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -8,6 +8,13 @@ export interface AuthStatus { instanceName: string; demoMode?: boolean; sharingMode?: SharingMode; + // Impressum / Datenschutz of this instance (LEGAL_* environment variables) + legal?: { + imprintUrl?: string; + hasImprintContent?: boolean; + privacyUrl?: string; + hasPrivacyContent?: boolean; + }; } // Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md) diff --git a/internal/api/server.go b/internal/api/server.go index 146d355..620ab93 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -83,6 +83,7 @@ func (s *Server) Router() http.Handler { // Public Health, Setup & Auth Routes r.Get("/api/v1/health", s.handleHealth) r.Get("/api/v1/auth/status", s.handleAuthStatus) + r.Get("/api/v1/legal/{type}", s.handleLegalText) r.Post("/api/v1/auth/setup", s.handleFirstBootSetup) r.Post("/api/v1/auth/register", s.handleRegister) r.Post("/api/v1/auth/login", s.handleLogin) @@ -458,9 +459,32 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { "instanceName": instanceName, "demoMode": s.cfg.DemoMode, "sharingMode": s.store.SharingMode(), + // Public so the login screen can link Impressum and Datenschutz + "legal": map[string]interface{}{ + "imprintUrl": s.cfg.Legal.ImprintURL, + "hasImprintContent": s.cfg.Legal.ImprintContent != "", + "privacyUrl": s.cfg.Legal.PrivacyURL, + "hasPrivacyContent": s.cfg.Legal.PrivacyContent != "", + }, }) } +// handleLegalText serves the configured Markdown of the imprint or privacy notice (public, no login). +func (s *Server) handleLegalText(w http.ResponseWriter, r *http.Request) { + content := "" + switch chi.URLParam(r, "type") { + case "imprint": + content = s.cfg.Legal.ImprintContent + case "privacy": + content = s.cfg.Legal.PrivacyContent + } + if content == "" { + writeError(w, http.StatusNotFound, "Kein Text hinterlegt") + return + } + writeJSON(w, http.StatusOK, map[string]string{"content": content}) +} + func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) { count, err := s.store.CountUsers() if err != nil || count > 0 { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 7242ac6..9c4df1b 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1366,3 +1366,42 @@ func TestFirstBootSetupWithSampleData(t *testing.T) { t.Fatalf("expected sample notes and task lists, got %d notes, %d task lists", len(docs), len(lists)) } } + +// Impressum and Datenschutz must be reachable without login; unset texts stay hidden. +func TestLegalTextsArePublic(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legal.db") + store, err := storage.NewStore(dbPath) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + defer store.Close() + cfg := &config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key", + Legal: config.LegalTexts{ImprintContent: "# Impressum\nMax Muster", PrivacyURL: "https://example.org/datenschutz"}} + router := NewServer(cfg, store, ws.NewHub(), nil).Router() + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil)) + var st struct { + Legal struct { + ImprintURL string `json:"imprintUrl"` + HasImprintContent bool `json:"hasImprintContent"` + PrivacyURL string `json:"privacyUrl"` + HasPrivacyContent bool `json:"hasPrivacyContent"` + } `json:"legal"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &st) + if !st.Legal.HasImprintContent || st.Legal.HasPrivacyContent || st.Legal.PrivacyURL != "https://example.org/datenschutz" { + t.Fatalf("unexpected legal status: %+v", st.Legal) + } + + rec = httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/legal/imprint", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Max Muster") { + t.Fatalf("imprint without login: %d %s", rec.Code, rec.Body.String()) + } + rec = httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/legal/privacy", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("privacy without text should be 404, got %d", rec.Code) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 915ce29..ebfedc5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,43 @@ type Config struct { SMTPTLS bool // SMTPSkipVerify accepts self-signed or mismatching certificates (internal mail servers) SMTPSkipVerify bool + + // Legal notices (Impressum, Datenschutz): an external URL and/or Markdown text, shown even before login + Legal LegalTexts +} + +type LegalTexts struct { + ImprintURL, ImprintContent string + PrivacyURL, PrivacyContent string +} + +// legalContent reads Markdown from NAME or, for longer texts, from the file named in NAME_FILE. +func legalContent(name string) string { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + return v + } + if path := strings.TrimSpace(os.Getenv(name + "_FILE")); path != "" { + data, err := os.ReadFile(path) + if err != nil { + slog.Warn("Rechtstext konnte nicht gelesen werden", "variable", name+"_FILE", "error", err) + return "" + } + return strings.TrimSpace(string(data)) + } + return "" +} + +// legalURL accepts only http(s) links, so a misconfiguration cannot inject javascript: URLs. +func legalURL(name string) string { + v := strings.TrimSpace(os.Getenv(name)) + if v == "" { + return "" + } + if !strings.HasPrefix(v, "https://") && !strings.HasPrefix(v, "http://") { + slog.Warn("Rechtstext-URL ignoriert: nur http(s) erlaubt", "variable", name) + return "" + } + return v } func Load() *Config { @@ -81,6 +118,12 @@ func Load() *Config { SMTPFrom: smtpFrom, SMTPTLS: smtpTLS, SMTPSkipVerify: smtpSkipVerify, + Legal: LegalTexts{ + ImprintURL: legalURL("LEGAL_IMPRINT_URL"), + ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"), + PrivacyURL: legalURL("LEGAL_PRIVACY_URL"), + PrivacyContent: legalContent("LEGAL_PRIVACY_CONTENT"), + }, } } From e509f685c1e49eb073ea2ce63923dcec3ed45a67 Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 28 Oct 2026 23:21:20 +0100 Subject: [PATCH 6/8] feat(export): Obsidian-compatible Markdown vault as streamed ZIP - GET /export/markdown streams the workspace as ZIP (no temp files): notes as .md with YAML front matter in the user's folder structure (shared notes and mounted folders included, empty folders kept) - Clean Markdown: top-level headings/dividers/callouts/code as real blocks (their children move up a level), lists with tabs, todos as - [ ] - Aufgaben/.md in Obsidian Tasks format (due date, priority, subtasks, notes), Listen/.md for shopping and other lists - Safe, unique file names; download button in settings (mobile-safe) Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 1 + Doku/modules/workspace.md | 11 + .../features/settings/UserSettingsView.tsx | 55 ++- frontend/src/lib/api.ts | 11 + internal/api/server.go | 12 + internal/storage/export_markdown.go | 321 ++++++++++++++++++ internal/storage/export_markdown_test.go | 126 +++++++ 7 files changed, 531 insertions(+), 6 deletions(-) create mode 100644 internal/storage/export_markdown.go create mode 100644 internal/storage/export_markdown_test.go diff --git a/Doku/api/rest.md b/Doku/api/rest.md index cb29a6f..b6db0c3 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -31,6 +31,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | GET | `/stats` | Zähler fürs Dashboard | | GET | `/search?q=` | Suche in eigenen Gedanken und Notizen | | GET | `/export` | Datenexport als JSON | +| GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/.md` | | GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) | | DELETE | `/themes/{id}` | Eigenes Theme löschen | | GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) | diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 6127a75..e775e8f 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -51,3 +51,14 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie. - Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*. - Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen ([FLAW-18](../flaws.md)). + +## Export als Markdown-Archiv + +Kontoeinstellungen → Private Webhooks & Export → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)). + +- Ordner und Notizen so, wie der Nutzer sie sieht (eigene Ordner, eingehängte geteilte Ordner, mit ihm geteilte Notizen); leere Ordner bleiben erhalten. +- Jede Notiz: YAML-Frontmatter (`title`, `icon`, `tags`, `created_at`, `updated_at`) und Markdown, das Obsidian direkt darstellt: Überschriften, Trenner, Hinweisboxen (`> [!note]`) und Codeblöcke der obersten Ebene als echte Blöcke, ihre Kinder rücken eine Ebene nach vorn; alles andere als Liste mit einem Tab pro Ebene, To-dos als `- [ ]`. +- Dateinamen werden für alle Betriebssysteme bereinigt (keine `/ \ : * ? " < > |`, nie `.`/`..`); doppelte Titel bekommen „ (2)“. +- Das ZIP wird beim Lesen der Daten direkt in die Antwort geschrieben, ohne temporäre Dateien. +- Ein Import aus ZIP gibt es noch nicht. + diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index daf5e0a..41ad376 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -362,17 +362,38 @@ export const UserSettingsView: React.FC = ({ setTimeout(() => setCopiedLabel(null), 2000); }; + // Works on mobile browsers too: the link must be in the document and the URL alive until the click ran + const saveBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 10000); + }; + const handleExportData = async () => { const data = await api.exportAll(); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8', }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`; - a.click(); - URL.revokeObjectURL(url); + saveBlob(blob, `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`); + }; + + const [exportingVault, setExportingVault] = useState(false); + const [vaultError, setVaultError] = useState(null); + const handleExportVault = async () => { + setExportingVault(true); + setVaultError(null); + try { + saveBlob(await api.exportMarkdownVault(), `mindsift-vault-${new Date().toISOString().slice(0, 10)}.zip`); + } catch (err: any) { + setVaultError(err.message || 'Export fehlgeschlagen'); + } finally { + setExportingVault(false); + } }; const baseUrl = window.location.origin; @@ -1230,6 +1251,28 @@ export const UserSettingsView: React.FC = ({ Backup herunterladen + + {/* Obsidian-compatible Markdown vault */} +
+
+

Markdown-Archiv für Obsidian (.zip)

+

+ Alle Notizen als .md-Dateien in deiner Ordnerstruktur (mit Titel, Tags und + Datum im Kopf), Aufgaben je Liste unter Aufgaben/ im Format des + Obsidian-Tasks-Plugins, Listen unter Listen/. Mit dir geteilte Notizen sind + dabei. +

+ {vaultError &&

{vaultError}

} +
+ +
)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a51c502..af20c22 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1074,6 +1074,17 @@ export const api = { async exportAll(): Promise { return request('/api/v1/export'); }, + + // Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout + async exportMarkdownVault(): Promise { + const token = getToken(); + const res = await fetch('/api/v1/export/markdown', { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + cache: 'no-store', + }); + if (!res.ok) throw new Error(`Export fehlgeschlagen (HTTP ${res.status})`); + return res.blob(); + }, }; export function getOfflineQueue(): Thought[] { diff --git a/internal/api/server.go b/internal/api/server.go index 620ab93..bd68973 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -109,6 +109,7 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/stats", s.handleStats) r.Get("/api/v1/search", s.handleSearch) r.Get("/api/v1/export", s.handleExport) + r.Get("/api/v1/export/markdown", s.handleExportMarkdown) r.Get("/api/v1/themes", s.handleGetThemes) r.Post("/api/v1/themes", s.handleSaveTheme) r.Delete("/api/v1/themes/{id}", s.handleDeleteTheme) @@ -2117,6 +2118,17 @@ func (s *Server) handleDeleteTheme(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } +// handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files). +func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-vault-%s.zip"`, time.Now().Format("2006-01-02"))) + if err := s.store.WriteMarkdownVault(userID, w); err != nil { + // Headers are already sent; the client gets a truncated archive + slog.Error("Markdown-Export fehlgeschlagen", "user_id", userID, "error", err) + } +} + func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) exportData, err := s.store.ExportUserData(userID) diff --git a/internal/storage/export_markdown.go b/internal/storage/export_markdown.go new file mode 100644 index 0000000..aa5283b --- /dev/null +++ b/internal/storage/export_markdown.go @@ -0,0 +1,321 @@ +package storage + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "path" + "sort" + "strings" + "time" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// WriteMarkdownVault streams the user's workspace as an Obsidian-compatible ZIP straight into w: +// notes as .md files in their folders (as this user sees them, shared ones included), task lists +// under Aufgaben/, shopping and other lists under Listen/. Nothing is buffered on disk. +func (s *Store) WriteMarkdownVault(userID string, w io.Writer) error { + zw := zip.NewWriter(w) + names := newVaultNames() + now := time.Now() + + // Folders first, so empty ones survive the round trip + if folders, err := s.GetFolders(userID); err == nil { + for _, f := range folders { + if dir := names.dir(f); dir != "" { + if _, err := zw.CreateHeader(&zip.FileHeader{Name: dir + "/", Modified: now}); err != nil { + return err + } + } + } + } + + docs, err := s.GetDocuments(userID) + if err != nil { + return err + } + for _, d := range docs { + name := names.file(names.dir(d.FolderPath), d.Title, "Unbenannte Notiz") + body := markdownFrontmatter(map[string]any{ + "title": d.Title, "icon": d.Icon, "tags": d.Tags, "created_at": d.CreatedAt, "updated_at": d.UpdatedAt, + }) + BlocksToVaultMarkdown(d.Blocks) + if err := writeZipFile(zw, name, body, parseISO(d.UpdatedAt, now)); err != nil { + return err + } + } + + if err := s.writeVaultTasks(zw, userID, names, now); err != nil { + return err + } + if err := s.writeVaultLists(zw, userID, names, now); err != nil { + return err + } + return zw.Close() +} + +func (s *Store) writeVaultTasks(zw *zip.Writer, userID string, names *vaultNames, now time.Time) error { + tasks, err := s.GetTasks(userID, "") + if err != nil { + return err + } + lists, _ := s.GetTaskLists(userID) + byList := make(map[string][]domain.Thought) + for _, t := range tasks { + byList[t.TaskListID] = append(byList[t.TaskListID], t) + } + write := func(title string, tl *domain.TaskList, items []domain.Thought) error { + sort.SliceStable(items, func(i, j int) bool { return items[i].SortOrder < items[j].SortOrder }) + meta := map[string]any{"title": title} + if tl != nil { + meta["icon"], meta["created_at"], meta["updated_at"] = tl.Icon, tl.CreatedAt, tl.UpdatedAt + } + var sb strings.Builder + sb.WriteString(markdownFrontmatter(meta)) + for _, t := range items { + sb.WriteString(taskMarkdownLine(t)) + } + return writeZipFile(zw, names.file("Aufgaben", title, "Aufgaben"), sb.String(), now) + } + for i := range lists { + if err := write(lists[i].Title, &lists[i], byList[lists[i].ID]); err != nil { + return err + } + } + if items := byList[""]; len(items) > 0 { + return write("Ohne Liste", nil, items) + } + return nil +} + +func (s *Store) writeVaultLists(zw *zip.Writer, userID string, names *vaultNames, now time.Time) error { + lists, err := s.GetAllListsForUser(userID) + if err != nil { + return err + } + for _, l := range lists { + var sb strings.Builder + sb.WriteString(markdownFrontmatter(map[string]any{"title": l.Title, "icon": l.Icon, "created_at": l.CreatedAt, "updated_at": l.UpdatedAt})) + for _, it := range l.Items { + mark := "[ ]" + if it.IsCompleted { + mark = "[x]" + } + line := "- " + mark + " " + oneLine(it.Content) + if it.Quantity != "" { + line += " (" + oneLine(it.Quantity) + ")" + } + if it.Category != "" && !strings.Contains(it.Content, "#") { + line += " #" + strings.ReplaceAll(it.Category, " ", "_") + } + sb.WriteString(line + "\n") + } + if err := writeZipFile(zw, names.file("Listen", l.Title, "Liste"), sb.String(), parseISO(l.UpdatedAt, now)); err != nil { + return err + } + } + return nil +} + +// taskMarkdownLine writes a task in the format of the Obsidian Tasks plugin (📅 due date, ⏫/🔺 priority) +// with its subtasks and notes indented below. +func taskMarkdownLine(t domain.Thought) string { + mark := "[ ]" + if t.IsCompleted { + mark = "[x]" + } + line := "- " + mark + " " + oneLine(t.Content) + switch t.Priority { + case "urgent": + line += " 🔺" + case "high": + line += " ⏫" + case "low": + line += " 🔽" + } + if t.DueDate != "" { + line += " 📅 " + t.DueDate + } + var sb strings.Builder + sb.WriteString(line + "\n") + for _, st := range t.Subtasks { + sub := "[ ]" + if st.IsCompleted { + sub = "[x]" + } + sb.WriteString("\t- " + sub + " " + oneLine(st.Title) + "\n") + } + if notes := strings.TrimSpace(t.Notes); notes != "" { + for _, l := range strings.Split(notes, "\n") { + sb.WriteString("\t> " + strings.TrimRight(l, "\r") + "\n") + } + } + return sb.String() +} + +// BlocksToVaultMarkdown turns outliner blocks into Markdown that Obsidian renders natively: +// top-level headings, dividers and callouts as real block elements, everything else as a nested +// list (one tab per level), code as fenced blocks inside the list. +func BlocksToVaultMarkdown(blocks []domain.DocumentBlock) string { + var sb strings.Builder + // Children of a top-level heading/callout/code/divider become a list one level up; otherwise their + // tab indent would turn them into an indented code block in Markdown + lift := 0 + for _, b := range blocks { + lvl := b.Level + if lvl <= 0 { + lvl, lift = 0, 0 + } else if lvl -= lift; lvl < 0 { + lvl = 0 + } + indent := strings.Repeat("\t", lvl) + content := strings.ReplaceAll(b.Content, "\r\n", "\n") + // Soft line breaks continue the list item, aligned under its text + cont := func(text, prefix string) string { + return strings.ReplaceAll(text, "\n", "\n"+indent+prefix) + } + + if b.Level <= 0 { + switch b.Type { + case "h1", "h2", "h3": + hashes := map[string]string{"h1": "#", "h2": "##", "h3": "###"}[b.Type] + sb.WriteString("\n" + hashes + " " + oneLine(content) + "\n\n") + lift = 1 + continue + case "divider": + sb.WriteString("\n---\n\n") + lift = 1 + continue + case "callout": + sb.WriteString("\n> [!note]\n> " + strings.ReplaceAll(content, "\n", "\n> ") + "\n\n") + lift = 1 + continue + case "code": + sb.WriteString("\n```" + b.Lang + "\n" + content + "\n```\n\n") + lift = 1 + continue + } + } + switch b.Type { + case "code": + // Fenced block nested under the previous list item + sb.WriteString(indent + "```" + b.Lang + "\n" + indent + strings.ReplaceAll(content, "\n", "\n"+indent) + "\n" + indent + "```\n") + case "todo": + mark := "[ ]" + if b.Checked { + mark = "[x]" + } + sb.WriteString(indent + "- " + mark + " " + cont(content, " ") + "\n") + case "h1", "h2", "h3": + hashes := map[string]string{"h1": "#", "h2": "##", "h3": "###"}[b.Type] + sb.WriteString(indent + "- " + hashes + " " + oneLine(content) + "\n") + case "divider": + sb.WriteString(indent + "- ---\n") + case "callout": + sb.WriteString(indent + "- > " + cont(content, " > ") + "\n") + default: + if strings.TrimSpace(content) == "" { + sb.WriteString(indent + "-\n") + } else { + sb.WriteString(indent + "- " + cont(content, " ") + "\n") + } + } + } + return strings.TrimLeft(sb.String(), "\n") +} + +// markdownFrontmatter writes YAML front matter; JSON-encoded strings are valid YAML scalars. +func markdownFrontmatter(meta map[string]any) string { + keys := make([]string, 0, len(meta)) + for k := range meta { + keys = append(keys, k) + } + order := map[string]int{"title": 0, "icon": 1, "tags": 2, "created_at": 3, "updated_at": 4} + sort.Slice(keys, func(i, j int) bool { return order[keys[i]] < order[keys[j]] }) + var sb strings.Builder + sb.WriteString("---\n") + for _, k := range keys { + switch v := meta[k].(type) { + case string: + if v != "" { + q, _ := json.Marshal(v) + sb.WriteString(k + ": " + string(q) + "\n") + } + case []string: + if len(v) > 0 { + sb.WriteString(k + ":\n") + for _, t := range v { + q, _ := json.Marshal(t) + sb.WriteString(" - " + string(q) + "\n") + } + } + } + } + sb.WriteString("---\n\n") + return sb.String() +} + +func writeZipFile(zw *zip.Writer, name, body string, modified time.Time) error { + f, err := zw.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Deflate, Modified: modified}) + if err != nil { + return err + } + _, err = io.WriteString(f, body) + return err +} + +func oneLine(s string) string { + return strings.Join(strings.Fields(strings.ReplaceAll(s, "\n", " ")), " ") +} + +func parseISO(v string, fallback time.Time) time.Time { + if t, err := time.Parse(time.RFC3339, v); err == nil { + return t + } + return fallback +} + +// vaultNames builds safe, unique file and folder names inside the archive. +type vaultNames struct{ used map[string]bool } + +func newVaultNames() *vaultNames { return &vaultNames{used: make(map[string]bool)} } + +// segment makes one path part safe on Windows, macOS and Linux and never "." or "..". +func segment(s string) string { + s = strings.Map(func(r rune) rune { + if r < 32 || strings.ContainsRune(`/\:*?"<>|`, r) { + return '_' + } + return r + }, strings.TrimSpace(s)) + s = strings.Trim(s, ". ") + if len([]rune(s)) > 100 { + s = string([]rune(s)[:100]) + } + return s +} + +func (n *vaultNames) dir(folderPath string) string { + parts := make([]string, 0) + for _, p := range strings.Split(folderPath, "/") { + if seg := segment(p); seg != "" { + parts = append(parts, seg) + } + } + return path.Join(parts...) +} + +// file returns "dir/title.md", adding " (2)", " (3)" … when the name is already taken. +func (n *vaultNames) file(dir, title, fallback string) string { + base := segment(title) + if base == "" { + base = fallback + } + name := path.Join(dir, base+".md") + for i := 2; n.used[strings.ToLower(name)]; i++ { + name = path.Join(dir, fmt.Sprintf("%s (%d).md", base, i)) + } + n.used[strings.ToLower(name)] = true + return name +} diff --git a/internal/storage/export_markdown_test.go b/internal/storage/export_markdown_test.go new file mode 100644 index 0000000..4e7433d --- /dev/null +++ b/internal/storage/export_markdown_test.go @@ -0,0 +1,126 @@ +package storage + +import ( + "archive/zip" + "bytes" + "io" + "path/filepath" + "strings" + "testing" + + "github.com/sandrodach/mindsift/internal/domain" +) + +func TestBlocksToVaultMarkdown(t *testing.T) { + md := BlocksToVaultMarkdown([]domain.DocumentBlock{ + {Type: "h2", Content: "Warum", Level: 0}, + {Type: "bullet", Content: "Strom sparen", Level: 1}, + {Type: "bullet", Content: "120 € im Jahr", Level: 2}, + {Type: "h1", Content: "Umzug", Level: 0}, + {Type: "bullet", Content: "Server", Level: 0}, + {Type: "todo", Content: "Backup ziehen", Checked: true, Level: 1}, + {Type: "bullet", Content: "Zeile 1\nZeile 2", Level: 1}, + {Type: "code", Content: "docker compose up -d", Lang: "bash", Level: 2}, + {Type: "divider", Level: 0}, + {Type: "code", Content: "echo hi", Lang: "sh", Level: 0}, + }) + want := []string{ + // children of a heading start a list at the margin, not an indented code block + "## Warum\n\n- Strom sparen\n\t- 120 € im Jahr\n", + "# Umzug\n\n- Server\n", + "\t- [x] Backup ziehen\n", + "\t- Zeile 1\n\t Zeile 2\n", + "\t\t```bash\n\t\tdocker compose up -d\n\t\t```\n", + "\n---\n\n", + "```sh\necho hi\n```\n", + } + for _, w := range want { + if !strings.Contains(md, w) { + t.Errorf("missing %q in:\n%s", w, md) + } + } +} + +func TestWriteMarkdownVault(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "vault.db")) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + defer store.Close() + openSharing(t, store) + anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member") + ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member") + + if _, err := store.CreateFolder(anna.ID, "Projekte/Leer"); err != nil { + t.Fatalf("CreateFolder: %v", err) + } + blocks := []domain.DocumentBlock{{ID: "b1", Type: "bullet", Content: "Inhalt", Level: 0}} + // Note tags come from #hashtags in the text + tagged := []domain.DocumentBlock{{ID: "b0", Type: "bullet", Content: "Inhalt #server", Level: 0}} + _, _ = store.CreateDocument(anna.ID, "", "Homelab", "🖥️", "Projekte", tagged, nil, false) + _, _ = store.CreateDocument(anna.ID, "", "Homelab", "🖥️", "Projekte", blocks, nil, false) + _, _ = store.CreateDocument(anna.ID, "", "../../etc/passwd", "", "", blocks, nil, false) + shared, _ := store.CreateDocument(ben.ID, "", "Von Ben", "", "", blocks, nil, false) + _, _ = store.CreateDocument(ben.ID, "", "Bens Geheimnis", "", "", blocks, nil, false) + if _, err := store.CreateDocShare(ben.ID, shared.ID, anna.ID, "read"); err != nil { + t.Fatalf("share: %v", err) + } + + tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "🏠", "") + task, _ := store.CreateThoughtWithDetails(anna.ID, "Fenster putzen", "high", "manual", "2026-11-02", tl.ID, "todo", "") + _, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "2026-11-02", tl.ID) + subs := []domain.SubTask{{ID: "s1", Title: "Leiter holen", IsCompleted: true}} + _, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, nil, nil, nil, nil, nil, nil, &subs, nil) + list, _ := store.CreateList(anna.ID, "Einkauf", "🛒", "shopping") + _, _ = store.CreateListItem(list.ID, "Milch", "Kühlregal", "2L", 0, anna.ID) + + var buf bytes.Buffer + if err := store.WriteMarkdownVault(anna.ID, &buf); err != nil { + t.Fatalf("WriteMarkdownVault: %v", err) + } + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil { + t.Fatalf("not a zip: %v", err) + } + files := map[string]string{} + for _, f := range zr.File { + for _, part := range strings.Split(f.Name, "/") { + if part == ".." || part == "." || strings.HasPrefix(f.Name, "/") { + t.Errorf("unsafe path in archive: %q", f.Name) + } + } + rc, _ := f.Open() + data, _ := io.ReadAll(rc) + rc.Close() + files[f.Name] = string(data) + } + + for _, name := range []string{"Projekte/Leer/", "Projekte/Homelab.md", "Projekte/Homelab (2).md", "Von Ben.md", "Aufgaben/Haushalt.md", "Listen/Einkauf.md"} { + if _, ok := files[name]; !ok { + t.Errorf("missing %q; archive has %v", name, keys(files)) + } + } + for name := range files { + if strings.Contains(name, "Geheimnis") { + t.Errorf("Ben's unshared note must not be exported: %q", name) + } + } + tagged1, tagged2 := files["Projekte/Homelab.md"], files["Projekte/Homelab (2).md"] + if !strings.Contains(tagged1+tagged2, "---\ntitle: \"Homelab\"\nicon: \"🖥️\"\ntags:\n - \"server\"\n") { + t.Errorf("frontmatter wrong:\n%s\n--- (2):\n%s", files["Projekte/Homelab.md"], files["Projekte/Homelab (2).md"]) + } + if want := "- [ ] Fenster putzen ⏫ 📅 2026-11-02\n\t- [x] Leiter holen\n"; !strings.Contains(files["Aufgaben/Haushalt.md"], want) { + t.Errorf("task line wrong:\n%s", files["Aufgaben/Haushalt.md"]) + } + if !strings.Contains(files["Listen/Einkauf.md"], "- [ ] Milch (2L) #Kühlregal") { + t.Errorf("list item wrong:\n%s", files["Listen/Einkauf.md"]) + } +} + +func keys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} From b07a7acf4281a9e4d2798150bf2d3361c471b5b2 Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 28 Oct 2026 23:21:20 +0100 Subject: [PATCH 7/8] fix(ui): tab counters sit on the label like in the nav bar Co-Authored-By: Claude Opus 5.5 --- Doku/ui-patterns.md | 2 +- .../src/features/lists/SharedListsView.tsx | 11 +++++++---- frontend/src/features/triage/TasksView.tsx | 19 ++++++++++++------- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index 0c2e1f5..6fe68b2 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -49,7 +49,7 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla - `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü. - Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an. - **Position (`position`):** - - `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. So in der Navigation und an den Tabs. Zeilen mit solchen Tabs brauchen etwas Abstand (`gap-x-3`, `pt-2`), damit der Kreis nicht in den Nachbar-Tab ragt. + - `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-2`, solange ein Zähler da ist), der Kreis bekommt `-top-1.5 -right-1`. - `inline`: im Textfluss. - Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken. - **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben. diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 0e0a28f..372d47c 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -843,8 +843,8 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
{/* Top Header: List Tabs & Actions (Desktop) */}
- {/* List Tabs (counters sit on the top right corner, hence the extra gap) */} -
+ {/* List Tabs */} +
{lists.map((lst) => { const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length; const isSelected = activeList?.id === lst.id; @@ -860,7 +860,11 @@ export const SharedListsView: React.FC = ({ onRefreshGloba className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn" > {lst.icon} - {lst.title} + {/* Counter on the label, like in the nav bar */} + 0 ? 'pr-2' : ''}`}> + {lst.title} + + = ({ onRefreshGloba className="ml-0.5" /> - {isSelected && lists.length > 1 && ( {safeTaskLists.map((tl) => ( @@ -1399,7 +1402,10 @@ export const TasksView: React.FC = ({ className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn" > {tl.icon} - {tl.title} + + {tl.title} + + {(() => { const isShared = (tl.shares && tl.shares.length > 0) || (tl.members && tl.members.length > 1); @@ -1422,7 +1428,6 @@ export const TasksView: React.FC = ({ ); })()} - {selectedListId === tl.id && (