From 8b043c48099bbdca45f261cb25763f5bf50c1ca5 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 21:20:57 +0100 Subject: [PATCH 01/19] fix(ui): prevent modal overlays from inheriting margin-top from parent space-y containers --- frontend/src/App.tsx | 6 +++--- frontend/src/components/MobileHeaderMenu.tsx | 4 ++-- frontend/src/components/UnifiedShareModal.tsx | 2 +- frontend/src/features/teams/GroupsView.tsx | 2 +- frontend/src/features/triage/ManageColumnsModal.tsx | 2 +- frontend/src/features/triage/TaskDetailModal.tsx | 2 +- frontend/src/features/triage/TasksView.tsx | 2 +- frontend/src/features/triage/TriageDeck.tsx | 2 +- frontend/src/features/workspace/WorkspaceView.tsx | 4 ++-- frontend/src/index.css | 11 +++++++++++ 10 files changed, 24 insertions(+), 13 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b250bac..8192b32 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1018,7 +1018,7 @@ export const App: React.FC = () => { {/* MOBILE QUICK-ADD BOTTOM SHEET / MODAL */} {showMobileCapture && (
setShowMobileCapture(false)} >
{ {/* MOBILE USER PROFILE SHEET */} {showMobileProfile && (
setShowMobileProfile(false)} >
{ {/* IMMEDIATE MOBILE WEB-APP INSTALLATION OFFER SHEET */} {showPwaInstallModal && !isStandaloneApp && (
= ({ activeTabLab if (!isMenuOpen) return null; return ( -
+
{/* Backdrop */}
{ triggerHaptic(8); closeMenu(); diff --git a/frontend/src/components/UnifiedShareModal.tsx b/frontend/src/components/UnifiedShareModal.tsx index 3671ae3..d4e290e 100644 --- a/frontend/src/components/UnifiedShareModal.tsx +++ b/frontend/src/components/UnifiedShareModal.tsx @@ -96,7 +96,7 @@ export const UnifiedShareModal: React.FC = ({ return (
{ if (e.target === e.currentTarget) onClose(); }} diff --git a/frontend/src/features/teams/GroupsView.tsx b/frontend/src/features/teams/GroupsView.tsx index 7273072..7238ad2 100644 --- a/frontend/src/features/teams/GroupsView.tsx +++ b/frontend/src/features/teams/GroupsView.tsx @@ -252,7 +252,7 @@ export const GroupsView: React.FC = ({ currentUser, onBack }) = {/* Modal: Neue Gruppe erstellen */} {showCreateModal && (
setShowCreateModal(false)} >
= ({ }; return ( -
+
e.stopPropagation()} diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index 58709b8..93f9fd3 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -163,7 +163,7 @@ export const TaskDetailModal: React.FC = ({ const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0; return ( -
+
e.stopPropagation()} diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index df53be1..4d319be 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -1488,7 +1488,7 @@ export const TasksView: React.FC = ({ {/* NEW LIST MODAL */} {showNewListModal && ( -
+

diff --git a/frontend/src/features/triage/TriageDeck.tsx b/frontend/src/features/triage/TriageDeck.tsx index d9b462f..22aa1f4 100644 --- a/frontend/src/features/triage/TriageDeck.tsx +++ b/frontend/src/features/triage/TriageDeck.tsx @@ -811,7 +811,7 @@ export const TriageDeck: React.FC = ({ {/* LONG-PRESS / MORE OPTIONS ACTION SHEET (For Triage Card & Queue Items) */} {actionSheetCard && (
setActionSheetCard(null)} >
= ({ {/* Mobile Backdrop for File Tree Slide-Over Drawer */} {showMobileFileDrawer && (
setShowMobileFileDrawer(false)} /> )} @@ -4820,7 +4820,7 @@ export const WorkspaceView: React.FC = ({ {/* LINK INSERTION MODAL (/link, /notiz, /web) */} {linkModalBlockId && (
{ const blockId = linkModalBlockId; const wasEditingLink = Boolean(editingLinkOriginalRaw); diff --git a/frontend/src/index.css b/frontend/src/index.css index 429f2fc..9b5c1c4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -348,10 +348,21 @@ body { /* Modal-Hintergrund-Overlay */ .modal-overlay { + margin: 0 !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; background-color: var(--bg-overlay); backdrop-filter: blur(4px); } + /* Verhindert, dass Elterncontainer mit space-y-* oder space-x-* feste Overlays nach unten verschieben */ + .fixed.inset-0, + [class*="fixed inset-0"] { + margin: 0 !important; + } + /* File Tree Item */ .tree-node-active { background-color: var(--bg-panel-elevated); -- 2.54.0 From 444442fdf0747f91826cb1cea1582a7e2baf0025 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 21:37:06 +0100 Subject: [PATCH 02/19] feat: integrate user profile pictures with circular avatar display and upload in settings --- frontend/src/App.tsx | 28 ++-- frontend/src/components/UnifiedShareModal.tsx | 22 ++- frontend/src/components/UserAvatar.tsx | 157 ++++++++++++++++++ frontend/src/features/UserAutocomplete.tsx | 9 +- .../features/settings/AdminSettingsView.tsx | 22 ++- .../features/settings/UserSettingsView.tsx | 121 +++++++++++++- frontend/src/features/teams/GroupsView.tsx | 5 +- frontend/src/features/triage/TasksView.tsx | 9 +- frontend/src/lib/api.ts | 1 + frontend/src/lib/types.ts | 6 + internal/api/server.go | 2 + internal/domain/models.go | 4 + internal/storage/sqlite.go | 46 ++--- 13 files changed, 367 insertions(+), 65 deletions(-) create mode 100644 frontend/src/components/UserAvatar.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8192b32..1ffc867 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -46,6 +46,7 @@ import { HelpView } from './features/help/HelpView'; import { GroupsView } from './features/teams/GroupsView'; import { useMobileActions } from './context/MobileActionContext'; import { MobileHeaderMenu } from './components/MobileHeaderMenu'; +import { UserAvatar } from './components/UserAvatar'; type ActiveTab = | 'triage' @@ -588,9 +589,7 @@ export const App: React.FC = () => { }`} title="Profil & Einstellungen" > -
- {user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'} -
+
@@ -632,9 +631,7 @@ export const App: React.FC = () => { className="h-8 flex items-center gap-2 text-xs font-medium text-main hover:opacity-85 transition-opacity cursor-pointer group" title="Benutzermenü öffnen" > -
- {user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'} -
+ {user.displayName} @@ -644,12 +641,15 @@ export const App: React.FC = () => { {showProfileMenu && (
{/* User Info Header */} -
-

{user.displayName}

-

{user.email}

- - {roleLabel} - +
+ +
+

{user.displayName}

+

{user.email}

+ + {roleLabel} + +
{/* Theme Quick Switcher */} @@ -1070,9 +1070,7 @@ export const App: React.FC = () => { {/* User Info Header */}
-
- {user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'} -
+

{user.displayName}

{user.email}

diff --git a/frontend/src/components/UnifiedShareModal.tsx b/frontend/src/components/UnifiedShareModal.tsx index d4e290e..77d8dba 100644 --- a/frontend/src/components/UnifiedShareModal.tsx +++ b/frontend/src/components/UnifiedShareModal.tsx @@ -10,6 +10,7 @@ import { } from 'lucide-react'; import { User, Team, UnifiedShare } from '../lib/types'; import { UserAutocomplete } from '../features/UserAutocomplete'; +import { UserAvatar } from './UserAvatar'; export interface UnifiedShareModalProps { isOpen: boolean; @@ -284,15 +285,18 @@ export const UnifiedShareModal: React.FC = ({ className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs hover:border-border/80 transition-colors" >
-
- {isTeam ? : } -
+ {isTeam ? ( +
+ +
+ ) : ( + c.id === sh.targetId)?.avatarUrl} + name={sh.targetName || sh.targetEmail} + size="sm" + className="border border-border/80" + /> + )}
{sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')} diff --git a/frontend/src/components/UserAvatar.tsx b/frontend/src/components/UserAvatar.tsx new file mode 100644 index 0000000..b0110be --- /dev/null +++ b/frontend/src/components/UserAvatar.tsx @@ -0,0 +1,157 @@ +import React, { useState } from 'react'; + +export interface UserAvatarProps { + user?: { + displayName?: string; + email?: string; + avatarUrl?: string; + } | null; + name?: string; + avatarUrl?: string; + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + className?: string; + alt?: string; +} + +const SIZE_MAP = { + xs: 'w-5 h-5 text-[10px]', + sm: 'w-7 h-7 text-xs', + md: 'w-9 h-9 text-sm', + lg: 'w-12 h-12 text-base', + xl: 'w-20 h-20 text-2xl', +}; + +const BG_COLORS = [ + 'bg-primary/20 text-primary border-primary/30', + 'bg-secondary/20 text-secondary border-secondary/30', + 'bg-accent/20 text-accent border-accent/30', + 'bg-info/20 text-info border-info/30', + 'bg-success/20 text-success border-success/30', + 'bg-warning/20 text-warning border-warning/30', +]; + +function getInitials(name?: string, email?: string): string { + const cleanName = (name || '').trim(); + if (cleanName) { + const parts = cleanName.split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return cleanName.slice(0, 2).toUpperCase(); + } + const cleanEmail = (email || '').trim(); + if (cleanEmail) { + return cleanEmail.slice(0, 2).toUpperCase(); + } + return '?'; +} + +function getColorIndex(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + return Math.abs(hash) % BG_COLORS.length; +} + +export function isEmoji(str?: string): boolean { + if (!str) return false; + const trimmed = str.trim(); + // Simple heuristic: length <= 8 and not a URL or base64 image + return !trimmed.startsWith('data:') && !trimmed.startsWith('http') && !trimmed.startsWith('/') && trimmed.length <= 8; +} + +export const UserAvatar: React.FC = ({ + user, + name, + avatarUrl, + size = 'md', + className = '', + alt, +}) => { + const [imageError, setImageError] = useState(false); + + const effectiveAvatarUrl = avatarUrl !== undefined ? avatarUrl : user?.avatarUrl; + const effectiveName = name !== undefined ? name : (user?.displayName || user?.email || ''); + const effectiveEmail = user?.email || ''; + + const sizeClass = SIZE_MAP[size] || SIZE_MAP.md; + const initials = getInitials(effectiveName, effectiveEmail); + const colorIndex = getColorIndex(effectiveName || effectiveEmail || 'user'); + const colorClass = BG_COLORS[colorIndex]; + + const hasImage = Boolean( + effectiveAvatarUrl && + !imageError && + (effectiveAvatarUrl.startsWith('data:image/') || + effectiveAvatarUrl.startsWith('http://') || + effectiveAvatarUrl.startsWith('https://') || + effectiveAvatarUrl.startsWith('/')) + ); + + const isEmojiAvatar = Boolean(effectiveAvatarUrl && isEmoji(effectiveAvatarUrl)); + + return ( +
+ {hasImage ? ( + {alt setImageError(true)} + className="w-full h-full object-cover rounded-full" + /> + ) : isEmojiAvatar ? ( +
+ {effectiveAvatarUrl} +
+ ) : ( +
+ {initials} +
+ )} +
+ ); +}; + +/** + * Resizes and center-crops an uploaded image file to a compact base64 Data URL. + */ +export async function resizeImageToDataUrl(file: File, maxSize = 256): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('Fehler beim Lesen der Datei')); + reader.onload = (e) => { + const img = new Image(); + img.onerror = () => reject(new Error('Fehler beim Laden des Bildes')); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = maxSize; + canvas.height = maxSize; + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('Canvas Context konnte nicht erstellt werden')); + return; + } + + // Center crop calculation + const minDim = Math.min(img.width, img.height); + const sx = (img.width - minDim) / 2; + const sy = (img.height - minDim) / 2; + + ctx.drawImage(img, sx, sy, minDim, minDim, 0, 0, maxSize, maxSize); + + // Try webp first, fallback to jpeg + let dataUrl = canvas.toDataURL('image/webp', 0.85); + if (!dataUrl.startsWith('data:image/webp')) { + dataUrl = canvas.toDataURL('image/jpeg', 0.85); + } + resolve(dataUrl); + }; + img.src = e.target?.result as string; + }; + reader.readAsDataURL(file); + }); +} diff --git a/frontend/src/features/UserAutocomplete.tsx b/frontend/src/features/UserAutocomplete.tsx index b818b01..c3bb558 100644 --- a/frontend/src/features/UserAutocomplete.tsx +++ b/frontend/src/features/UserAutocomplete.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Search, X, Check } from 'lucide-react'; import { User } from '../lib/types'; +import { UserAvatar } from '../components/UserAvatar'; interface UserAutocompleteProps { users: User[]; @@ -101,9 +102,7 @@ export const UserAutocomplete: React.FC = ({ {selectedUser ? (
-
- {(selectedUser.displayName || selectedUser.email || '?')[0].toUpperCase()} -
+ {selectedUser.displayName || selectedUser.email} @@ -170,9 +169,7 @@ export const UserAutocomplete: React.FC = ({ }`} >
-
- {(u.displayName || u.email || '?')[0].toUpperCase()} -
+
{u.displayName || u.email} diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index d76aee3..55c0936 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -21,6 +21,7 @@ import { UserRole, } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; +import { UserAvatar } from '../../components/UserAvatar'; interface AdminSettingsViewProps { currentUser: User; @@ -556,16 +557,19 @@ export const AdminSettingsView: React.FC = ({ {users.map((u) => (
-
-
- {u.displayName} - {!u.isActive && ( - - Gesperrt - - )} +
+ +
+
+ {u.displayName} + {!u.isActive && ( + + Gesperrt + + )} +
+

{u.email}

-

{u.email}

diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 532b4d0..a90a97f 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { User as UserIcon, Bell, @@ -15,10 +15,13 @@ import { Send, AlertCircle, Palette, + Upload, + Camera, } from 'lucide-react'; import { APIKey, User } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme'; +import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar'; interface UserSettingsViewProps { currentUser: User; @@ -42,9 +45,12 @@ export const UserSettingsView: React.FC = ({ // --- Profile State --- const [displayName, setDisplayName] = useState(currentUser.displayName || ''); const [email, setEmail] = useState(currentUser.email || ''); + const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || ''); const [newPassword, setNewPassword] = useState(''); const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [savingProfile, setSavingProfile] = useState(false); + const [isProcessingAvatar, setIsProcessingAvatar] = useState(false); + const fileInputRef = useRef(null); // --- Multi-Channel Alarms State --- const [alarmChannels, setAlarmChannels] = useState( @@ -69,6 +75,7 @@ export const UserSettingsView: React.FC = ({ useEffect(() => { setDisplayName(currentUser.displayName || ''); setEmail(currentUser.email || ''); + setAvatarUrl(currentUser.avatarUrl || ''); setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []); setAlarmEmail(currentUser.alarmEmail || currentUser.email || ''); setDiscordWebhookUrl(currentUser.discordWebhookUrl || ''); @@ -95,6 +102,34 @@ export const UserSettingsView: React.FC = ({ ); }; + // --- Handlers: Avatar --- + const handleAvatarFileSelected = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setIsProcessingAvatar(true); + setProfileMsg(null); + try { + const dataUrl = await resizeImageToDataUrl(file, 256); + setAvatarUrl(dataUrl); + triggerHaptic(10); + } catch (err: any) { + setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Laden des Bildes' }); + } finally { + setIsProcessingAvatar(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const handleSelectPresetAvatar = (preset: string) => { + setAvatarUrl(preset); + triggerHaptic(10); + }; + + const handleRemoveAvatar = () => { + setAvatarUrl(''); + triggerHaptic(10); + }; + // --- Handlers: Profile --- const handleSaveProfile = async (e: React.FormEvent) => { e.preventDefault(); @@ -104,6 +139,7 @@ export const UserSettingsView: React.FC = ({ const patch: any = { displayName: displayName.trim(), email: email.trim(), + avatarUrl: avatarUrl.trim(), }; if (newPassword.trim()) { patch.password = newPassword.trim(); @@ -358,7 +394,88 @@ export const UserSettingsView: React.FC = ({
)} -
+ + {/* Avatar Management */} +
+ +
+
+ + {isProcessingAvatar && ( +
+ Lädt… +
+ )} +
+ +
+
+ + + + {avatarUrl && ( + + )} +
+ + {/* Emoji Presets */} +
+ Oder schnelles Emoji wählen: +
+ {['🦊', '🐱', '🐶', '🐼', '🦁', '🦉', '🚀', '☕', '⚡', '🎨', '🌟', '🎯', '💡'].map( + (preset) => ( + + ) + )} +
+
+ +

+ Wird überall kreisförmig dargestellt (z. B. Menüleiste oben, Freigabelisten, Aufgaben). +

+
+
+
+
diff --git a/frontend/src/features/teams/GroupsView.tsx b/frontend/src/features/teams/GroupsView.tsx index 7238ad2..205e8dc 100644 --- a/frontend/src/features/teams/GroupsView.tsx +++ b/frontend/src/features/teams/GroupsView.tsx @@ -15,6 +15,7 @@ import { import { Team, User } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; import { UserAutocomplete } from '../UserAutocomplete'; +import { UserAvatar } from '../../components/UserAvatar'; interface GroupsViewProps { currentUser: User; @@ -487,9 +488,7 @@ export const GroupsView: React.FC = ({ currentUser, onBack }) = className="p-2.5 bg-panel border border-border rounded-btn flex items-center justify-between gap-2 text-xs" >
-
- {(m.displayName || 'U').slice(0, 1).toUpperCase()} -
+

{m.displayName} {isSelf && (Du)} diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 4d319be..f198564 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -1249,8 +1249,12 @@ export const TasksView: React.FC = ({ return (

{otherMembers.slice(0, 2).map((m) => ( - - {getInitials(m.displayName || m.email)[0]} + + {m.avatarUrl ? ( + + ) : ( + getInitials(m.displayName || m.email)[0] + )} ))}
@@ -1563,6 +1567,7 @@ export const TasksView: React.FC = ({ targetId: sh.targetId, targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'), targetEmail: sh.email, + avatarUrl: sh.avatarUrl, permission: 'editor' as const, role: 'editor', createdAt: sh.createdAt, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 492e6b8..c11c321 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -163,6 +163,7 @@ export const api = { displayName?: string; email?: string; password?: string; + avatarUrl?: string; alarmChannels?: string[]; alarmEmail?: string; discordWebhookUrl?: string; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ef62c94..4d2ff16 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -14,6 +14,7 @@ export interface User { email: string; username?: string; displayName: string; + avatarUrl?: string; role: UserRole; isActive: boolean; alarmChannels?: string[]; @@ -79,6 +80,7 @@ export interface TaskListShare { targetId: string; targetName?: string; email?: string; + avatarUrl?: string; createdAt: string; } @@ -86,6 +88,7 @@ export interface TaskListMember { userId: string; displayName: string; email: string; + avatarUrl?: string; role: string; } @@ -132,6 +135,7 @@ export interface TeamMember { userId: string; displayName: string; email: string; + avatarUrl?: string; role: 'admin' | 'member'; joinedAt: string; } @@ -240,6 +244,7 @@ export interface SpaceMember { userId: string; displayName: string; email: string; + avatarUrl?: string; role: 'owner' | 'editor' | 'viewer'; joinedAt: string; } @@ -250,6 +255,7 @@ export interface UnifiedShare { targetId: string; targetName?: string; targetEmail?: string; + avatarUrl?: string; permission?: 'editor' | 'viewer'; role?: string; createdBy?: string; diff --git a/internal/api/server.go b/internal/api/server.go index ecebb35..5ad6a2f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1505,6 +1505,7 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request DisplayName *string `json:"displayName"` Email *string `json:"email"` Password *string `json:"password"` + AvatarURL *string `json:"avatarUrl"` AlarmChannels *[]string `json:"alarmChannels"` AlarmEmail *string `json:"alarmEmail"` DiscordWebhookURL *string `json:"discordWebhookUrl"` @@ -1534,6 +1535,7 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request req.DiscordWebhookURL, req.CustomWebhookURL, req.NotifyBeforeMinutes, + req.AvatarURL, ) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) diff --git a/internal/domain/models.go b/internal/domain/models.go index 4934766..fcf16f6 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -98,6 +98,7 @@ type User struct { Username string `json:"username,omitempty"` PasswordHash string `json:"-"` DisplayName string `json:"displayName"` + AvatarURL string `json:"avatarUrl,omitempty"` Role string `json:"role"` // admin | member IsActive bool `json:"isActive"` AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook @@ -179,6 +180,7 @@ type TaskListShare struct { TargetID string `json:"targetId"` TargetName string `json:"targetName,omitempty"` Email string `json:"email,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` CreatedAt string `json:"createdAt"` } @@ -186,6 +188,7 @@ type TaskListMember struct { UserID string `json:"userId"` DisplayName string `json:"displayName"` Email string `json:"email"` + AvatarURL string `json:"avatarUrl,omitempty"` Role string `json:"role"` // owner | member } @@ -238,6 +241,7 @@ type TeamMember struct { UserID string `json:"userId"` DisplayName string `json:"displayName"` Email string `json:"email"` + AvatarURL string `json:"avatarUrl,omitempty"` Role string `json:"role"` // admin | member JoinedAt string `json:"joinedAt"` } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 819cf75..a748531 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -81,6 +81,7 @@ func (s *Store) migrate() error { discord_webhook_url TEXT NOT NULL DEFAULT '', custom_webhook_url TEXT NOT NULL DEFAULT '', notify_before_minutes INTEGER NOT NULL DEFAULT 30, + avatar_url TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); @@ -313,6 +314,7 @@ func (s *Store) migrate() error { "ALTER TABLE users ADD COLUMN discord_webhook_url TEXT NOT NULL DEFAULT '';", "ALTER TABLE users ADD COLUMN custom_webhook_url TEXT NOT NULL DEFAULT '';", "ALTER TABLE users ADD COLUMN notify_before_minutes INTEGER NOT NULL DEFAULT 30;", + "ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT '';", "ALTER TABLE thoughts ADD COLUMN task_list_id TEXT NOT NULL DEFAULT '';", "ALTER TABLE thoughts ADD COLUMN kanban_status TEXT NOT NULL DEFAULT 'todo';", @@ -486,7 +488,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) { // --- USERS --- -const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at` +const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at` func parseAlarmChannels(raw string) []string { parts := strings.Split(raw, ",") @@ -509,7 +511,7 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) { err := row.Scan( &u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName, &u.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail, - &u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.CreatedAt, + &u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.CreatedAt, ) if err != nil { return nil, err @@ -559,11 +561,12 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam DiscordWebhookURL: "", CustomWebhookURL: "", NotifyBeforeMinutes: 30, + AvatarURL: "", CreatedAt: domain.NowISO(), } _, err := s.db.Exec( - `INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at) - VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, '', '', 30, ?)`, + `INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at) + VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, '', '', 30, '', ?)`, u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt, ) if err != nil { @@ -714,7 +717,7 @@ func (s *Store) UpdateUser( discordWebhookURL *string, notifyBeforeMinutes *int, ) (*domain.User, error) { - return s.UpdateUserFull(userID, displayName, email, role, isActive, nil, nil, discordWebhookURL, nil, notifyBeforeMinutes) + return s.UpdateUserFull(userID, displayName, email, role, isActive, nil, nil, discordWebhookURL, nil, notifyBeforeMinutes, nil) } func (s *Store) UpdateUserFull( @@ -724,6 +727,7 @@ func (s *Store) UpdateUserFull( alarmChannels *[]string, alarmEmail, discordWebhookURL, customWebhookURL *string, notifyBeforeMinutes *int, + avatarURL *string, ) (*domain.User, error) { u, err := s.GetUserByID(userID) if err != nil { @@ -756,14 +760,17 @@ func (s *Store) UpdateUserFull( if notifyBeforeMinutes != nil { u.NotifyBeforeMinutes = *notifyBeforeMinutes } + if avatarURL != nil { + u.AvatarURL = strings.TrimSpace(*avatarURL) + } activeInt := 0 if u.IsActive { activeInt = 1 } channelsStr := strings.Join(u.AlarmChannels, ",") _, err = s.db.Exec( - `UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ? WHERE id = ?`, - u.DisplayName, u.Email, u.Role, activeInt, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, userID, + `UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`, + u.DisplayName, u.Email, u.Role, activeInt, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID, ) if err != nil { return nil, err @@ -2196,7 +2203,7 @@ func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error { func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) { rows, err := s.db.Query( - `SELECT id, email, display_name, role, is_active, created_at FROM users WHERE id != ? AND is_active = 1 ORDER BY display_name ASC`, + `SELECT id, email, display_name, role, is_active, avatar_url, created_at FROM users WHERE id != ? AND is_active = 1 ORDER BY display_name ASC`, currentUserID, ) if err != nil { @@ -2208,7 +2215,7 @@ func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) { for rows.Next() { var u domain.User var activeInt int - if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &activeInt, &u.CreatedAt); err == nil { + if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &activeInt, &u.AvatarURL, &u.CreatedAt); err == nil { u.IsActive = activeInt == 1 users = append(users, u) } @@ -2936,7 +2943,7 @@ func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, er if shares[i].TargetType == "team" { _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName) } else { - _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName, &shares[i].Email) + _ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName, &shares[i].Email, &shares[i].AvatarURL) } } return shares, nil @@ -2946,25 +2953,26 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb memberMap := make(map[string]domain.TaskListMember) // 1. Owner - var ownerName, ownerEmail string - _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail) + var ownerName, ownerEmail, ownerAvatar string + _ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail, &ownerAvatar) memberMap[tl.UserID] = domain.TaskListMember{ UserID: tl.UserID, DisplayName: ownerName, Email: ownerEmail, + AvatarURL: ownerAvatar, Role: "owner", } // 2. Team members if tl.TeamID != "" if tl.TeamID != "" { rows, err := s.db.Query( - `SELECT u.id, u.display_name, u.email FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`, + `SELECT u.id, u.display_name, u.email, u.avatar_url FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`, tl.TeamID, ) if err == nil { for rows.Next() { var m domain.TaskListMember - if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil { + if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil { m.Role = "member" memberMap[m.UserID] = m } @@ -2977,13 +2985,13 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb for _, sh := range tl.Shares { if sh.TargetType == "team" { rows, err := s.db.Query( - `SELECT u.id, u.display_name, u.email FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`, + `SELECT u.id, u.display_name, u.email, u.avatar_url FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`, sh.TargetID, ) if err == nil { for rows.Next() { var m domain.TaskListMember - if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil { + if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil { m.Role = "member" memberMap[m.UserID] = m } @@ -2992,7 +3000,7 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb } } else if sh.TargetType == "user" { var m domain.TaskListMember - if err := s.db.QueryRow(`SELECT id, display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil { + if err := s.db.QueryRow(`SELECT id, display_name, email, avatar_url FROM users WHERE id = ?`, sh.TargetID).Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil { m.Role = "member" memberMap[m.UserID] = m } @@ -3466,7 +3474,7 @@ func (s *Store) RemoveTeamMember(teamID, userID string) error { func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) { rows, err := s.db.Query( - `SELECT tm.team_id, tm.user_id, u.display_name, u.email, tm.role, tm.joined_at + `SELECT tm.team_id, tm.user_id, u.display_name, u.email, u.avatar_url, tm.role, tm.joined_at FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ? @@ -3481,7 +3489,7 @@ func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) { members := make([]domain.TeamMember, 0) for rows.Next() { var m domain.TeamMember - if err := rows.Scan(&m.TeamID, &m.UserID, &m.DisplayName, &m.Email, &m.Role, &m.JoinedAt); err == nil { + if err := rows.Scan(&m.TeamID, &m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL, &m.Role, &m.JoinedAt); err == nil { members = append(members, m) } } -- 2.54.0 From c420155dc0834f2594f57cd86cc1da65e49294e2 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 21:39:20 +0100 Subject: [PATCH 03/19] fix: apply theme border-radius to list delete button in tasks and shared lists --- frontend/src/features/lists/SharedListsView.tsx | 6 +++--- frontend/src/features/triage/TasksView.tsx | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 4f3853c..7605cc7 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -837,11 +837,11 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index f198564..24767db 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -1230,10 +1230,10 @@ export const TasksView: React.FC = ({
-- 2.54.0 From f9ef03af7fea3e957b41b75fbf6b05874cbd457c Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 21:45:10 +0100 Subject: [PATCH 04/19] fix(workspace): delete empty bulletpoint on first backspace, move descendants on indent, preserve subpoints on backspace, and prompt on x button --- .../src/features/workspace/WorkspaceView.tsx | 322 ++++++++++++++---- 1 file changed, 254 insertions(+), 68 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index cd6eb30..37d61f2 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -175,6 +175,13 @@ export const WorkspaceView: React.FC = ({ const [draggingBlockId, setDraggingBlockId] = useState(null); const [dragOverBlockTarget, setDragOverBlockTarget] = useState<{ blockId: string; position: 'before' | 'after' | 'inside' } | null>(null); + // Modal for deleting block with sub-bulletpoints (X button) + const [deleteSubpointsModal, setDeleteSubpointsModal] = useState<{ + blockId: string; + count: number; + snippet: string; + } | null>(null); + // --- GLOBAL TO-DO ROLLUP STATE --- const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open'); const [todoFolderDrilldown, setTodoFolderDrilldown] = useState(null); @@ -1281,59 +1288,162 @@ export const WorkspaceView: React.FC = ({ }, 20); }; - // Indent block (Tab): increase level by 1 + // Indent block (Tab): increase level by 1 and move all descendant sub-bullets along const indentBlock = (index: number) => { if (isReadOnly || !selectedDoc || index === 0) return; - const block = selectedDoc.blocks[index]; - const prevBlock = selectedDoc.blocks[index - 1]; + const blocks = selectedDoc.blocks; + const block = blocks[index]; + const prevBlock = blocks[index - 1]; + if (!block || !prevBlock) return; const currentLevel = block.level || 0; const maxLevel = (prevBlock.level || 0) + 1; - if (currentLevel < maxLevel) { - updateBlock(block.id, { level: currentLevel + 1 }); - } - }; - - // Outdent block (Shift+Tab): decrease level by 1 (without leaving active zoomed subtree) - const outdentBlock = (blockId: string) => { - if (isReadOnly || !selectedDoc) return; - const block = selectedDoc.blocks.find((b) => b.id === blockId); - if (!block) return; - const currentLevel = block.level || 0; - const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0; - if (currentLevel > minLevel) { - updateBlock(blockId, { level: currentLevel - 1 }); - } - }; - - // Delete block and all its descendant sub-bullets with confirmation - const removeBlock = (blockId: string): boolean => { - if (isReadOnly || !selectedDoc) return false; - const blocks = selectedDoc.blocks; - const index = blocks.findIndex((b) => b.id === blockId); - if (index === -1) return false; - - const targetBlock = blocks[index]; - const targetLevel = targetBlock.level || 0; + if (currentLevel >= maxLevel) return; // Find all descendant sub-bullets directly or indirectly nested under this block - const descendantIds: string[] = []; + const descendantIndices: number[] = []; for (let i = index + 1; i < blocks.length; i++) { - const cur = blocks[i]; - const curLevel = cur.level || 0; - if (curLevel <= targetLevel) break; - descendantIds.push(cur.id); + if ((blocks[i].level || 0) <= currentLevel) break; + descendantIndices.push(i); } - if (descendantIds.length > 0) { - const count = descendantIds.length; - const snippet = targetBlock.content?.trim() ? ` "${targetBlock.content.trim().slice(0, 30)}..."` : ''; - const confirmText = - count === 1 - ? `Dieser Aufzählungspunkt${snippet} enthält 1 Unterpunkt. Möchtest du ihn mitsamt dem Unterpunkt wirklich löschen?` - : `Dieser Aufzählungspunkt${snippet} enthält ${count} Unterpunkte. Möchtest du ihn mitsamt allen ${count} Unterpunkten wirklich löschen?`; - if (!window.confirm(confirmText)) { - return false; + const nextBlocks = blocks.map((b, idx) => { + if (idx === index) { + return { ...b, level: currentLevel + 1 }; } + if (descendantIndices.includes(idx)) { + return { ...b, level: (b.level || 0) + 1 }; + } + return b; + }); + + persistDocument({ ...selectedDoc, blocks: nextBlocks }); + }; + + // Outdent block (Shift+Tab): decrease level by 1 and move all descendant sub-bullets along + const outdentBlock = (blockId: string) => { + if (isReadOnly || !selectedDoc) return; + const blocks = selectedDoc.blocks; + const index = blocks.findIndex((b) => b.id === blockId); + if (index === -1) return; + const block = blocks[index]; + const currentLevel = block.level || 0; + const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0; + if (currentLevel <= minLevel) return; + + // Find all descendant sub-bullets directly or indirectly nested under this block + const descendantIndices: number[] = []; + for (let i = index + 1; i < blocks.length; i++) { + if ((blocks[i].level || 0) <= currentLevel) break; + descendantIndices.push(i); + } + + const nextBlocks = blocks.map((b, idx) => { + if (idx === index) { + return { ...b, level: currentLevel - 1 }; + } + if (descendantIndices.includes(idx)) { + return { ...b, level: Math.max(minLevel, (b.level || 0) - 1) }; + } + return b; + }); + + persistDocument({ ...selectedDoc, blocks: nextBlocks }); + }; + + // Delete a single block without deleting its sub-bullets (sub-bullets are promoted 1 level up) + const deleteSingleBlock = (blockId: string, focusPrev = true) => { + if (isReadOnly || !selectedDoc) return; + const blocks = selectedDoc.blocks; + const index = blocks.findIndex((b) => b.id === blockId); + if (index === -1) return; + const targetBlock = blocks[index]; + const targetLevel = targetBlock.level || 0; + const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0; + + // Find all descendant sub-bullets directly or indirectly nested under this block + const descendantIndices: number[] = []; + for (let i = index + 1; i < blocks.length; i++) { + if ((blocks[i].level || 0) <= targetLevel) break; + descendantIndices.push(i); + } + + triggerHaptic(10); + + // Target block is removed, but descendants are PRESERVED! + // Descendants are shifted up by 1 level to adopt the target block's level + let nextBlocks: DocumentBlock[] = []; + for (let i = 0; i < blocks.length; i++) { + if (i === index) continue; + if (descendantIndices.includes(i)) { + const curLevel = blocks[i].level || 0; + nextBlocks.push({ + ...blocks[i], + level: Math.max(minLevel, curLevel - 1), + }); + } else { + nextBlocks.push(blocks[i]); + } + } + + if (nextBlocks.length === 0) { + nextBlocks = [ + { + id: `blk_${Date.now()}_1`, + type: 'bullet', + content: '', + level: minLevel, + }, + ]; + } + + let prevBlock: DocumentBlock | undefined; + if (focusPrev) { + if (zoomedBlock) { + const zoomIdx = blocks.findIndex((b) => b.id === zoomedBlock.id); + if (index > zoomIdx + 1) { + prevBlock = blocks[index - 1]; + } else { + const nextFirstChild = nextBlocks[zoomIdx + 1]; + if (nextFirstChild && (nextFirstChild.level || 0) > (zoomedBlock.level || 0)) { + prevBlock = nextFirstChild; + } + } + } else { + prevBlock = index > 0 ? blocks[index - 1] : nextBlocks[0]; + } + } + + persistDocument({ ...selectedDoc, blocks: nextBlocks }); + + if (prevBlock) { + setLastFocusedBlockId(prevBlock.id); + setBlockCursor(prevBlock.id, (prevBlock.content || '').length); + setTimeout(() => { + const el = blockInputRefs.current[prevBlock.id]; + if (el) { + el.focus(); + if ('setSelectionRange' in el && typeof el.value === 'string') { + el.setSelectionRange(el.value.length, el.value.length); + } + } + }, 30); + } + }; + + // Delete a block AND all its descendant sub-bullets + const deleteBlockWithDescendants = (blockId: string) => { + if (isReadOnly || !selectedDoc) return; + const blocks = selectedDoc.blocks; + const index = blocks.findIndex((b) => b.id === blockId); + if (index === -1) return; + const targetBlock = blocks[index]; + const targetLevel = targetBlock.level || 0; + const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0; + + const descendantIds: string[] = []; + for (let i = index + 1; i < blocks.length; i++) { + if ((blocks[i].level || 0) <= targetLevel) break; + descendantIds.push(blocks[i].id); } triggerHaptic(15); @@ -1346,12 +1456,11 @@ export const WorkspaceView: React.FC = ({ id: `blk_${Date.now()}_1`, type: 'bullet', content: '', - level: 0, + level: minLevel, }, ]; } - // Determine fallback block to focus let prevBlock: DocumentBlock | undefined; if (zoomedBlock) { const zoomIdx = blocks.findIndex((b) => b.id === zoomedBlock.id); @@ -1382,16 +1491,32 @@ export const WorkspaceView: React.FC = ({ } }, 30); } - - return true; }; - // Delete block and focus the preceding one - const deleteBlockAndFocusPrev = (index: number) => { + // Handler for delete click on the X icon: prompts if sub-bullets exist + const handleDeleteButtonClick = (blockId: string) => { if (isReadOnly || !selectedDoc) return; - const targetBlock = selectedDoc.blocks[index]; - if (!targetBlock) return; - removeBlock(targetBlock.id); + const blocks = selectedDoc.blocks; + const index = blocks.findIndex((b) => b.id === blockId); + if (index === -1) return; + const targetBlock = blocks[index]; + const targetLevel = targetBlock.level || 0; + + let count = 0; + for (let i = index + 1; i < blocks.length; i++) { + if ((blocks[i].level || 0) <= targetLevel) break; + count++; + } + + if (count > 0) { + setDeleteSubpointsModal({ + blockId, + count, + snippet: targetBlock.content?.trim() ? `"${targetBlock.content.trim().slice(0, 35)}..."` : '', + }); + } else { + deleteSingleBlock(blockId, false); + } }; const updateBlock = (blockId: string, patch: Partial) => { @@ -1538,19 +1663,10 @@ export const WorkspaceView: React.FC = ({ const target = e.target as HTMLTextAreaElement | HTMLInputElement; const fullContent = block.content || ''; if (!fullContent || fullContent === '') { - const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0; - if ((block.level || 0) > minLevel) { - e.preventDefault(); - outdentBlock(block.id); - } else if ( - zoomedBlock - ? visibleBlocks.length > 1 - : selectedDoc?.blocks && selectedDoc.blocks.length > 1 - ) { - e.preventDefault(); - setSlashBlockId(null); - deleteBlockAndFocusPrev(index); - } + e.preventDefault(); + setSlashBlockId(null); + deleteSingleBlock(block.id, true); + return; } else if ( segStart > 0 && target && @@ -4134,7 +4250,7 @@ export const WorkspaceView: React.FC = ({ insertNewBulletAfter(originalIndex, block.level || 0); } else if (!isReadOnly && (e.key === 'Backspace' || e.key === 'Delete')) { e.preventDefault(); - deleteBlockAndFocusPrev(originalIndex); + deleteSingleBlock(block.id, true); } }} onClick={() => { @@ -4202,8 +4318,8 @@ export const WorkspaceView: React.FC = ({ {!isReadOnly && (
)} + + {/* DELETE SUBPOINTS CONFIRMATION MODAL */} + {deleteSubpointsModal && ( +
setDeleteSubpointsModal(null)} + > +
e.stopPropagation()} + > +
+
+
+ +
+
+

Aufzählungspunkt löschen

+

+ Dieser Punkt enthält {deleteSubpointsModal.count} {deleteSubpointsModal.count === 1 ? 'Unterpunkt' : 'Unterpunkte'}. +

+
+
+ +
+ +

+ Möchtest du alle Unterpunkte mit löschen oder nur diesen Aufzählungspunkt entfernen? +

+ +
+ + + +
+
+
+ )}
); }; -- 2.54.0 From e8723cbf8280086edb1b8d94af81e1305152c753 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 21:46:30 +0100 Subject: [PATCH 05/19] feat(mobile): separate new task button to top and add dedicated list management group --- frontend/src/features/triage/TasksView.tsx | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 24767db..41465f4 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -815,7 +815,7 @@ export const TasksView: React.FC = ({ }; useEffect(() => { - const actionItems: any[] = [ + const primaryItems: any[] = [ { id: 'new-task', label: 'Neue Aufgabe erstellen', @@ -826,6 +826,9 @@ export const TasksView: React.FC = ({ setTimeout(() => quickInputRef.current?.focus(), 100); }, }, + ]; + + const listManagementItems: any[] = [ { id: 'new-task-list', label: 'Neue Aufgabenliste anlegen', @@ -835,13 +838,13 @@ export const TasksView: React.FC = ({ ]; if (currentSelectedList) { - actionItems.push({ + listManagementItems.push({ id: 'share-task-list', label: `Liste „${currentSelectedList.title}“ teilen`, icon: , onClick: () => handleOpenShareModal(currentSelectedList), }); - actionItems.push({ + listManagementItems.push({ id: 'delete-task-list', label: `Liste „${currentSelectedList.title}“ löschen`, icon: , @@ -922,9 +925,13 @@ export const TasksView: React.FC = ({ setGroups([ { - id: 'tasks-actions', - title: 'Aktionen', - items: actionItems, + id: 'tasks-primary', + items: primaryItems, + }, + { + id: 'tasks-list-management', + title: 'Listen-Verwaltung', + items: listManagementItems, }, { id: 'tasks-views', @@ -933,7 +940,7 @@ export const TasksView: React.FC = ({ }, { id: 'tasks-lists', - title: 'Aufgabenlisten', + title: 'Aufgabenlisten wechseln', items: listItems, }, ]); -- 2.54.0 From 1e47f3aeb7a44bdd0978e6283eb8d1689d399f04 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 21:53:04 +0100 Subject: [PATCH 06/19] fix(mobile): render quick-capture options inline inside bottom sheet modal --- frontend/src/App.tsx | 4 +- .../features/capture/InstantCaptureBar.tsx | 39 ++++++++++++------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1ffc867..b64bc22 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1022,7 +1022,7 @@ export const App: React.FC = () => { onClick={() => setShowMobileCapture(false)} >
e.stopPropagation()} > {/* Header */} @@ -1045,6 +1045,8 @@ export const App: React.FC = () => { {/* InstantCaptureBar */}
{ handleCaptured(thought, target); setShowMobileCapture(false); diff --git a/frontend/src/features/capture/InstantCaptureBar.tsx b/frontend/src/features/capture/InstantCaptureBar.tsx index c2d5f82..aeaaabe 100644 --- a/frontend/src/features/capture/InstantCaptureBar.tsx +++ b/frontend/src/features/capture/InstantCaptureBar.tsx @@ -21,19 +21,23 @@ interface InstantCaptureBarProps { onCaptured: (thought: Thought, target?: CaptureTarget) => void; lists?: SharedList[]; autoFocusTrigger?: number; + inlineOptions?: boolean; + defaultShowOptions?: boolean; } export const InstantCaptureBar: React.FC = ({ onCaptured, lists = [], autoFocusTrigger = 0, + inlineOptions = false, + defaultShowOptions = false, }) => { const [content, setContent] = useState(''); const [priority, setPriority] = useState('normal'); const [dueDate, setDueDate] = useState(''); const [target, setTarget] = useState('inbox'); const [targetListId, setTargetListId] = useState(''); - const [showOptions, setShowOptions] = useState(false); + const [showOptions, setShowOptions] = useState(defaultShowOptions); const [isListening, setIsListening] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [justSavedLabel, setJustSavedLabel] = useState(null); @@ -72,6 +76,7 @@ export const InstantCaptureBar: React.FC = ({ // Close dropdown on outside click useEffect(() => { + if (inlineOptions) return; const handleClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setShowOptions(false); @@ -79,7 +84,7 @@ export const InstantCaptureBar: React.FC = ({ }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); + }, [inlineOptions]); // Global keyboard shortcut: 'c' or '/' focuses the Instant Capture input immediately useEffect(() => { @@ -293,7 +298,7 @@ export const InstantCaptureBar: React.FC = ({ type="button" onClick={() => setShowOptions((prev) => !prev)} title="Ziel wählen (Quick Dump, direkt als Aufgabe, auf Liste oder als Notiz) & Optionen" - className={`h-6 px-2.5 capture-pill rounded-full text-[11px] flex items-center gap-1 transition-colors ${ + className={`h-6 px-2.5 capture-pill rounded-full text-[11px] flex items-center gap-1 transition-colors cursor-pointer ${ showOptions || target !== 'inbox' || priority !== 'normal' || dueDate ? 'bg-accent-dim text-accent border border-accent-border font-medium' : 'text-muted hover:text-main hover:bg-panel-elevated border border-transparent' @@ -308,11 +313,11 @@ export const InstantCaptureBar: React.FC = ({ ) : ( )} - + {getTargetLabel()} {dueDate && • {dueDate.slice(5)}} - + {/* Voice Dictation Button */} @@ -362,9 +367,15 @@ export const InstantCaptureBar: React.FC = ({
- {/* Expandable Target & Metadata Panel (Theme-controlled Dropdown) */} + {/* Expandable Target & Metadata Panel (Theme-controlled Dropdown or Inline) */} {showOptions && ( -
+
{/* Row 1: Where should this entry go? */}
@@ -386,7 +397,7 @@ export const InstantCaptureBar: React.FC = ({ executeCapture('inbox'); } }} - className={`h-8 px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors ${ + className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${ target === 'inbox' ? 'bg-accent-dim border-accent text-main font-medium' : 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover' @@ -405,7 +416,7 @@ export const InstantCaptureBar: React.FC = ({ executeCapture('task'); } }} - className={`h-8 px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors ${ + className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${ target === 'task' ? 'bg-accent-dim border-accent text-main font-medium' : 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover' @@ -424,7 +435,7 @@ export const InstantCaptureBar: React.FC = ({ setTargetListId(lists[0].id); } }} - className={`h-8 px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors ${ + className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${ target === 'list_item' ? 'bg-accent-dim border-accent text-main font-medium' : 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover' @@ -443,7 +454,7 @@ export const InstantCaptureBar: React.FC = ({ executeCapture('document'); } }} - className={`h-8 px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors ${ + className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${ target === 'document' ? 'bg-accent-dim border-accent text-main font-medium' : 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover' @@ -475,7 +486,7 @@ export const InstantCaptureBar: React.FC = ({ executeCapture('list_item', lst.id); } }} - className={`h-7 px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors ${ + className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${ isSelected ? 'bg-accent text-on-accent border-accent font-medium' : 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover' @@ -506,7 +517,7 @@ export const InstantCaptureBar: React.FC = ({ key={p} type="button" onClick={() => setPriority(p)} - className={`px-2.5 py-0.5 border rounded-btn transition-colors text-xs ${ + className={`min-h-[30px] px-2.5 py-1 border rounded-btn transition-colors text-xs cursor-pointer ${ active ? 'bg-accent text-on-accent border-accent font-medium' : 'bg-canvas text-muted hover:text-main border-border' @@ -527,7 +538,7 @@ export const InstantCaptureBar: React.FC = ({ type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} - className="input-field px-2 py-0.5 text-xs" + className="input-field px-2.5 py-1 text-xs min-h-[30px]" />
-- 2.54.0 From f2ead7513a9b3b0f902410a4b6780c7d4252737a Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 22:03:28 +0100 Subject: [PATCH 07/19] feat(mobile): declutter mobile views with contextual FAB, filter sheet, and clean outliner --- frontend/src/App.tsx | 9 +- frontend/src/context/MobileActionContext.tsx | 11 + .../src/features/lists/SharedListsView.tsx | 11 +- frontend/src/features/triage/TasksView.tsx | 383 +++++++++++++++++- .../src/features/workspace/WorkspaceView.tsx | 76 ++-- 5 files changed, 430 insertions(+), 60 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b64bc22..e3ab213 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -132,11 +132,12 @@ export const App: React.FC = () => { // Mobile Navigation & Quick-Action State const [showMobileCapture, setShowMobileCapture] = useState(false); const [showMobileProfile, setShowMobileProfile] = useState(false); - const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups } = useMobileActions(); + const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups, primaryAction, setPrimaryAction } = useMobileActions(); useEffect(() => { closeMenu(); if (activeTab === 'triage') { + setPrimaryAction(() => setShowMobileCapture(true)); setGroups([ { id: 'triage-actions', @@ -966,7 +967,11 @@ export const App: React.FC = () => { type="button" onClick={() => { triggerHaptic(15); - setShowMobileCapture(true); + if (primaryAction) { + primaryAction(); + } else { + setShowMobileCapture(true); + } }} className="w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer" title="Schnell erfassen (+)" diff --git a/frontend/src/context/MobileActionContext.tsx b/frontend/src/context/MobileActionContext.tsx index ef661a4..1ea376d 100644 --- a/frontend/src/context/MobileActionContext.tsx +++ b/frontend/src/context/MobileActionContext.tsx @@ -22,6 +22,8 @@ interface MobileActionContextValue { isMenuOpen: boolean; setIsMenuOpen: React.Dispatch>; closeMenu: () => void; + primaryAction?: () => void; + setPrimaryAction: (fn?: () => void) => void; } const MobileActionContext = createContext({ @@ -30,11 +32,14 @@ const MobileActionContext = createContext({ isMenuOpen: false, setIsMenuOpen: () => {}, closeMenu: () => {}, + primaryAction: undefined, + setPrimaryAction: () => {}, }); export const MobileActionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [groups, setGroupsState] = useState([]); const [isMenuOpen, setIsMenuOpen] = useState(false); + const [primaryAction, setPrimaryActionState] = useState<(() => void) | undefined>(undefined); const setGroups = useCallback((newGroups: MobileMenuGroup[]) => { setGroupsState(newGroups); @@ -44,6 +49,10 @@ export const MobileActionProvider: React.FC<{ children: React.ReactNode }> = ({ setIsMenuOpen(false); }, []); + const setPrimaryAction = useCallback((fn?: () => void) => { + setPrimaryActionState(() => fn); + }, []); + return ( = ({ isMenuOpen, setIsMenuOpen, closeMenu, + primaryAction, + setPrimaryAction, }} > {children} diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 7605cc7..2fd00e0 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -77,7 +77,7 @@ export function deduplicateItems(items: ListItem[]): ListItem[] { } export const SharedListsView: React.FC = ({ onRefreshGlobal, currentUser }) => { - const { setGroups } = useMobileActions(); + const { setGroups, setPrimaryAction } = useMobileActions(); const [lists, setLists] = useState([]); const [activeListId, setActiveListId] = useState(''); @@ -714,6 +714,13 @@ export const SharedListsView: React.FC = ({ onRefreshGloba // Register context-sensitive mobile menu groups behind the MindSift logo useEffect(() => { + setPrimaryAction(() => { + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 60); + }); + setGroups([ { id: 'lists-actions', @@ -786,7 +793,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba }), }, ]); - }, [lists, activeList, completedItems.length, setGroups]); + }, [lists, activeList, completedItems.length, setGroups, setPrimaryAction]); return (
diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 41465f4..5c0d6d2 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -23,8 +23,9 @@ import { Settings, ChevronDown, Folder, + Filter, } from 'lucide-react'; -import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn } from '../../lib/types'; +import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn, PriorityLevel } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { TaskDetailModal } from './TaskDetailModal'; @@ -73,7 +74,15 @@ export const TasksView: React.FC = ({ const [quickTitle, setQuickTitle] = useState(''); const [quickDueDate, setQuickDueDate] = useState(''); const quickInputRef = useRef(null); - const { setGroups } = useMobileActions(); + const { setGroups, setPrimaryAction } = useMobileActions(); + + // Mobile task creation & filter modal state + const [showMobileCreateModal, setShowMobileCreateModal] = useState(false); + const [mobileCreateTitle, setMobileCreateTitle] = useState(''); + const [mobileCreateDueDate, setMobileCreateDueDate] = useState(''); + const [mobileCreatePriority, setMobileCreatePriority] = useState('normal'); + const [mobileCreateListId, setMobileCreateListId] = useState(''); + const [showFilterModal, setShowFilterModal] = useState(false); // New TaskList modal state const [showNewListModal, setShowNewListModal] = useState(false); @@ -530,6 +539,31 @@ export const TasksView: React.FC = ({ } }; + const handleMobileCreateTask = async (e: React.FormEvent) => { + e.preventDefault(); + const title = mobileCreateTitle.trim(); + if (!title) return; + triggerHaptic(12); + + try { + const created = await api.captureThought(title, mobileCreatePriority, 'manual', mobileCreateDueDate || undefined, { + target: 'task', + taskListId: mobileCreateListId || (selectedListId === 'all' ? '' : selectedListId), + }); + setTasks((prev) => { + if (prev.some((t) => t.id === created.id)) return prev; + return [created, ...prev]; + }); + setMobileCreateTitle(''); + setMobileCreateDueDate(''); + setMobileCreatePriority('normal'); + setShowMobileCreateModal(false); + onRefreshStats(); + } catch { + loadData(); + } + }; + const handleQuickAddTaskToColumn = async (columnId: string) => { const title = prompt('Neue Aufgabe für diese Spalte:'); if (!title || !title.trim()) return; @@ -815,6 +849,11 @@ export const TasksView: React.FC = ({ }; useEffect(() => { + setPrimaryAction(() => { + setMobileCreateListId(selectedListId === 'all' ? '' : selectedListId); + setShowMobileCreateModal(true); + }); + const primaryItems: any[] = [ { id: 'new-task', @@ -822,12 +861,26 @@ export const TasksView: React.FC = ({ icon: , variant: 'accent', onClick: () => { - window.scrollTo({ top: 0, behavior: 'smooth' }); - setTimeout(() => quickInputRef.current?.focus(), 100); + setMobileCreateListId(selectedListId === 'all' ? '' : selectedListId); + setShowMobileCreateModal(true); }, }, ]; + const filterSortActiveCount = + (selectedTag ? 1 : 0) + (groupBy !== 'none' ? 1 : 0) + (sortBy !== 'custom' ? 1 : 0); + + const filterItems: any[] = [ + { + id: 'tasks-filter-modal', + label: 'Filter, Hashtags & Sortierung', + icon: , + badge: filterSortActiveCount > 0 ? `${filterSortActiveCount} aktiv` : undefined, + active: filterSortActiveCount > 0, + onClick: () => setShowFilterModal(true), + }, + ]; + const listManagementItems: any[] = [ { id: 'new-task-list', @@ -928,6 +981,11 @@ export const TasksView: React.FC = ({ id: 'tasks-primary', items: primaryItems, }, + { + id: 'tasks-filters', + title: 'Filter & Sortierung', + items: filterItems, + }, { id: 'tasks-list-management', title: 'Listen-Verwaltung', @@ -948,12 +1006,17 @@ export const TasksView: React.FC = ({ viewMode, filter, selectedListId, + selectedTag, + groupBy, + sortBy, openTasks.length, doneTasks.length, safeDiscarded.length, kanbanTasks.length, safeTaskLists, currentSelectedList, + setPrimaryAction, + setGroups, ]); const renderTaskProgressBar = (task: Thought) => { @@ -1071,11 +1134,11 @@ export const TasksView: React.FC = ({ className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden" onClick={(e) => e.stopPropagation()} > - {/* Kanban status selector pill */} + {/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */} = ({
- {/* Hashtag Filter & Grouping / Sorting Toolbar */} -
+ {/* Mobile Active Filter Badge Banner (minimal and clean) */} + {(selectedTag || groupBy !== 'none' || sortBy !== 'custom') && ( +
+
+ + {selectedTag && ( + + #{selectedTag} + + + )} + {groupBy !== 'none' && ( + + Gruppe: {groupBy} + + )} + {sortBy !== 'custom' && ( + + Sortiert: {sortBy} + + )} +
+ +
+ )} + + {/* Hashtag Filter & Grouping / Sorting Toolbar (Desktop only - on mobile accessible via Logo Menu Filter & Sort) */} +
{/* Hashtag filter chips */}
@@ -1497,6 +1602,264 @@ export const TasksView: React.FC = ({
+ {/* MOBILE CREATE TASK BOTTOM SHEET MODAL */} + {showMobileCreateModal && ( +
setShowMobileCreateModal(false)} + > +
e.stopPropagation()} + > +
+
+
+ +
+

Neue Aufgabe anlegen

+
+ +
+ +
+
+ + setMobileCreateTitle(e.target.value)} + placeholder="Was ist zu tun?…" + className="w-full input-field px-3 py-2 text-sm" + /> +
+ + {/* List Selector (if lists exist) */} + {safeTaskLists.length > 0 && ( +
+ + +
+ )} + + {/* Priority & Due Date */} +
+
+ + +
+ +
+ + setMobileCreateDueDate(e.target.value)} + className="w-full input-field px-2 py-2 text-xs" + /> +
+
+ +
+ + +
+
+
+
+ )} + + {/* MOBILE FILTER & SORT MODAL */} + {showFilterModal && ( +
setShowFilterModal(false)} + > +
e.stopPropagation()} + > +
+
+
+ +
+

Filter & Sortierung

+
+ +
+ + {/* Hashtag Filters */} +
+ + + Nach Hashtags filtern + +
+ + {allAvailableTags.map((tag) => { + const count = safeTasks.filter((t) => t.tags?.includes(tag)).length; + const isSelected = selectedTag === tag; + return ( + + ); + })} +
+
+ + {/* Grouping */} + {viewMode !== 'kanban' && ( +
+ + Gruppierung + +
+ {[ + { id: 'none', label: 'Keine' }, + { id: 'tag', label: 'Nach Hashtag' }, + { id: 'status', label: 'Nach Status' }, + { id: 'priority', label: 'Nach Priorität' }, + { id: 'due', label: 'Nach Fälligkeit' }, + ].map((g) => ( + + ))} +
+
+ )} + + {/* Sorting */} + {viewMode !== 'kanban' && ( +
+ + Sortierung + +
+ {[ + { id: 'custom', label: 'Manuell (Drag & Drop)' }, + { id: 'created', label: 'Erstelldatum' }, + { id: 'due', label: 'Fälligkeitsdatum' }, + { id: 'priority', label: 'Priorität' }, + { id: 'alpha', label: 'Alphabetisch' }, + ].map((s) => ( + + ))} +
+
+ )} + +
+ + +
+
+
+ )} + {/* NEW LIST MODAL */} {showNewListModal && (
diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 37d61f2..25d2196 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -100,7 +100,7 @@ export const WorkspaceView: React.FC = ({ initialDocId, onStatsChange, }) => { - const { setGroups } = useMobileActions(); + const { setGroups, setPrimaryAction } = useMobileActions(); const [showMobileFileDrawer, setShowMobileFileDrawer] = useState(false); // Top Workspace Tab Mode: 'editor' vs 'todos' vs 'tags' @@ -2836,6 +2836,10 @@ export const WorkspaceView: React.FC = ({ // Register context-sensitive mobile menu groups behind the MindSift logo useEffect(() => { + setPrimaryAction(() => { + handleCreateDoc(null); + }); + const openTodosCount = allWorkspaceTodos.filter((t) => !t.block.checked).length; setGroups([ { @@ -2958,6 +2962,7 @@ export const WorkspaceView: React.FC = ({ allWorkspaceTodos, selectedDoc, setGroups, + setPrimaryAction, ]); const renderDocItem = (doc: Document, depth: number) => { @@ -3186,49 +3191,6 @@ export const WorkspaceView: React.FC = ({ return (
- {/* Mobile Compact Workspace Bar (Options & mode switching live in MindSift Logo Menu) */} -
- - -
- {workspaceMode === 'editor' && selectedDoc && ( - - - {saveState === 'saving' ? 'Speichert…' : 'Gespeichert'} - - )} - -
-
{/* Top Navigation Mode Switcher: Notizen & Outliner vs. Globales To-Do Dashboard (Desktop) */}
@@ -4342,8 +4304,30 @@ export const WorkspaceView: React.FC = ({
) : ( -
- Wähle links eine Datei im Datei-Explorer aus oder erstelle eine neue Datei. +
+ +

Keine Notiz geöffnet.

+
+ + +
)}
-- 2.54.0 From 3fcd164fd838dad07492728947f52a192c20efd1 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 07:19:55 +0100 Subject: [PATCH 08/19] fix(workspace): center floating file explorer dialog on mobile --- .../src/features/workspace/WorkspaceView.tsx | 589 +++++++++--------- 1 file changed, 299 insertions(+), 290 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 25d2196..0127323 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -3189,6 +3189,278 @@ export const WorkspaceView: React.FC = ({ const isEmpty = docs.length === 0 && allFolderPaths.length === 0; + const renderExplorerBody = () => ( + <> +
+
+ + setSearchQuery(e.target.value)} + placeholder="Dateien durchsuchen…" + className="w-full h-9 sm:h-7 pl-8 sm:pl-7 pr-2 input-field text-sm sm:text-xs" + /> +
+ +
+ + + +
+
+ + {isCreatingFolder && newFolderParent === null && ( +
+ + setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleConfirmCreateFolder(); + if (e.key === 'Escape') setIsCreatingFolder(false); + }} + placeholder="Neuer Ordnername…" + className="flex-1 h-8 sm:h-6 px-2 sm:px-1.5 input-field text-xs" + /> + + +
+ )} + + {/* Empty workspace state */} + {isEmpty ? ( +
+ +

Noch keine Notizen

+

+ Erstelle eine neue Datei oder lege Ordner an, um deine Dokumente übersichtlich zu strukturieren. +

+
+ + +
+
+ ) : ( +
+ {/* Drag target for Root when dragging a note or a folder */} + {(draggingDocId || draggingFolderPath) && ( +
{ + e.preventDefault(); + e.stopPropagation(); + setDragOverFolder('__root__'); + }} + onDragLeave={(e) => { + e.preventDefault(); + if (dragOverFolder === '__root__') setDragOverFolder(null); + }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + const droppedFolder = + e.dataTransfer.getData('application/mindsift-folder-path') || + draggingFolderPath; + setDragOverFolder(null); + setDraggingDocId(null); + setDraggingFolderPath(null); + + if (droppedFolder) { + handleMoveFolder(droppedFolder, ''); + } else { + handleDropDocToFolder('', e); + } + }} + className={`mx-2 my-1.5 p-2 border-2 border-dashed rounded-btn text-center text-xs transition-colors ${ + dragOverFolder === '__root__' + ? 'border-accent bg-accent/20 text-main font-semibold' + : 'border-border text-muted bg-canvas' + }`} + > + {draggingFolderPath + ? '📁 Ordner in Hauptverzeichnis verschieben' + : '📁 In Hauptverzeichnis verschieben (ohne Ordner)'} +
+ )} + + {/* Angepinnte Notizen (Favorites) - Collapsible */} + {pinnedDocs.length > 0 && !searchQuery && ( +
+ + {!isPinnedCollapsed && ( +
+ {pinnedDocs.map((doc) => { + const isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor'; + return ( +
{ + e.stopPropagation(); + e.dataTransfer.setData('application/mindsift-doc-id', doc.id); + e.dataTransfer.setData('text/plain', doc.id); + setDraggingDocId(doc.id); + }} + onDragEnd={() => setDraggingDocId(null)} + onClick={() => { + setSelectedDoc(doc); + setZoomedBlockId(null); + setWorkspaceMode('editor'); + setShowMobileFileDrawer(false); + }} + className={`group flex items-center justify-between px-2 py-2.5 sm:py-1.5 rounded-btn transition-colors cursor-pointer text-sm sm:text-xs ${ + isSelected + ? 'bg-panel-elevated border-l-2 border-accent text-main font-medium' + : 'text-muted hover:text-main hover:bg-canvas/50' + }`} + title="Angepinnte Notiz öffnen" + > +
+ {doc.icon || '📄'} + {doc.title || 'Unbenannt'} +
+ +
+ ); + })} +
+ )} +
+ )} + + {/* Themen & Hashtags (Collapsible) - directly under Pinned Notes */} + {workspaceTags.length > 0 && !searchQuery && ( +
+ + {!isTagsCollapsed && ( +
+ {workspaceTags.map((wt) => ( + + ))} +
+ )} +
+ )} + + {rootTree.map(renderFolder)} + {rootDocs.map((doc) => renderDocItem(doc, 0))} + + {filteredDocs.length === 0 && searchQuery && ( +
+ Keine Dateien gefunden für "{searchQuery}" +
+ )} +
+ )} + + ); + return (
@@ -3269,308 +3541,45 @@ export const WorkspaceView: React.FC = ({ {/* ===================================================================== */} {workspaceMode === 'editor' && (
- {/* Mobile Backdrop for File Tree Slide-Over Drawer */} + {/* Mobile Centered Floating Explorer Dialog ("in der Mitte schweben") */} {showMobileFileDrawer && (
setShowMobileFileDrawer(false)} - /> - )} - - {/* Left Document & Folder Explorer Tree (Desktop Sidebar + Mobile Slide-Over Drawer) */} -
- {/* Mobile Drawer Top Header */} -
-
- - Dateibaum & Ordner -
- -
- -
-
- - setSearchQuery(e.target.value)} - placeholder="Dateien durchsuchen…" - className="w-full h-9 sm:h-7 pl-8 sm:pl-7 pr-2 input-field text-sm sm:text-xs" - /> -
- -
- - - -
-
- - {isCreatingFolder && newFolderParent === null && ( -
- - setNewFolderName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleConfirmCreateFolder(); - if (e.key === 'Escape') setIsCreatingFolder(false); - }} - placeholder="Neuer Ordnername…" - className="flex-1 h-8 sm:h-6 px-2 sm:px-1.5 input-field text-xs" - /> - - -
- )} - - {isEmpty ? ( -
- -
Keine Dateien vorhanden
-

- Erstelle eine neue Datei oder lege Ordner an, um deine Dokumente übersichtlich zu strukturieren. -

-
+ {/* Mobile Dialog Header */} +
+
+
+ +
+ Dateibaum & Ordner +
-
+ +
+ {renderExplorerBody()} +
- ) : ( -
- {/* Drag target for Root when dragging a note or a folder */} - {(draggingDocId || draggingFolderPath) && ( -
{ - e.preventDefault(); - e.stopPropagation(); - setDragOverFolder('__root__'); - }} - onDragLeave={(e) => { - e.preventDefault(); - if (dragOverFolder === '__root__') setDragOverFolder(null); - }} - onDrop={(e) => { - e.preventDefault(); - e.stopPropagation(); - const droppedFolder = - e.dataTransfer.getData('application/mindsift-folder-path') || - draggingFolderPath; - setDragOverFolder(null); - setDraggingDocId(null); - setDraggingFolderPath(null); +
+ )} - if (droppedFolder) { - handleMoveFolder(droppedFolder, ''); - } else { - handleDropDocToFolder('', e); - } - }} - className={`mx-2 my-1.5 p-2 border-2 border-dashed rounded-btn text-center text-xs transition-colors ${ - dragOverFolder === '__root__' - ? 'border-accent bg-accent/20 text-main font-semibold' - : 'border-border text-muted bg-canvas' - }`} - > - {draggingFolderPath - ? '📁 Ordner in Hauptverzeichnis verschieben' - : '📁 In Hauptverzeichnis verschieben (ohne Ordner)'} -
- )} - - {/* Angepinnte Notizen (Favorites) - Collapsible */} - {pinnedDocs.length > 0 && !searchQuery && ( -
- - {!isPinnedCollapsed && ( -
- {pinnedDocs.map((doc) => { - const isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor'; - return ( -
{ - e.stopPropagation(); - e.dataTransfer.setData('application/mindsift-doc-id', doc.id); - e.dataTransfer.setData('text/plain', doc.id); - setDraggingDocId(doc.id); - }} - onDragEnd={() => setDraggingDocId(null)} - onClick={() => { - setSelectedDoc(doc); - setZoomedBlockId(null); - setWorkspaceMode('editor'); - setShowMobileFileDrawer(false); - }} - className={`group flex items-center justify-between px-2 py-2.5 sm:py-1.5 rounded-btn transition-colors cursor-pointer text-sm sm:text-xs ${ - isSelected - ? 'bg-panel-elevated border-l-2 border-accent text-main font-medium' - : 'text-muted hover:text-main hover:bg-canvas/50' - }`} - title="Angepinnte Notiz öffnen" - > -
- {doc.icon || '📄'} - {doc.title || 'Unbenannt'} -
- -
- ); - })} -
- )} -
- )} - - {/* Themen & Hashtags (Collapsible) - directly under Pinned Notes */} - {workspaceTags.length > 0 && !searchQuery && ( -
- - {!isTagsCollapsed && ( -
- {workspaceTags.map((wt) => ( - - ))} -
- )} -
- )} - - {rootTree.map(renderFolder)} - {rootDocs.map((doc) => renderDocItem(doc, 0))} - - {filteredDocs.length === 0 && searchQuery && ( -
- Keine Dateien gefunden für "{searchQuery}" -
- )} -
- )} + {/* Desktop Left Document & Folder Explorer Tree (Sidebar) */} +
+ {renderExplorerBody()}
- {/* Right Logseq Outliner Editor */}
{selectedDoc ? ( -- 2.54.0 From 35fd682fcd88c5766a11f51c414f234c9f62dd37 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 08:26:24 +0100 Subject: [PATCH 09/19] =?UTF-8?q?feat(workspace):=20=C3=BCberarbeite=20get?= =?UTF-8?q?eilte=20Notizen=20und=20ganze=20Ordner=20teilen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/workspace/WorkspaceView.tsx | 247 +++++- frontend/src/lib/api.ts | 61 ++ frontend/src/lib/types.ts | 30 + internal/api/server.go | 163 ++++ internal/domain/models.go | 38 +- internal/storage/sqlite.go | 767 ++++++++++++++++-- 6 files changed, 1213 insertions(+), 93 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 0127323..024a247 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -48,7 +48,7 @@ import { Hash, User as UserIcon, } from 'lucide-react'; -import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, Team, User } from '../../lib/types'; +import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types'; import { api, getToken, triggerHaptic } from '../../lib/api'; import { CodeBlockItem } from './CodeBlockItem'; import { UserAutocomplete } from '../UserAutocomplete'; @@ -69,6 +69,9 @@ interface FolderTreeItem { depth: number; subfolders: FolderTreeItem[]; docs: Document[]; + sharedFolder?: SharedFolder; + isShared?: boolean; + isSharedRoot?: boolean; } interface SlashCommandItem { @@ -199,6 +202,12 @@ export const WorkspaceView: React.FC = ({ const [sharingLoading, setSharingLoading] = useState(false); const [shareError, setShareError] = useState(null); + // --- FOLDER SHARING STATE --- + const [sharedFolders, setSharedFolders] = useState([]); + const [sharingFolder, setSharingFolder] = useState(null); + const [showFolderShareModal, setShowFolderShareModal] = useState(false); + const [folderShares, setFolderShares] = useState([]); + // --- DOCUMENT LOCKING STATE --- const [activeLock, setActiveLock] = useState(null); const [holdsLock, setHoldsLock] = useState(false); @@ -411,14 +420,16 @@ export const WorkspaceView: React.FC = ({ const loadDocs = async (selectId?: string | null) => { try { - const [list, serverFolders, tags] = await Promise.all([ + const [list, serverFolders, tags, sFolders] = await Promise.all([ api.getDocuments(), api.getFolders(), api.getWorkspaceTags().catch(() => []), + api.getSharedFolders().catch(() => []), ]); setDocs(list); setCustomFolders(serverFolders); setWorkspaceTags(tags); + setSharedFolders(sFolders); if (selectId) { const found = list.find((d) => d.id === selectId); if (found) { @@ -637,6 +648,8 @@ export const WorkspaceView: React.FC = ({ } } else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') { api.getFolders().then(setCustomFolders).catch(() => {}); + api.getSharedFolders().then(setSharedFolders).catch(() => {}); + api.getDocuments().then(setDocs).catch(() => {}); } }; @@ -665,6 +678,30 @@ export const WorkspaceView: React.FC = ({ } }; + const openFolderShareModal = async (node: FolderTreeItem) => { + try { + let sf = node.sharedFolder; + if (!sf) { + sf = await api.createSharedFolder({ name: node.name, mountPath: node.path }); + const sfList = await api.getSharedFolders(); + setSharedFolders(sfList); + } + const [shares, usersList, teamsList] = await Promise.all([ + api.getFolderShares(sf.id).catch(() => []), + api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []), + api.getUserTeams().catch(() => []), + ]); + setFolderShares(shares); + setCollaborators(usersList); + setShareTeams(teamsList); + setSharingFolder(sf); + setShowFolderShareModal(true); + } catch (err) { + console.error('Failed to open folder share modal', err); + alert('Fehler beim Öffnen der Ordnerfreigabe'); + } + }; + const handleAddShare = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedDoc) return; @@ -796,6 +833,16 @@ export const WorkspaceView: React.FC = ({ // Compute all unique folder paths const allFolderPaths = useMemo(() => { const set = new Set(customFolders); + sharedFolders.forEach((sf) => { + if (sf.mountPath) { + const parts = sf.mountPath.split('/'); + let cur = ''; + for (const p of parts) { + cur = cur ? `${cur}/${p}` : p; + set.add(cur); + } + } + }); docs.forEach((doc) => { const f = getDocFolder(doc); if (f) { @@ -808,7 +855,7 @@ export const WorkspaceView: React.FC = ({ } }); return Array.from(set).sort((a, b) => a.localeCompare(b)); - }, [customFolders, docs]); + }, [customFolders, sharedFolders, docs]); // Keep folders expanded by default useEffect(() => { @@ -919,6 +966,27 @@ export const WorkspaceView: React.FC = ({ triggerHaptic(15); + // Check if sourcePath is the root mount of a shared folder + const matchingSF = sharedFolders.find((sf) => sf.mountPath === cleanSource); + if (matchingSF) { + try { + await api.updateFolderMount(matchingSF.id, newFolderPath); + const sfList = await api.getSharedFolders(); + setSharedFolders(sfList); + await loadDocs(selectedDoc?.id); + setExpandedFolders((prev) => { + const next = new Set(prev); + next.add(newFolderPath); + if (cleanTarget) next.add(cleanTarget); + return next; + }); + } catch (err: any) { + console.error('Failed to move shared folder mount', err); + alert(err.message || 'Fehler beim Verschieben des geteilten Ordners'); + } + return; + } + // 1. Update all documents that are in cleanSource or any subfolder of cleanSource const docsToUpdate = docs.filter((d) => { const f = getDocFolder(d); @@ -948,7 +1016,7 @@ export const WorkspaceView: React.FC = ({ if (found) setSelectedDoc(found); } - // Persist all affected docs to backend + // Persist all affected docs to backend using api.moveDocument for (const d of docsToUpdate) { const f = getDocFolder(d); let nextFolder = newFolderPath; @@ -956,15 +1024,7 @@ export const WorkspaceView: React.FC = ({ const suffix = f.slice(cleanSource.length); nextFolder = `${newFolderPath}${suffix}`; } - const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:')); - if (nextFolder) updatedTags.push(`folder:${nextFolder}`); - - api - .updateDocument(d.id, { - folderPath: nextFolder, - tags: updatedTags, - }) - .catch((err) => console.error('Failed to update doc folder', err)); + api.moveDocument(d.id, nextFolder).catch((err) => console.error('Failed to move doc', err)); } // 2. Sync folder structure with server @@ -995,18 +1055,41 @@ export const WorkspaceView: React.FC = ({ if (getDocFolder(doc) === cleanPath) return; triggerHaptic(15); - const updatedTags = (doc.tags || []).filter((t) => !t.startsWith('folder:')); - if (cleanPath) { - updatedTags.push(`folder:${cleanPath}`); - } - const updatedDoc: Document = { - ...doc, - folderPath: cleanPath, - tags: updatedTags, - }; - await persistDocument(updatedDoc, true); - if (cleanPath) { - setExpandedFolders((prev) => new Set([...prev, cleanPath])); + try { + const updatedDoc = await api.moveDocument(doc.id, cleanPath); + setDocs((prev) => + prev.map((d) => + d.id === doc.id + ? { + ...d, + folderPath: updatedDoc.folderPath, + sharedFolderId: updatedDoc.sharedFolderId, + subFolderPath: updatedDoc.subFolderPath, + isSharedFolder: updatedDoc.isSharedFolder, + } + : d + ) + ); + if (selectedDoc?.id === doc.id) { + setSelectedDoc((prev) => + prev + ? { + ...prev, + folderPath: updatedDoc.folderPath, + sharedFolderId: updatedDoc.sharedFolderId, + subFolderPath: updatedDoc.subFolderPath, + isSharedFolder: updatedDoc.isSharedFolder, + } + : null + ); + } + if (cleanPath) { + setExpandedFolders((prev) => new Set([...prev, cleanPath])); + } + api.getFolders().then(setCustomFolders).catch(() => {}); + } catch (err: any) { + console.error('Failed to move doc', err); + alert(err.message || 'Fehler beim Verschieben der Notiz'); } }; @@ -1043,6 +1126,30 @@ export const WorkspaceView: React.FC = ({ }; const handleDeleteFolder = async (folderPath: string, folderName: string) => { + const matchingSF = sharedFolders.find((sf) => sf.mountPath === folderPath); + if (matchingSF) { + if (matchingSF.isOwner) { + if (!window.confirm(`Möchtest du den geteilten Ordner "${folderName}" wirklich löschen? Die Freigabe wird für alle Teilnehmer aufgehoben.`)) { + return; + } + } else { + if (!window.confirm(`Möchtest du den geteilten Ordner "${folderName}" aus deinem Workspace entfernen? (Die Quell-Dateien des Eigentümers bleiben erhalten).`)) { + return; + } + } + try { + await api.deleteSharedFolder(matchingSF.id); + const sfList = await api.getSharedFolders(); + setSharedFolders(sfList); + await loadDocs(selectedDoc?.id); + return; + } catch (err: any) { + console.error('Failed to delete shared folder', err); + alert(err.message || 'Fehler beim Löschen des geteilten Ordners'); + return; + } + } + // Find all documents in this folder and subfolders const docsInFolder = docs.filter((d) => { const f = getDocFolder(d); @@ -2787,12 +2894,30 @@ export const WorkspaceView: React.FC = ({ allFolderPaths.forEach((path) => { const parts = path.split('/'); const name = parts[parts.length - 1]; + + let matchingSF: SharedFolder | undefined; + let isRoot = false; + for (const sf of sharedFolders) { + if (sf.mountPath === path) { + matchingSF = sf; + isRoot = true; + break; + } else if (path.startsWith(sf.mountPath + '/')) { + matchingSF = sf; + isRoot = false; + break; + } + } + folderMap.set(path, { path, name, depth: parts.length - 1, subfolders: [], docs: [], + sharedFolder: matchingSF, + isShared: !!matchingSF, + isSharedRoot: isRoot, }); }); @@ -2822,7 +2947,7 @@ export const WorkspaceView: React.FC = ({ const rootOnlyDocs = filteredDocs.filter((d) => !getDocFolder(d)); return { rootTree: rootFolders, rootDocs: rootOnlyDocs }; - }, [allFolderPaths, filteredDocs]); + }, [allFolderPaths, filteredDocs, sharedFolders]); const pinnedDocs = useMemo(() => { return docs.filter((d) => d.isPinned); @@ -3091,10 +3216,29 @@ export const WorkspaceView: React.FC = ({ )} {node.name} + {node.isShared && ( + + + + )} ({totalItems})
+ )} +
+ +
+
+
+ + {/* Mobile Compact Header Bar (Live-Sync Status, Title, Drawer Switcher) */} +
+
+ +
+
+ {selectedDoc && ( + + )} +
+ +
@@ -3845,6 +3915,16 @@ export const WorkspaceView: React.FC = ({ + {isLockedByOther && ( +
+ +
+ Diese Notiz wird aktuell von {activeLock?.userName || 'einem anderen Benutzer'} bearbeitet. +

Die Bearbeitung ist vorübergehend gesperrt, um Zeilen- und Datenverlust zu vermeiden.

+
+
+ )} + {!isLockedByOther && selectedDoc.permission === 'read' && (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4421c01..1c6fef5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1019,6 +1019,8 @@ export function getOfflineQueue(): Thought[] { let activeGlobalWS: WebSocket | null = null; let wsReconnectTimer: any = null; let wsListeners: ((msg: WSMessage) => void)[] = []; +let wsStatusListeners: ((connected: boolean) => void)[] = []; +let isWSConnected = false; let manualDisconnect = false; export function buildWebSocketURL(listId?: string): string { @@ -1030,6 +1032,30 @@ export function buildWebSocketURL(listId?: string): string { return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`; } +export function isWebSocketConnected(): boolean { + return isWSConnected && activeGlobalWS !== null && activeGlobalWS.readyState === WebSocket.OPEN; +} + +export function onWebSocketStatus(callback: (connected: boolean) => void): () => void { + wsStatusListeners.push(callback); + callback(isWebSocketConnected()); + return () => { + wsStatusListeners = wsStatusListeners.filter((fn) => fn !== callback); + }; +} + +function notifyStatusChange(connected: boolean) { + isWSConnected = connected; + wsStatusListeners.forEach((fn) => { + try { + fn(connected); + } catch (e) { + console.error('WS status listener error', e); + } + }); + window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected } })); +} + export function connectWebSocket(): void { const token = getToken(); if (!token) return; @@ -1044,6 +1070,10 @@ export function connectWebSocket(): void { const ws = new WebSocket(url); activeGlobalWS = ws; + ws.onopen = () => { + notifyStatusChange(true); + }; + ws.onmessage = (event) => { try { const msg = JSON.parse(event.data) as WSMessage; @@ -1062,6 +1092,7 @@ export function connectWebSocket(): void { ws.onclose = () => { activeGlobalWS = null; + notifyStatusChange(false); if (!manualDisconnect && getToken()) { clearTimeout(wsReconnectTimer); wsReconnectTimer = setTimeout(() => { @@ -1071,15 +1102,31 @@ export function connectWebSocket(): void { }; ws.onerror = () => { + notifyStatusChange(false); try { ws.close(); } catch {} }; } catch (e) { console.error('Failed to connect WebSocket', e); + notifyStatusChange(false); } } +// Ensure reconnect when browser recovers from sleep or tab visibility +if (typeof window !== 'undefined') { + window.addEventListener('online', () => { + if (getToken() && !isWebSocketConnected()) { + connectWebSocket(); + } + }); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && getToken() && !isWebSocketConnected()) { + connectWebSocket(); + } + }); +} + export function disconnectWebSocket(): void { manualDisconnect = true; clearTimeout(wsReconnectTimer); @@ -1089,6 +1136,7 @@ export function disconnectWebSocket(): void { } catch {} activeGlobalWS = null; } + notifyStatusChange(false); } export function sendWebSocketMessage(msg: any): void { diff --git a/internal/api/server.go b/internal/api/server.go index 0f71b95..5b6919a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1226,7 +1226,7 @@ func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) { userName = user.DisplayName } - info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 12*time.Second) + info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 20*time.Second) if !acquired { w.WriteHeader(http.StatusLocked) _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -1249,7 +1249,7 @@ func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") - ok := s.wsHub.HeartbeatDocLock(docID, userID, 12*time.Second) + ok := s.wsHub.HeartbeatDocLock(docID, userID, 20*time.Second) writeJSON(w, http.StatusOK, map[string]bool{"extended": ok}) } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 5505944..399d85a 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -362,6 +362,10 @@ func (s *Store) migrate() error { "CREATE TABLE IF NOT EXISTS doc_user_folders (user_id TEXT NOT NULL, doc_id TEXT NOT NULL, folder_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, doc_id));", "CREATE INDEX IF NOT EXISTS idx_doc_user_folders_user ON doc_user_folders(user_id);", + + "CREATE TABLE IF NOT EXISTS workspace_folders (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, shared_folder_id TEXT NOT NULL DEFAULT '', path TEXT NOT NULL, sub_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL);", + "CREATE INDEX IF NOT EXISTS idx_workspace_folders_user ON workspace_folders(user_id);", + "CREATE INDEX IF NOT EXISTS idx_workspace_folders_sf ON workspace_folders(shared_folder_id);", } for _, q := range columnMigrations { _, _ = s.db.Exec(q) @@ -2101,14 +2105,39 @@ func (s *Store) GetFolders(userID string) ([]string, error) { // 2. Add mounted shared folders (so even empty shared folders appear in the tree) mounts, _, err := s.getUserFolderMounts(userID) if err == nil { - for _, mPath := range mounts { + for sfID, mPath := range mounts { if mPath != "" { addFolderSegments(mPath, folderSet) + + // 2b. Add all subfolders created in this shared folder + sRows, err := s.db.Query(`SELECT sub_path FROM workspace_folders WHERE shared_folder_id = ?`, sfID) + if err == nil { + for sRows.Next() { + var sp string + if err := sRows.Scan(&sp); err == nil && sp != "" { + fullP := mPath + "/" + sp + addFolderSegments(fullP, folderSet) + } + } + _ = sRows.Close() + } } } } - // 3. Scan physical directories on disk in docDir/userID + // 3. Add personal empty folders created by this user + pRows, err := s.db.Query(`SELECT path FROM workspace_folders WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID) + if err == nil { + for pRows.Next() { + var p string + if err := pRows.Scan(&p); err == nil && p != "" { + addFolderSegments(p, folderSet) + } + } + _ = pRows.Close() + } + + // 4. Scan physical directories on disk in docDir/userID if s.docDir != "" { userDir := filepath.Join(s.docDir, userID) if fi, err := os.Stat(userDir); err == nil && fi.IsDir() { @@ -2158,6 +2187,31 @@ func (s *Store) CreateFolder(userID, folderPath string) (string, error) { return "", fmt.Errorf("ungültiger Ordnerpfad") } + mounts, _, err := s.getUserFolderMounts(userID) + var sfID, subPath string + if err == nil { + sfID, subPath, _ = matchMountPath(clean, mounts) + } + + now := time.Now().UTC().Format(time.RFC3339) + id := domain.NewID("wfld") + + if sfID != "" { + var existsID string + err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE shared_folder_id = ? AND sub_path = ?`, sfID, subPath).Scan(&existsID) + if err == sql.ErrNoRows { + _, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, ?, ?, ?, ?)`, + id, userID, sfID, clean, subPath, now) + } + } else { + var existsID string + err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID, clean).Scan(&existsID) + if err == sql.ErrNoRows { + _, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`, + id, userID, clean, now) + } + } + if s.docDir != "" { targetDir := filepath.Join(s.docDir, userID, clean) if err := os.MkdirAll(targetDir, 0755); err != nil { @@ -2201,6 +2255,21 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) { _, _ = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, id, userID) } + // Clean up from workspace_folders + mounts, _, err := s.getUserFolderMounts(userID) + if err == nil { + sfID, subPath, isMatch := matchMountPath(clean, mounts) + if isMatch && sfID != "" { + if subPath == "" { + _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, sfID) + } else { + _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ? AND (sub_path = ? OR sub_path LIKE ?)`, + sfID, subPath, subPath+"/%") + } + } + } + _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%") + // Remove physical folder and subdirectories from disk if s.docDir != "" { targetDir := filepath.Join(s.docDir, userID, clean) @@ -2367,6 +2436,28 @@ func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) } } + // Migrate any existing workspace_folders created by this user in cleanMount + fRows, err := s.db.Query(`SELECT id, path FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, cleanMount, cleanMount+"/%") + if err == nil { + type wfEntry struct{ id, path string } + var wfEntries []wfEntry + for fRows.Next() { + var wfe wfEntry + if err := fRows.Scan(&wfe.id, &wfe.path); err == nil { + wfEntries = append(wfEntries, wfe) + } + } + _ = fRows.Close() + + for _, wfe := range wfEntries { + subP := "" + if strings.HasPrefix(wfe.path, cleanMount+"/") { + subP = strings.TrimPrefix(wfe.path, cleanMount+"/") + } + _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, folderID, subP, wfe.id) + } + } + var ownerName string _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName) @@ -2646,6 +2737,7 @@ func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error { _, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id = ?`, folderID) _, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ?`, folderID) _, _ = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ?`, folderID) + _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, folderID) _, err = s.db.Exec(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID) return err } -- 2.54.0 From ed673b070733ed8d10c66ff1cee0501241abad0c Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 11:05:09 +0100 Subject: [PATCH 11/19] feat(workspace): migrate notes, blocks, and folders to pure database-first architecture with atomic block patching and live sync --- .../src/features/workspace/WorkspaceView.tsx | 494 ++++++++-- frontend/src/lib/api.ts | 19 + frontend/src/lib/types.ts | 15 + internal/api/server.go | 79 +- internal/domain/models.go | 19 + internal/storage/sqlite.go | 910 +++++++++++++----- internal/storage/sqlite_test.go | 155 +++ 7 files changed, 1353 insertions(+), 338 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 0c6654c..e7f2524 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -49,7 +49,7 @@ import { User as UserIcon, Radio, } from 'lucide-react'; -import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types'; +import { BlockType, Document, DocumentBlock, DocBlocksPatchEvent, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types'; import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected } from '../../lib/api'; import { CodeBlockItem } from './CodeBlockItem'; import { UserAutocomplete } from '../UserAutocomplete'; @@ -237,7 +237,130 @@ export const WorkspaceView: React.FC = ({ const blockInputRefs = useRef>({}); const isLockedByOther = !!(activeLock && activeLock.userId !== currentUser?.id); - const isReadOnly = selectedDoc?.permission === 'read' || isLockedByOther; + const isReadOnly = selectedDoc?.permission === 'read'; + + // Track server-synced blocks & metadata per document, plus local unsaved block deltas + const syncedDocMetaRef = useRef>(new Map()); + const syncedBlocksRef = useRef>>(new Map()); + const syncedOrderRef = useRef>(new Map()); + const localDirtyBlocksRef = useRef>>(new Map()); + const localDeletedBlocksRef = useRef>>(new Map()); + + const blocksAreEqual = (a?: DocumentBlock, b?: DocumentBlock): boolean => { + if (!a || !b) return false; + return ( + a.id === b.id && + (a.type || 'bullet') === (b.type || 'bullet') && + (a.content || '') === (b.content || '') && + (a.level || 0) === (b.level || 0) && + !!a.checked === !!b.checked && + !!a.collapsed === !!b.collapsed && + (a.lang || '') === (b.lang || '') + ); + }; + + const recordSyncedDoc = (doc: Document) => { + if (!doc || !doc.id) return; + syncedDocMetaRef.current.set(doc.id, { + title: doc.title || '', + icon: doc.icon || '📄', + tagsJson: JSON.stringify((doc.tags || []).filter((t) => !t.startsWith('folder:'))), + isPinned: !!doc.isPinned, + }); + const bMap = new Map(); + const order: string[] = []; + (doc.blocks || []).forEach((b) => { + bMap.set(b.id, { ...b }); + order.push(b.id); + }); + syncedBlocksRef.current.set(doc.id, bMap); + syncedOrderRef.current.set(doc.id, order); + }; + + const mergeIncomingBlocks = ( + docId: string, + currentBlocks: DocumentBlock[], + patch: { upserted?: DocumentBlock[]; deletedIds?: string[]; blockOrder?: string[] } + ): DocumentBlock[] => { + const dirtyMap = localDirtyBlocksRef.current.get(docId); + const localDeleted = localDeletedBlocksRef.current.get(docId); + + // Update server baseline refs + let srvMap = syncedBlocksRef.current.get(docId); + if (!srvMap) { + srvMap = new Map(); + currentBlocks.forEach((b) => srvMap!.set(b.id, { ...b })); + syncedBlocksRef.current.set(docId, srvMap); + } + const deletedSet = new Set(patch.deletedIds || []); + deletedSet.forEach((delId) => srvMap!.delete(delId)); + (patch.upserted || []).forEach((b) => srvMap!.set(b.id, { ...b })); + if (patch.blockOrder && patch.blockOrder.length > 0) { + syncedOrderRef.current.set(docId, [...patch.blockOrder]); + } + + // Build merged block map preserving any block with unsaved local edits + const blockMap = new Map(); + currentBlocks.forEach((b) => { + if (!deletedSet.has(b.id) || dirtyMap?.has(b.id)) { + blockMap.set(b.id, b); + } + }); + + (patch.upserted || []).forEach((b) => { + if (localDeleted?.has(b.id)) { + return; + } + if (dirtyMap?.has(b.id)) { + blockMap.set(b.id, dirtyMap.get(b.id)!); + } else { + blockMap.set(b.id, b); + } + }); + + // Reconcile ordering + const mergedOrder: string[] = []; + const seen = new Set(); + + if (patch.blockOrder && patch.blockOrder.length > 0) { + patch.blockOrder.forEach((id) => { + if (blockMap.has(id) && !seen.has(id) && !localDeleted?.has(id)) { + mergedOrder.push(id); + seen.add(id); + } + }); + // Preserve any locally created unsaved blocks in their relative position + currentBlocks.forEach((b, idx) => { + if (!seen.has(b.id) && blockMap.has(b.id) && !deletedSet.has(b.id)) { + let insertIdx = 0; + for (let j = idx - 1; j >= 0; j--) { + const predPos = mergedOrder.indexOf(currentBlocks[j].id); + if (predPos !== -1) { + insertIdx = predPos + 1; + break; + } + } + mergedOrder.splice(insertIdx, 0, b.id); + seen.add(b.id); + } + }); + } else { + currentBlocks.forEach((b) => { + if (blockMap.has(b.id) && !seen.has(b.id)) { + mergedOrder.push(b.id); + seen.add(b.id); + } + }); + (patch.upserted || []).forEach((b) => { + if (blockMap.has(b.id) && !seen.has(b.id)) { + mergedOrder.push(b.id); + seen.add(b.id); + } + }); + } + + return mergedOrder.map((id) => blockMap.get(id)!).filter(Boolean); + }; const stopHeartbeat = () => { if (heartbeatTimerRef.current) { @@ -282,7 +405,6 @@ export const WorkspaceView: React.FC = ({ const ensureLock = async (force = false): Promise => { if (!selectedDoc || selectedDoc.permission === 'read') return false; - if (isLockedByOther) return false; if (holdsLockRef.current && !force) { return true; } @@ -296,8 +418,6 @@ export const WorkspaceView: React.FC = ({ return true; } else if (res.lock) { setActiveLock(res.lock); - holdsLockRef.current = false; - setHoldsLock(false); return false; } } catch (e) { @@ -312,7 +432,7 @@ export const WorkspaceView: React.FC = ({ clearTimeout(idleUnlockTimerRef.current); idleUnlockTimerRef.current = null; } - if (!isReadOnly && !isLockedByOther) { + if (!isReadOnly) { ensureLock(); } }; @@ -322,11 +442,9 @@ export const WorkspaceView: React.FC = ({ if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); } - // If a block is currently being dragged, do not schedule an unlock if (isDraggingBlockRef.current) { return; } - // Generous grace period: if user clicked into another block in the doc or paused typing idleUnlockTimerRef.current = setTimeout(async () => { if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { await flushPendingSave(); @@ -335,7 +453,7 @@ export const WorkspaceView: React.FC = ({ }, 8000); }; - // Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD) + // Debounced auto-save refs (500ms delay) const debounceSaveTimerRef = useRef(null); const pendingSaveDocRef = useRef(null); @@ -343,27 +461,86 @@ export const WorkspaceView: React.FC = ({ if (isReadOnly) return; let saveSucceeded = false; try { - const saved = await api.updateDocument(docToSave.id, { - title: docToSave.title, - icon: docToSave.icon, - folderPath: docToSave.folderPath, - blocks: docToSave.blocks, - tags: docToSave.tags, - isPinned: docToSave.isPinned, + const docId = docToSave.id; + const dirtyMap = localDirtyBlocksRef.current.get(docId); + const deletedSet = localDeletedBlocksRef.current.get(docId); + const srvBlocks = syncedBlocksRef.current.get(docId); + const srvOrder = syncedOrderRef.current.get(docId) || []; + + const upsertedMap = new Map(); + if (dirtyMap) { + dirtyMap.forEach((b, id) => upsertedMap.set(id, b)); + } + (docToSave.blocks || []).forEach((b) => { + const srv = srvBlocks?.get(b.id); + if (!srv || !blocksAreEqual(b, srv)) { + upsertedMap.set(b.id, b); + } }); + + const upsertedToSend = Array.from(upsertedMap.values()); + const deletedToSend = Array.from(deletedSet?.values() || []); + const currentOrder = (docToSave.blocks || []).map((b) => b.id); + const orderChanged = + currentOrder.length !== srvOrder.length || + currentOrder.some((id, idx) => id !== srvOrder[idx]); + + const srvMeta = syncedDocMetaRef.current.get(docId); + const cleanTags = (docToSave.tags || []).filter((t) => !t.startsWith('folder:')); + const cleanTagsJson = JSON.stringify(cleanTags); + const metaChanged = + !srvMeta || + srvMeta.title !== docToSave.title || + srvMeta.icon !== docToSave.icon || + srvMeta.tagsJson !== cleanTagsJson || + srvMeta.isPinned !== !!docToSave.isPinned; + + // Clear the sent snapshot from dirty/deleted maps before awaiting network call + if (dirtyMap) { + upsertedToSend.forEach((b) => { + const cur = dirtyMap.get(b.id); + if (cur && blocksAreEqual(cur, b)) { + dirtyMap.delete(b.id); + } + }); + } + if (deletedSet) { + deletedToSend.forEach((id) => deletedSet.delete(id)); + } + + if (upsertedToSend.length > 0 || deletedToSend.length > 0 || orderChanged) { + const patchedDoc = await api.patchDocumentBlocks(docId, { + upserted: upsertedToSend, + deletedIds: deletedToSend, + blockOrder: currentOrder, + }); + // Update server block baseline with patched result + const nextSrvMap = new Map(); + const nextSrvOrder: string[] = []; + (patchedDoc.blocks || []).forEach((b) => { + nextSrvMap.set(b.id, { ...b }); + nextSrvOrder.push(b.id); + }); + syncedBlocksRef.current.set(docId, nextSrvMap); + syncedOrderRef.current.set(docId, nextSrvOrder); + } + + if (metaChanged) { + await api.updateDocument(docId, { + title: docToSave.title, + icon: docToSave.icon, + tags: cleanTags, + isPinned: docToSave.isPinned, + }); + syncedDocMetaRef.current.set(docId, { + title: docToSave.title, + icon: docToSave.icon, + tagsJson: cleanTagsJson, + isPinned: !!docToSave.isPinned, + }); + } + saveSucceeded = true; - setSelectedDoc((prev) => - prev && prev.id === saved.id - ? { - ...prev, - ...saved, - permission: prev.permission, - isOwner: prev.isOwner, - ownerName: prev.ownerName, - sharedWith: prev.sharedWith, - } - : prev - ); onStatsChange(); api.getWorkspaceTags().then(setWorkspaceTags).catch(() => {}); if (selectedTag) { @@ -371,11 +548,6 @@ export const WorkspaceView: React.FC = ({ } } catch (e: any) { console.error('Failed to save document to backend', e); - if (e.message && e.message.includes('423')) { - api.getDocLock(docToSave.id).then((res) => { - if (res.lock) setActiveLock(res.lock); - }).catch(() => {}); - } } finally { if (saveSucceeded) { if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) { @@ -383,7 +555,6 @@ export const WorkspaceView: React.FC = ({ setSaveState('saved'); } } else { - // Keep pendingSaveDocRef so changes are preserved and not silently marked as saved! setSaveState('saving'); } if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { @@ -413,7 +584,40 @@ export const WorkspaceView: React.FC = ({ const persistDocument = async (updatedDoc: Document, immediate = false) => { if (isReadOnly) return; - await ensureLock(); + ensureLock(); + + // Record block-level delta against previous local state and server baseline + const docId = updatedDoc.id; + let dirtyMap = localDirtyBlocksRef.current.get(docId); + if (!dirtyMap) { + dirtyMap = new Map(); + localDirtyBlocksRef.current.set(docId, dirtyMap); + } + let deletedSet = localDeletedBlocksRef.current.get(docId); + if (!deletedSet) { + deletedSet = new Set(); + localDeletedBlocksRef.current.set(docId, deletedSet); + } + const srvBlocks = syncedBlocksRef.current.get(docId); + const prevLocalBlocks = + selectedDoc && selectedDoc.id === docId ? selectedDoc.blocks || [] : []; + + const nextIds = new Set(); + (updatedDoc.blocks || []).forEach((b) => { + nextIds.add(b.id); + deletedSet!.delete(b.id); + const srv = srvBlocks?.get(b.id); + if (!srv || !blocksAreEqual(b, srv)) { + dirtyMap!.set(b.id, { ...b }); + } + }); + + prevLocalBlocks.forEach((prevB) => { + if (!nextIds.has(prevB.id)) { + dirtyMap!.delete(prevB.id); + deletedSet!.add(prevB.id); + } + }); // 1. Update local UI state immediately for responsive, instant typing setSelectedDoc(updatedDoc); @@ -430,7 +634,6 @@ export const WorkspaceView: React.FC = ({ if (immediate) { await flushPendingSave(); } else { - // 500 ms debounced delay after last keystroke so disk/SSD is only written after typing pauses debounceSaveTimerRef.current = setTimeout(async () => { debounceSaveTimerRef.current = null; if (pendingSaveDocRef.current) { @@ -438,7 +641,7 @@ export const WorkspaceView: React.FC = ({ pendingSaveDocRef.current = null; await executeSaveToBackend(doc); } - }, 500); + }, 350); } }; @@ -450,6 +653,7 @@ export const WorkspaceView: React.FC = ({ api.getWorkspaceTags().catch(() => []), api.getSharedFolders().catch(() => []), ]); + list.forEach(recordSyncedDoc); setDocs(list); setCustomFolders(serverFolders); setWorkspaceTags(tags); @@ -616,7 +820,7 @@ export const WorkspaceView: React.FC = ({ }; }, []); - // Real-time WebSocket listener for Document & Lock events + // Real-time WebSocket listener for Document, Block Delta & Folder events useEffect(() => { const handleWs = (e: any) => { const msg = e.detail; @@ -634,30 +838,85 @@ export const WorkspaceView: React.FC = ({ const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id; if (unlockedDocId && unlockedDocId === selectedDocIdRef.current) { setActiveLock(null); - // Fetch fresh document from backend so updates made by the other user are immediately visible - api.getDocument(unlockedDocId).then((freshDoc) => { - if (freshDoc && freshDoc.id === selectedDocIdRef.current) { - setSelectedDoc((prev) => (prev && prev.id === freshDoc.id ? { ...prev, ...freshDoc, isPinned: prev.isPinned } : prev)); - setDocs((prev) => prev.map((d) => (d.id === freshDoc.id ? { ...d, ...freshDoc, isPinned: d.isPinned } : d))); + } + } else if (msg.event === 'DOC_BLOCKS_PATCHED') { + const patch = msg.payload as DocBlocksPatchEvent; + if (!patch || !patch.docId) return; + + setDocs((prev) => + prev.map((d) => { + if (d.id !== patch.docId) return d; + const merged = mergeIncomingBlocks(d.id, d.blocks || [], { + upserted: patch.upserted, + deletedIds: patch.deletedIds, + blockOrder: patch.blockOrder, + }); + return { ...d, blocks: merged, updatedAt: patch.updatedAt || d.updatedAt }; + }) + ); + + if (selectedDocIdRef.current === patch.docId) { + setSelectedDoc((prev) => { + if (!prev || prev.id !== patch.docId) return prev; + const merged = mergeIncomingBlocks(prev.id, prev.blocks || [], { + upserted: patch.upserted, + deletedIds: patch.deletedIds, + blockOrder: patch.blockOrder, + }); + const nextDoc = { ...prev, blocks: merged, updatedAt: patch.updatedAt || prev.updatedAt }; + if (pendingSaveDocRef.current && pendingSaveDocRef.current.id === patch.docId) { + pendingSaveDocRef.current = { ...pendingSaveDocRef.current, blocks: merged }; } - }).catch(() => {}); + return nextDoc; + }); } } else if (msg.event === 'DOC_CREATED') { const created = msg.payload as Document; if (created && created.id) { + recordSyncedDoc(created); setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev])); onStatsChange(); } } else if (msg.event === 'DOC_UPDATED') { const updated = msg.payload as Document; if (updated && updated.id) { + const incomingOrder = (updated.blocks || []).map((b) => b.id); setDocs((prev) => - prev.map((d) => (d.id === updated.id ? { ...d, ...updated, isPinned: d.isPinned } : d)) + prev.map((d) => { + if (d.id !== updated.id) return d; + const mergedBlocks = mergeIncomingBlocks(d.id, d.blocks || [], { + upserted: updated.blocks || [], + blockOrder: incomingOrder, + }); + return { + ...d, + ...updated, + blocks: mergedBlocks, + isPinned: d.isPinned, + }; + }) ); if (selectedDocIdRef.current === updated.id) { - if (!holdsLockRef.current) { - setSelectedDoc((prev) => (prev ? { ...prev, ...updated, isPinned: prev.isPinned } : updated)); - } + setSelectedDoc((prev) => { + if (!prev || prev.id !== updated.id) return prev; + const mergedBlocks = mergeIncomingBlocks(prev.id, prev.blocks || [], { + upserted: updated.blocks || [], + blockOrder: incomingOrder, + }); + const nextDoc = { + ...prev, + ...updated, + blocks: mergedBlocks, + isPinned: prev.isPinned, + }; + if (pendingSaveDocRef.current && pendingSaveDocRef.current.id === updated.id) { + pendingSaveDocRef.current = { + ...pendingSaveDocRef.current, + blocks: mergedBlocks, + }; + } + return nextDoc; + }); } onStatsChange(); } @@ -673,7 +932,28 @@ export const WorkspaceView: React.FC = ({ } else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') { api.getFolders().then(setCustomFolders).catch(() => {}); api.getSharedFolders().then(setSharedFolders).catch(() => {}); - api.getDocuments().then(setDocs).catch(() => {}); + api.getDocuments().then((freshList) => { + freshList.forEach(recordSyncedDoc); + setDocs(freshList); + if (selectedDocIdRef.current) { + const found = freshList.find((d) => d.id === selectedDocIdRef.current); + if (found) { + setSelectedDoc((prev) => + prev && prev.id === found.id + ? { + ...prev, + folderPath: found.folderPath, + sharedFolderId: found.sharedFolderId, + subFolderPath: found.subFolderPath, + isSharedFolder: found.isSharedFolder, + permission: found.permission, + isOwner: found.isOwner, + } + : prev + ); + } + } + }).catch(() => {}); } }; @@ -849,9 +1129,7 @@ export const WorkspaceView: React.FC = ({ const getDocFolder = (doc: Document): string => { - if (doc.folderPath) return doc.folderPath; - const tag = doc.tags?.find((t) => t.startsWith('folder:')); - return tag ? tag.replace('folder:', '') : ''; + return doc.folderPath || ''; }; // Compute all unique folder paths @@ -894,9 +1172,6 @@ export const WorkspaceView: React.FC = ({ triggerHaptic(15); const tags = ['notiz']; const path = folderPath || ''; - if (path) { - tags.push(`folder:${path}`); - } const created = await api.createDocument({ title: 'Neue Notiz', icon: '📄', @@ -945,16 +1220,37 @@ export const WorkspaceView: React.FC = ({ const handleChangeDocFolder = async (folderPath: string) => { if (!selectedDoc) return; const cleanPath = folderPath.trim(); - const updatedTags = (selectedDoc.tags || []).filter((t) => !t.startsWith('folder:')); - if (cleanPath) { - updatedTags.push(`folder:${cleanPath}`); + try { + const updatedDoc = await api.moveDocument(selectedDoc.id, cleanPath); + setDocs((prev) => + prev.map((d) => + d.id === selectedDoc.id + ? { + ...d, + folderPath: updatedDoc.folderPath, + sharedFolderId: updatedDoc.sharedFolderId, + subFolderPath: updatedDoc.subFolderPath, + isSharedFolder: updatedDoc.isSharedFolder, + } + : d + ) + ); + setSelectedDoc((prev) => + prev && prev.id === selectedDoc.id + ? { + ...prev, + folderPath: updatedDoc.folderPath, + sharedFolderId: updatedDoc.sharedFolderId, + subFolderPath: updatedDoc.subFolderPath, + isSharedFolder: updatedDoc.isSharedFolder, + } + : prev + ); + api.getFolders().then(setCustomFolders).catch(() => {}); + } catch (err: any) { + console.error('Failed to change document folder', err); + alert(err.message || 'Fehler beim Verschieben der Notiz'); } - const updatedDoc = { - ...selectedDoc, - folderPath: cleanPath, - tags: updatedTags, - }; - await persistDocument(updatedDoc, true); }; // Global drag cleanup so drag states never get stuck if dragend is lost during re-render @@ -1020,16 +1316,10 @@ export const WorkspaceView: React.FC = ({ const updatedDocsList = docs.map((d) => { const f = getDocFolder(d); if (f === cleanSource) { - const nextFolder = newFolderPath; - const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:')); - if (nextFolder) updatedTags.push(`folder:${nextFolder}`); - return { ...d, folderPath: nextFolder, tags: updatedTags }; + return { ...d, folderPath: newFolderPath }; } else if (f.startsWith(cleanSource + '/')) { const suffix = f.slice(cleanSource.length); - const nextFolder = `${newFolderPath}${suffix}`; - const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:')); - if (nextFolder) updatedTags.push(`folder:${nextFolder}`); - return { ...d, folderPath: nextFolder, tags: updatedTags }; + return { ...d, folderPath: `${newFolderPath}${suffix}` }; } return d; }); @@ -1040,25 +1330,32 @@ export const WorkspaceView: React.FC = ({ if (found) setSelectedDoc(found); } - // Persist all affected docs to backend using api.moveDocument - for (const d of docsToUpdate) { - const f = getDocFolder(d); - let nextFolder = newFolderPath; - if (f.startsWith(cleanSource + '/')) { - const suffix = f.slice(cleanSource.length); - nextFolder = `${newFolderPath}${suffix}`; + try { + await api.createFolder(newFolderPath); + const subFoldersToRecreate = customFolders.filter((cf) => cf.startsWith(cleanSource + '/')); + for (const sf of subFoldersToRecreate) { + const suffix = sf.slice(cleanSource.length); + await api.createFolder(`${newFolderPath}${suffix}`); } - api.moveDocument(d.id, nextFolder).catch((err) => console.error('Failed to move doc', err)); + // Persist all affected docs to backend BEFORE deleting the old empty folder + await Promise.all( + docsToUpdate.map((d) => { + const f = getDocFolder(d); + let nextFolder = newFolderPath; + if (f.startsWith(cleanSource + '/')) { + const suffix = f.slice(cleanSource.length); + nextFolder = `${newFolderPath}${suffix}`; + } + return api.moveDocument(d.id, nextFolder); + }) + ); + await api.deleteFolder(cleanSource); + const freshFolders = await api.getFolders(); + setCustomFolders(freshFolders); + } catch (err) { + console.error('Error syncing moved folders with server', err); } - // 2. Sync folder structure with server - api - .createFolder(newFolderPath) - .then(() => api.deleteFolder(cleanSource)) - .then(() => api.getFolders()) - .then(setCustomFolders) - .catch((err) => console.error('Error syncing moved folders with server', err)); - // Expand the new folder path and its parent setExpandedFolders((prev) => { const next = new Set(prev); @@ -2887,10 +3184,13 @@ export const WorkspaceView: React.FC = ({ }; }); - // Persist immediately to backend & disk - await api.updateDocument(docId, { - blocks: updatedBlocks, - }); + // Persist immediately via atomic block patch + const toggledBlock = updatedBlocks.find((b) => b.id === blockId); + if (toggledBlock) { + await api.patchDocumentBlocks(docId, { + upserted: [toggledBlock], + }); + } }; // Jump directly to document from rollup @@ -3916,16 +4216,16 @@ export const WorkspaceView: React.FC = ({ {isLockedByOther && ( -
- +
+
- Diese Notiz wird aktuell von {activeLock?.userName || 'einem anderen Benutzer'} bearbeitet. -

Die Bearbeitung ist vorübergehend gesperrt, um Zeilen- und Datenverlust zu vermeiden.

+ {activeLock?.userName || 'Ein anderer Benutzer'} bearbeitet diese Notiz gerade ebenfalls. +

Live-Zusammenarbeit aktiv – Änderungen werden automatisch Punkt für Punkt synchronisiert.

)} - {!isLockedByOther && selectedDoc.permission === 'read' && ( + {selectedDoc.permission === 'read' && (
Du hast Nur-Lesezugriff auf diese Notiz. Änderungen können nicht vorgenommen werden. diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1c6fef5..acf96fe 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -576,6 +576,25 @@ export const api = { }; }, + async patchDocumentBlocks( + id: string, + payload: { + upserted?: DocumentBlock[]; + deletedIds?: string[]; + blockOrder?: string[]; + } + ): Promise { + const d = await request(`/api/v1/documents/${id}/blocks`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); + return { + ...d, + blocks: ensureArray(d.blocks), + tags: ensureArray(d.tags), + }; + }, + async deleteDocument(id: string): Promise { await request(`/api/v1/documents/${id}`, { method: 'DELETE' }); }, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f32d9e0..a29321b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -165,6 +165,21 @@ export interface DocumentBlock { collapsed?: boolean; } +export interface DocBlocksPatchRequest { + upserted?: DocumentBlock[]; + deletedIds?: string[]; + blockOrder?: string[]; +} + +export interface DocBlocksPatchEvent { + docId: string; + updatedBy: string; + updatedAt: string; + upserted?: DocumentBlock[]; + deletedIds?: string[]; + blockOrder?: string[]; +} + export interface DocShare { id: string; docId: string; diff --git a/internal/api/server.go b/internal/api/server.go index 5b6919a..849865c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -124,6 +124,7 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/documents", s.handleCreateDocument) r.Get("/api/v1/documents/{id}", s.handleGetDocument) r.Put("/api/v1/documents/{id}", s.handleUpdateDocument) + r.Patch("/api/v1/documents/{id}/blocks", s.handlePatchDocumentBlocks) r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument) r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument) @@ -772,6 +773,18 @@ func (s *Server) handlePurgeDiscarded(w http.ResponseWriter, r *http.Request) { // --- DOCUMENTS --- +func (s *Server) broadcastDocToAuthorizedUsers(docID, event string) { + users, err := s.store.GetAuthorizedDocUsers(docID) + if err != nil { + return + } + for _, uid := range users { + if userDoc, err := s.store.GetDocumentByID(uid, docID); err == nil && userDoc != nil { + s.wsHub.BroadcastToUser(uid, event, userDoc) + } + } +} + func (s *Server) handleGetDocuments(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docs, err := s.store.GetDocuments(userID) @@ -803,7 +816,7 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - s.wsHub.BroadcastDocEvent(doc.ID, "DOC_CREATED", doc) + s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED") writeJSON(w, http.StatusCreated, doc) } @@ -822,16 +835,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") - // Check document lock - if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID { - w.WriteHeader(http.StatusLocked) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "Dokument wird aktuell bearbeitet von " + lock.UserName, - "currentLock": lock, - }) - return - } - var req struct { Title *string `json:"title"` Icon *string `json:"icon"` @@ -851,11 +854,44 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { return } if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil { - s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc) + s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") } writeJSON(w, http.StatusOK, doc) } +func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + docID := chi.URLParam(r, "id") + + var req domain.DocBlocksPatchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiges JSON") + return + } + + doc, mergedOrder, err := s.store.PatchDocumentBlocks(userID, docID, req) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + patchEvent := domain.DocBlocksPatchEvent{ + DocID: docID, + UpdatedBy: userID, + UpdatedAt: doc.UpdatedAt, + Upserted: req.Upserted, + DeletedIDs: req.DeletedIDs, + BlockOrder: mergedOrder, + } + if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { + for _, uid := range users { + s.wsHub.BroadcastToUser(uid, "DOC_BLOCKS_PATCHED", patchEvent) + } + } + + writeJSON(w, http.StatusOK, doc) +} + func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") @@ -990,7 +1026,7 @@ func (s *Server) handleUpdateFolderMount(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusBadRequest, err.Error()) return } - s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "mountPath": req.MountPath}) + s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "mountPath": req.MountPath}) writeJSON(w, http.StatusOK, map[string]bool{"updated": true}) } @@ -1071,7 +1107,7 @@ func (s *Server) handleMoveDocument(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc) + s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", nil) writeJSON(w, http.StatusOK, doc) } @@ -1153,11 +1189,8 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - members, _ := s.store.GetTeamMembers(req.TeamID) - for _, tm := range members { - s.wsHub.BroadcastToUser(tm.UserID, "DOC_SHARED", share) - } - s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share) + s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED") + s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") writeJSON(w, http.StatusCreated, share) return } @@ -1176,8 +1209,8 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - s.wsHub.BroadcastToUser(share.UserID, "DOC_SHARED", share) - s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share) + s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED") + s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") writeJSON(w, http.StatusCreated, share) } @@ -1189,8 +1222,8 @@ func (s *Server) handleDeleteDocShare(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - s.wsHub.BroadcastToUser(targetUserID, "DOC_UNSHARED", map[string]string{"docId": docID}) - s.wsHub.BroadcastDocEvent(docID, "DOC_UNSHARED", map[string]string{"docId": docID, "userId": targetUserID}) + s.wsHub.BroadcastToUser(targetUserID, "DOC_DELETED", map[string]string{"id": docID}) + s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } diff --git a/internal/domain/models.go b/internal/domain/models.go index 8616ed5..0f6d863 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -269,6 +269,25 @@ type DocumentBlock struct { Collapsed bool `json:"collapsed,omitempty"` } +type DocBlocksPatchRequest struct { + Upserted []DocumentBlock `json:"upserted,omitempty"` + DeletedIDs []string `json:"deletedIds,omitempty"` + BlockOrder []string `json:"blockOrder,omitempty"` + Title *string `json:"title,omitempty"` + Icon *string `json:"icon,omitempty"` +} + +type DocBlocksPatchEvent struct { + DocID string `json:"docId"` + Upserted []DocumentBlock `json:"upserted,omitempty"` + DeletedIDs []string `json:"deletedIds,omitempty"` + BlockOrder []string `json:"blockOrder,omitempty"` + Title *string `json:"title,omitempty"` + Icon *string `json:"icon,omitempty"` + UpdatedBy string `json:"updatedBy"` + UpdatedAt string `json:"updatedAt"` +} + type DocShare struct { ID string `json:"id"` DocID string `json:"docId"` diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 399d85a..60b95e9 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -366,6 +366,9 @@ func (s *Store) migrate() error { "CREATE TABLE IF NOT EXISTS workspace_folders (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, shared_folder_id TEXT NOT NULL DEFAULT '', path TEXT NOT NULL, sub_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL);", "CREATE INDEX IF NOT EXISTS idx_workspace_folders_user ON workspace_folders(user_id);", "CREATE INDEX IF NOT EXISTS idx_workspace_folders_sf ON workspace_folders(shared_folder_id);", + + "CREATE TABLE IF NOT EXISTS document_blocks (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, sort_order REAL NOT NULL DEFAULT 0, type TEXT NOT NULL DEFAULT 'bullet', content TEXT NOT NULL DEFAULT '', level INTEGER NOT NULL DEFAULT 0, checked INTEGER NOT NULL DEFAULT 0, collapsed INTEGER NOT NULL DEFAULT 0, lang TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL, updated_by TEXT NOT NULL DEFAULT '', FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE);", + "CREATE INDEX IF NOT EXISTS idx_document_blocks_doc_order ON document_blocks(doc_id, sort_order);", } for _, q := range columnMigrations { _, _ = s.db.Exec(q) @@ -422,6 +425,9 @@ func (s *Store) migrate() error { _, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`) _, _ = s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`) + // Migrate workspace documents, blocks, folders, and strip legacy folder:* tags into pure DB tables + s.migrateWorkspaceToPureDB() + // Reindex block tags if table is newly added or empty var tagCount int _ = s.db.QueryRow(`SELECT COUNT(*) FROM doc_block_tags`).Scan(&tagCount) @@ -1210,74 +1216,458 @@ func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) { return res.RowsAffected() } -// --- DOCUMENTS (WORKSPACE) --- +// --- DOCUMENTS (WORKSPACE - PURE DATABASE ARCHITECTURE) --- -func (s *Store) saveDocumentToDisk(doc *domain.Document) { - if s.docDir == "" || doc == nil { - return +func stripFolderTags(tags []string) []string { + if len(tags) == 0 { + return []string{} } - userDir := filepath.Join(s.docDir, doc.UserID) - if doc.FolderPath != "" { - userDir = filepath.Join(userDir, filepath.Clean(doc.FolderPath)) - } - _ = os.MkdirAll(userDir, 0755) - - var md strings.Builder - md.WriteString(fmt.Sprintf("# %s %s\n\n", doc.Icon, doc.Title)) - if len(doc.Blocks) > 0 { - for _, b := range doc.Blocks { - indent := strings.Repeat(" ", b.Level) - switch b.Type { - case "h1": - md.WriteString(fmt.Sprintf("%s- # %s\n", indent, b.Content)) - case "h2": - md.WriteString(fmt.Sprintf("%s- ## %s\n", indent, b.Content)) - case "h3": - md.WriteString(fmt.Sprintf("%s- ### %s\n", indent, b.Content)) - case "todo": - chk := " " - if b.Checked { - chk = "x" - } - formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" ") - md.WriteString(fmt.Sprintf("%s- [%s] %s\n", indent, chk, formatted)) - case "bullet", "paragraph": - formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" ") - md.WriteString(fmt.Sprintf("%s- %s\n", indent, formatted)) - case "code": - md.WriteString(fmt.Sprintf("%s- ```%s\n%s %s\n%s ```\n", indent, b.Lang, indent, b.Content, indent)) - case "callout": - formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" > ") - md.WriteString(fmt.Sprintf("%s- > %s\n", indent, formatted)) - case "divider": - md.WriteString(fmt.Sprintf("%s- ---\n", indent)) - case "table": - md.WriteString(fmt.Sprintf("%s- %s\n", indent, b.Content)) - case "progress": - md.WriteString(fmt.Sprintf("%s- \n", indent, b.Content)) - default: - formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" ") - md.WriteString(fmt.Sprintf("%s- %s\n", indent, formatted)) - } + cleaned := make([]string, 0, len(tags)) + for _, t := range tags { + if !strings.HasPrefix(strings.TrimSpace(t), "folder:") { + cleaned = append(cleaned, t) } - } else if doc.Content != "" { - md.WriteString(doc.Content + "\n\n") } - - targetPath := filepath.Join(userDir, doc.ID+".md") - _ = os.WriteFile(targetPath, []byte(md.String()), 0644) + return cleaned } -func (s *Store) removeDocumentFromDisk(doc *domain.Document) { - if s.docDir == "" || doc == nil { +func (s *Store) migrateWorkspaceToPureDB() { + now := domain.NowISO() + + // 1. Migrate documents.blocks_json into document_blocks for any doc without rows in document_blocks + rows, err := s.db.Query(` + SELECT d.id, d.user_id, d.blocks_json, d.tags, d.folder_path, d.updated_at + FROM documents d + `) + if err == nil { + type migDoc struct { + id, userID, blocksJSON, tagsJSON, folderPath, updatedAt string + } + var docsToMigrate []migDoc + for rows.Next() { + var md migDoc + if err := rows.Scan(&md.id, &md.userID, &md.blocksJSON, &md.tagsJSON, &md.folderPath, &md.updatedAt); err == nil { + docsToMigrate = append(docsToMigrate, md) + } + } + _ = rows.Close() + + for _, md := range docsToMigrate { + var count int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM document_blocks WHERE doc_id = ?`, md.id).Scan(&count) + if count == 0 && md.blocksJSON != "" && md.blocksJSON != "[]" { + var blocks []domain.DocumentBlock + if err := json.Unmarshal([]byte(md.blocksJSON), &blocks); err == nil && len(blocks) > 0 { + ts := md.updatedAt + if ts == "" { + ts = now + } + s.replaceDocBlocksInDB(md.id, md.userID, blocks, ts) + } + } + + // Strip legacy folder:* tags from documents.tags + if strings.Contains(md.tagsJSON, "folder:") { + var tags []string + if err := json.Unmarshal([]byte(md.tagsJSON), &tags); err == nil { + cleaned := stripFolderTags(tags) + cleanedBytes, _ := json.Marshal(cleaned) + _, _ = s.db.Exec(`UPDATE documents SET tags = ? WHERE id = ?`, string(cleanedBytes), md.id) + } + } + + // Ensure personal folder exists in workspace_folders + if md.folderPath != "" { + var existsID string + err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ?`, md.userID, md.folderPath).Scan(&existsID) + if err == sql.ErrNoRows { + _, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`, + domain.NewID("wfld"), md.userID, md.folderPath, now) + } + } + } + } + + // 2. Sync all existing shared folders so all docs/subfolders in their owner's mount path are linked + sfRows, err := s.db.Query(` + SELECT f.id, f.owner_id, COALESCE(m.mount_path, f.name) + FROM workspace_shared_folders f + LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = f.owner_id + `) + if err == nil { + type sfInfo struct{ id, ownerID, mountPath string } + var sfs []sfInfo + for sfRows.Next() { + var info sfInfo + if err := sfRows.Scan(&info.id, &info.ownerID, &info.mountPath); err == nil { + sfs = append(sfs, info) + } + } + _ = sfRows.Close() + for _, info := range sfs { + s.syncSharedFolderContents(info.ownerID, info.id, info.mountPath) + } + } +} + +func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock { + rows, err := s.db.Query(` + SELECT id, type, content, level, checked, collapsed, lang + FROM document_blocks + WHERE doc_id = ? + ORDER BY sort_order ASC, rowid ASC + `, docID) + if err == nil { + blocks := make([]domain.DocumentBlock, 0) + for rows.Next() { + var b domain.DocumentBlock + var chk, col int + if scanErr := rows.Scan(&b.ID, &b.Type, &b.Content, &b.Level, &chk, &col, &b.Lang); scanErr == nil { + b.Checked = chk != 0 + b.Collapsed = col != 0 + blocks = append(blocks, b) + } + } + _ = rows.Close() + if len(blocks) > 0 { + return blocks + } + } + + blocks := make([]domain.DocumentBlock, 0) + if fallbackJSON != "" && fallbackJSON != "[]" { + _ = json.Unmarshal([]byte(fallbackJSON), &blocks) + if len(blocks) > 0 { + s.replaceDocBlocksInDB(docID, "", blocks, domain.NowISO()) + } + } + return blocks +} + +func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.DocumentBlock, now string) { + _, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, docID) + if len(blocks) == 0 { return } - userDir := filepath.Join(s.docDir, doc.UserID) - if doc.FolderPath != "" { - userDir = filepath.Join(userDir, filepath.Clean(doc.FolderPath)) + stmt, err := s.db.Prepare(` + INSERT INTO document_blocks (id, doc_id, sort_order, type, content, level, checked, collapsed, lang, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + doc_id = excluded.doc_id, + sort_order = excluded.sort_order, + type = excluded.type, + content = excluded.content, + level = excluded.level, + checked = excluded.checked, + collapsed = excluded.collapsed, + lang = excluded.lang, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by + `) + if err != nil { + return } - targetPath := filepath.Join(userDir, doc.ID+".md") - _ = os.Remove(targetPath) + defer stmt.Close() + + for i, b := range blocks { + bID := strings.TrimSpace(b.ID) + if bID == "" { + bID = domain.NewID("blk") + } + bType := b.Type + if bType == "" { + bType = "bullet" + } + chk := 0 + if b.Checked { + chk = 1 + } + col := 0 + if b.Collapsed { + col = 1 + } + _, _ = stmt.Exec(bID, docID, float64((i+1)*1000), bType, b.Content, b.Level, chk, col, b.Lang, now, userID) + } +} + +// PatchDocumentBlocks applies atomic block-level upserts, deletions, and order reconciliation without overwriting concurrent edits from other sessions. +func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPatchRequest) (*domain.Document, []string, error) { + doc, err := s.GetDocumentByID(userID, docID) + if err != nil { + return nil, nil, err + } + if !doc.IsOwner && doc.Permission != "write" { + return nil, nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") + } + + now := domain.NowISO() + + // Ensure document_blocks is seeded if previously only in blocks_json + var existingCount int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM document_blocks WHERE doc_id = ?`, docID).Scan(&existingCount) + if existingCount == 0 && len(doc.Blocks) > 0 { + s.replaceDocBlocksInDB(docID, doc.UserID, doc.Blocks, doc.UpdatedAt) + } + + // Read current ordered block IDs from DB before patch + var existingOrder []string + ordRows, err := s.db.Query(`SELECT id FROM document_blocks WHERE doc_id = ? ORDER BY sort_order ASC, rowid ASC`, docID) + if err == nil { + for ordRows.Next() { + var bid string + if err := ordRows.Scan(&bid); err == nil { + existingOrder = append(existingOrder, bid) + } + } + _ = ordRows.Close() + } + + // 1. Delete explicitly deleted block IDs + deletedSet := make(map[string]bool, len(req.DeletedIDs)) + for _, delID := range req.DeletedIDs { + delID = strings.TrimSpace(delID) + if delID == "" { + continue + } + deletedSet[delID] = true + _, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ? AND id = ?`, docID, delID) + } + + // Filter existingOrder to exclude deleted blocks + survivingExisting := make([]string, 0, len(existingOrder)) + survivingSet := make(map[string]bool, len(existingOrder)) + for _, bid := range existingOrder { + if !deletedSet[bid] { + survivingExisting = append(survivingExisting, bid) + survivingSet[bid] = true + } + } + + // 2. Upsert modified or newly created blocks + upsertedIDs := make([]string, 0, len(req.Upserted)) + for idx, b := range req.Upserted { + bID := strings.TrimSpace(b.ID) + if bID == "" || deletedSet[bID] { + continue + } + bType := b.Type + if bType == "" { + bType = "bullet" + } + chk := 0 + if b.Checked { + chk = 1 + } + col := 0 + if b.Collapsed { + col = 1 + } + defaultSort := float64((len(survivingExisting) + idx + 1) * 1000) + _, _ = s.db.Exec(` + INSERT INTO document_blocks (id, doc_id, sort_order, type, content, level, checked, collapsed, lang, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + type = excluded.type, + content = excluded.content, + level = excluded.level, + checked = excluded.checked, + collapsed = excluded.collapsed, + lang = excluded.lang, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by + `, bID, docID, defaultSort, bType, b.Content, b.Level, chk, col, b.Lang, now, userID) + upsertedIDs = append(upsertedIDs, bID) + survivingSet[bID] = true + } + + // 3. Reconcile block ordering without dropping blocks concurrently created by other users + var mergedOrder []string + inMerged := make(map[string]bool) + + if len(req.BlockOrder) > 0 { + for _, bid := range req.BlockOrder { + if !deletedSet[bid] && survivingSet[bid] && !inMerged[bid] { + mergedOrder = append(mergedOrder, bid) + inMerged[bid] = true + } + } + // Any surviving block in DB that was not in req.BlockOrder (e.g. concurrently added by another user) + // is inserted right after its predecessor from survivingExisting! + for i, bid := range survivingExisting { + if inMerged[bid] { + continue + } + insertPos := 0 + for j := i - 1; j >= 0; j-- { + predID := survivingExisting[j] + if inMerged[predID] { + for k, mID := range mergedOrder { + if mID == predID { + insertPos = k + 1 + break + } + } + break + } + } + mergedOrder = append(mergedOrder[:insertPos], append([]string{bid}, mergedOrder[insertPos:]...)...) + inMerged[bid] = true + } + // Ensure any upserted block not in BlockOrder is also appended + for _, bid := range upsertedIDs { + if !inMerged[bid] { + mergedOrder = append(mergedOrder, bid) + inMerged[bid] = true + } + } + } else { + for _, bid := range survivingExisting { + if !inMerged[bid] { + mergedOrder = append(mergedOrder, bid) + inMerged[bid] = true + } + } + for _, bid := range upsertedIDs { + if !inMerged[bid] { + mergedOrder = append(mergedOrder, bid) + inMerged[bid] = true + } + } + } + + // Update sort_order in document_blocks + for idx, bid := range mergedOrder { + _, _ = s.db.Exec(`UPDATE document_blocks SET sort_order = ? WHERE doc_id = ? AND id = ?`, float64((idx+1)*1000), docID, bid) + } + + // Reload authoritative block list and update document snapshot + finalBlocks := s.loadDocBlocks(docID, "") + var plainBuilder strings.Builder + for _, b := range finalBlocks { + if b.Content != "" { + plainBuilder.WriteString(b.Content) + plainBuilder.WriteString("\n") + } + } + plainContent := plainBuilder.String() + blocksJSON, _ := json.Marshal(finalBlocks) + + _, _ = s.db.Exec(`UPDATE documents SET content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`, + plainContent, string(blocksJSON), now, docID) + + doc.Blocks = finalBlocks + doc.Content = plainContent + doc.UpdatedAt = now + _ = s.indexDocBlockTags(doc.ID, doc.UserID, finalBlocks) + + return doc, mergedOrder, nil +} + +// GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID. +func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) { + userSet := make(map[string]bool) + + var ownerID, sharedFolderID string + err := s.db.QueryRow(`SELECT user_id, shared_folder_id FROM documents WHERE id = ?`, docID).Scan(&ownerID, &sharedFolderID) + if err != nil { + return nil, err + } + if ownerID != "" { + userSet[ownerID] = true + } + + // Direct user shares + uRows, err := s.db.Query(`SELECT user_id FROM doc_shares WHERE doc_id = ?`, docID) + if err == nil { + for uRows.Next() { + var uid string + if err := uRows.Scan(&uid); err == nil && uid != "" { + userSet[uid] = true + } + } + _ = uRows.Close() + } + + // Team shares + tRows, err := s.db.Query(` + SELECT tm.user_id + FROM doc_team_shares dts + JOIN team_members tm ON tm.team_id = dts.team_id + WHERE dts.doc_id = ? + `, docID) + if err == nil { + for tRows.Next() { + var uid string + if err := tRows.Scan(&uid); err == nil && uid != "" { + userSet[uid] = true + } + } + _ = tRows.Close() + } + + // Shared folder access + if sharedFolderID != "" { + sfUsers, err := s.GetAuthorizedSharedFolderUsers(sharedFolderID) + if err == nil { + for _, uid := range sfUsers { + userSet[uid] = true + } + } + } + + users := make([]string, 0, len(userSet)) + for uid := range userSet { + users = append(users, uid) + } + return users, nil +} + +// GetAuthorizedSharedFolderUsers returns all user IDs who have access to a shared folder. +func (s *Store) GetAuthorizedSharedFolderUsers(folderID string) ([]string, error) { + userSet := make(map[string]bool) + + var ownerID string + if err := s.db.QueryRow(`SELECT owner_id FROM workspace_shared_folders WHERE id = ?`, folderID).Scan(&ownerID); err == nil && ownerID != "" { + userSet[ownerID] = true + } + + rows, err := s.db.Query(` + SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? + `, folderID) + if err == nil { + type shareTarget struct{ tType, tID string } + var targets []shareTarget + for rows.Next() { + var st shareTarget + if err := rows.Scan(&st.tType, &st.tID); err == nil { + targets = append(targets, st) + } + } + _ = rows.Close() + + for _, st := range targets { + if st.tType == "user" && st.tID != "" { + userSet[st.tID] = true + } else if st.tType == "team" && st.tID != "" { + mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, st.tID) + if err == nil { + for mRows.Next() { + var uid string + if err := mRows.Scan(&uid); err == nil && uid != "" { + userSet[uid] = true + } + } + _ = mRows.Close() + } + } + } + } + + users := make([]string, 0, len(userSet)) + for uid := range userSet { + users = append(users, uid) + } + return users, nil } func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string, blocks []domain.DocumentBlock, tags []string, isPinned bool) (*domain.Document, error) { @@ -1292,9 +1682,7 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string, {ID: domain.NewID("blk"), Type: "paragraph", Content: ""}, } } - if tags == nil { - tags = []string{} - } + tags = stripFolderTags(tags) var plainBuilder strings.Builder for _, b := range blocks { @@ -1312,26 +1700,34 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string, pinnedInt = 1 } + cleanFolder := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath))) + if cleanFolder == "." { + cleanFolder = "" + } + mounts, _, _ := s.getUserFolderMounts(userID) var sharedFolderID, subFolderPath string - if fID, sub, ok := matchMountPath(folderPath, mounts); ok { + if fID, sub, ok := matchMountPath(cleanFolder, mounts); ok { sharedFolderID = fID subFolderPath = sub } doc := &domain.Document{ - ID: domain.NewID("doc"), - UserID: userID, - SpaceID: spaceID, - Title: strings.TrimSpace(title), - Icon: icon, - Content: plainBuilder.String(), - Blocks: blocks, - Tags: tags, - FolderPath: folderPath, + ID: domain.NewID("doc"), + UserID: userID, + SpaceID: spaceID, + Title: strings.TrimSpace(title), + Icon: icon, + Content: plainBuilder.String(), + Blocks: blocks, + Tags: tags, + FolderPath: cleanFolder, IsPinned: isPinned, SharedFolderID: sharedFolderID, SubFolderPath: subFolderPath, + IsSharedFolder: sharedFolderID != "", + Permission: "owner", + IsOwner: true, CreatedAt: now, UpdatedAt: now, } @@ -1347,7 +1743,7 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string, if isPinned { _, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, doc.ID, doc.CreatedAt) } - s.saveDocumentToDisk(doc) + s.replaceDocBlocksInDB(doc.ID, userID, doc.Blocks, now) _ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks) return doc, nil } @@ -1476,6 +1872,30 @@ func scanDocument(rows interface { d.Tags = []string{} _ = json.Unmarshal([]byte(blocksStr), &d.Blocks) _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) + return d, nil +} + +func (s *Store) scanDocumentWithBlocks(rows interface { + Scan(dest ...any) error +}) (*domain.Document, error) { + d := &domain.Document{} + var blocksStr, tagsStr string + var isPinned, isArchived int + err := rows.Scan( + &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, + &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, + &d.SharedFolderID, &d.SubFolderPath, + ) + if err != nil { + return nil, err + } + d.IsPinned = isPinned != 0 + d.IsArchived = isArchived != 0 + d.Tags = []string{} + _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) + d.Blocks = s.loadDocBlocks(d.ID, blocksStr) return d, nil } @@ -1510,18 +1930,39 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) { return nil, err } - docs := make([]domain.Document, 0) + type rawDocRow struct { + doc domain.Document + blocksStr string + } + var ownRaw []rawDocRow for rows.Next() { - d, err := scanDocument(rows) - if err == nil { + d := domain.Document{} + var blocksStr, tagsStr string + var isPinned, isArchived int + if err := rows.Scan( + &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, + &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, + &d.SharedFolderID, &d.SubFolderPath, + ); err == nil { + d.IsPinned = isPinned != 0 + d.IsArchived = isArchived != 0 + d.Tags = []string{} + _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) d.Permission = "owner" d.IsOwner = true - resolveDocumentPath(userID, d, mounts, names, userFolders) - docs = append(docs, *d) + resolveDocumentPath(userID, &d, mounts, names, userFolders) + ownRaw = append(ownRaw, rawDocRow{doc: d, blocksStr: blocksStr}) } } _ = rows.Close() + docs := make([]domain.Document, 0, len(ownRaw)) + for _, r := range ownRaw { + r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr) + docs = append(docs, r.doc) + } + // 2. Fetch documents shared with the user directly, via teams, or via shared folders sharedRows, err := s.db.Query( `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, @@ -1559,8 +2000,9 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) { userID, userID, userID, userID, userID, userID, userID, userID, ) if err == nil { + var sharedRaw []rawDocRow for sharedRows.Next() { - d := &domain.Document{} + d := domain.Document{} var blocksStr, tagsStr string var isPinned, isArchived int var perm, ownerName string @@ -1572,18 +2014,22 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) { ); scanErr == nil { d.IsPinned = isPinned != 0 d.IsArchived = isArchived != 0 - d.Blocks = []domain.DocumentBlock{} d.Tags = []string{} - _ = json.Unmarshal([]byte(blocksStr), &d.Blocks) _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) d.Permission = perm d.IsOwner = (perm == "owner") d.OwnerName = ownerName - resolveDocumentPath(userID, d, mounts, names, userFolders) - docs = append(docs, *d) + resolveDocumentPath(userID, &d, mounts, names, userFolders) + sharedRaw = append(sharedRaw, rawDocRow{doc: d, blocksStr: blocksStr}) } } _ = sharedRows.Close() + + for _, r := range sharedRaw { + r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr) + docs = append(docs, r.doc) + } } // 3. Attach shares to owned documents @@ -1617,7 +2063,7 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) WHERE d.id = ? AND d.user_id = ?`, userID, docID, userID, ) - doc, err := scanDocument(row) + doc, err := s.scanDocumentWithBlocks(row) if err == nil { doc.Permission = "owner" doc.IsOwner = true @@ -1676,10 +2122,10 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) } d.IsPinned = isPinned != 0 d.IsArchived = isArchived != 0 - d.Blocks = []domain.DocumentBlock{} d.Tags = []string{} - _ = json.Unmarshal([]byte(blocksStr), &d.Blocks) _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) + d.Blocks = s.loadDocBlocks(d.ID, blocksStr) d.Permission = perm d.IsOwner = (perm == "owner") d.OwnerName = ownerName @@ -1743,11 +2189,14 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st if icon != nil && *icon != "" { doc.Icon = *icon } - if folderPath != nil { - doc.FolderPath = *folderPath + if tags != nil { + doc.Tags = stripFolderTags(*tags) } + + doc.UpdatedAt = domain.NowISO() if blocks != nil { doc.Blocks = *blocks + s.replaceDocBlocksInDB(docID, userID, doc.Blocks, doc.UpdatedAt) var plainBuilder strings.Builder for _, b := range doc.Blocks { if b.Content != "" { @@ -1757,23 +2206,19 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st } doc.Content = plainBuilder.String() } - if tags != nil { - doc.Tags = *tags - } - doc.UpdatedAt = domain.NowISO() blocksJSON, _ := json.Marshal(doc.Blocks) tagsJSON, _ := json.Marshal(doc.Tags) + // IMPORTANT: Never overwrite documents.folder_path here! Folder placement is managed exclusively by MoveDocument. _, err = s.db.Exec( - `UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, folder_path = ?, updated_at = ? + `UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, updated_at = ? WHERE id = ?`, - doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, doc.UpdatedAt, docID, + doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.UpdatedAt, docID, ) if err != nil { return nil, err } - s.saveDocumentToDisk(doc) if blocks != nil { _ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks) } @@ -1787,13 +2232,13 @@ func (s *Store) DeleteDocument(userID, docID string) error { } if !doc.IsOwner { // Collaborator removing their shared access + _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID) return s.DeleteDocShare(userID, docID, userID) } - if doc != nil { - s.removeDocumentFromDisk(doc) - } + _, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, docID) _, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID) - _, err = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, docID, userID) + _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, docID) + _, err = s.db.Exec(`DELETE FROM documents WHERE id = ?`, docID) return err } @@ -1817,8 +2262,8 @@ func (s *Store) reindexAllDocBlockTags() error { _ = rows.Close() for _, d := range docs { - var blocks []domain.DocumentBlock - if err := json.Unmarshal([]byte(d.blocksJSON), &blocks); err == nil && len(blocks) > 0 { + blocks := s.loadDocBlocks(d.id, d.blocksJSON) + if len(blocks) > 0 { _ = s.indexDocBlockTags(d.id, d.userID, blocks) } } @@ -1851,22 +2296,6 @@ func (s *Store) indexDocBlockTags(docID, userID string, blocks []domain.Document return nil } -func (s *Store) saveTagPageToDisk(userID, tag, content string) { - if s.docDir == "" || tag == "" { - return - } - tagDir := filepath.Join(filepath.Dir(s.docDir), "tag_pages", userID) - _ = os.MkdirAll(tagDir, 0755) - safeTag := regexp.MustCompile(`[^a-zA-Z0-9äöüÄÖÜß_-]+`).ReplaceAllString(tag, "_") - targetPath := filepath.Join(tagDir, safeTag+".md") - if strings.TrimSpace(content) == "" { - _ = os.Remove(targetPath) - return - } - md := fmt.Sprintf("# #%s\n\n%s\n", tag, content) - _ = os.WriteFile(targetPath, []byte(md), 0644) -} - func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) { cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#")) if cleanTag == "" { @@ -1882,7 +2311,6 @@ func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) { if err != nil { return "", err } - s.saveTagPageToDisk(userID, cleanTag, content) return now, nil } @@ -2015,8 +2443,7 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) { matches := docMap[docID] first := matches[0] - var blocks []domain.DocumentBlock - _ = json.Unmarshal([]byte(first.blocksJSON), &blocks) + blocks := s.loadDocBlocks(first.docID, first.blocksJSON) blockIdxMap := make(map[string]int, len(blocks)) for i, b := range blocks { @@ -2137,23 +2564,6 @@ func (s *Store) GetFolders(userID string) ([]string, error) { _ = pRows.Close() } - // 4. Scan physical directories on disk in docDir/userID - if s.docDir != "" { - userDir := filepath.Join(s.docDir, userID) - if fi, err := os.Stat(userDir); err == nil && fi.IsDir() { - _ = filepath.Walk(userDir, func(path string, info os.FileInfo, err error) error { - if err != nil || !info.IsDir() || path == userDir { - return nil - } - rel, err := filepath.Rel(userDir, path) - if err == nil && rel != "" && rel != "." { - addFolderSegments(rel, folderSet) - } - return nil - }) - } - } - result := make([]string, 0, len(folderSet)) for f := range folderSet { result = append(result, f) @@ -2212,12 +2622,6 @@ func (s *Store) CreateFolder(userID, folderPath string) (string, error) { } } - if s.docDir != "" { - targetDir := filepath.Join(s.docDir, userID, clean) - if err := os.MkdirAll(targetDir, 0755); err != nil { - return "", err - } - } return clean, nil } @@ -2227,54 +2631,87 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) { return nil, fmt.Errorf("ungültiger Ordnerpfad") } - // Find all documents in this folder and subfolders - rows, err := s.db.Query( - `SELECT id FROM documents - WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`, - userID, clean, clean+"/%", - ) - if err != nil { - return nil, err - } - defer rows.Close() - var deletedDocIDs []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err == nil { - deletedDocIDs = append(deletedDocIDs, id) - } - } - _ = rows.Close() + seenDocID := make(map[string]bool) - // Delete from SQLite, pins, shares, tags - for _, id := range deletedDocIDs { - _, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, id) - _, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, id) - _, _ = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ?`, id) - _, _ = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, id, userID) - } - - // Clean up from workspace_folders mounts, _, err := s.getUserFolderMounts(userID) if err == nil { sfID, subPath, isMatch := matchMountPath(clean, mounts) if isMatch && sfID != "" { if subPath == "" { + // Root of a shared folder + shFolder, sfErr := s.GetSharedFolderByID(userID, sfID) + if sfErr == nil && !shFolder.IsOwner { + // Collaborator removing mount/share + _ = s.DeleteSharedFolder(userID, sfID) + return []string{}, nil + } + rows, qErr := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ?`, sfID) + if qErr == nil { + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil && !seenDocID[id] { + seenDocID[id] = true + deletedDocIDs = append(deletedDocIDs, id) + } + } + _ = rows.Close() + } _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, sfID) } else { + // Subfolder inside a shared folder + rows, qErr := s.db.Query( + `SELECT id FROM documents WHERE shared_folder_id = ? AND (sub_folder_path = ? OR sub_folder_path LIKE ?)`, + sfID, subPath, subPath+"/%", + ) + if qErr == nil { + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil && !seenDocID[id] { + seenDocID[id] = true + deletedDocIDs = append(deletedDocIDs, id) + } + } + _ = rows.Close() + } _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ? AND (sub_path = ? OR sub_path LIKE ?)`, sfID, subPath, subPath+"/%") } } } - _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%") - // Remove physical folder and subdirectories from disk - if s.docDir != "" { - targetDir := filepath.Join(s.docDir, userID, clean) - _ = os.RemoveAll(targetDir) + // Find all personal documents owned by this user in this folder and subfolders + rows, err := s.db.Query( + `SELECT id FROM documents + WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL) AND (folder_path = ? OR folder_path LIKE ?)`, + userID, clean, clean+"/%", + ) + if err != nil { + return nil, err } + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil && !seenDocID[id] { + seenDocID[id] = true + deletedDocIDs = append(deletedDocIDs, id) + } + } + _ = rows.Close() + + // Delete documents from SQLite, blocks, pins, shares, tags, user_folders + for _, id := range deletedDocIDs { + _, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, id) + _, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, id) + _, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, id) + _, _ = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ?`, id) + _, _ = s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ?`, id) + _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, id) + _, _ = s.db.Exec(`DELETE FROM documents WHERE id = ?`, id) + } + + // Also clear any personal folder placements for shared notes in this folder + _, _ = s.db.Exec(`UPDATE doc_user_folders SET folder_path = '' WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`, userID, clean, clean+"/%") + _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%") return deletedDocIDs, nil } @@ -2370,49 +2807,15 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do return doc, nil } -func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) (*domain.SharedFolder, error) { - cleanName := strings.TrimSpace(folderName) - if cleanName == "" { - return nil, fmt.Errorf("ordnername darf nicht leer sein") - } - cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(initialMountPath))) - if cleanMount == "." || cleanMount == "" { - cleanMount = cleanName +// syncSharedFolderContents ensures all documents and subfolders under cleanMount owned by ownerID are linked to folderID. +func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) { + if ownerID == "" || folderID == "" || cleanMount == "" { + return } - // Check if already shared by this user at this mount path - var existingID string - err := s.db.QueryRow(` - SELECT f.id FROM workspace_shared_folders f - JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ? - WHERE f.owner_id = ? AND (m.mount_path = ? OR f.name = ?) - `, userID, userID, cleanMount, cleanName).Scan(&existingID) - if err == nil && existingID != "" { - return s.GetSharedFolderByID(userID, existingID) - } - - now := domain.NowISO() - folderID := domain.NewID("fld") - - _, err = s.db.Exec(` - INSERT INTO workspace_shared_folders (id, owner_id, name, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - `, folderID, userID, cleanName, now, now) - if err != nil { - return nil, err - } - - _, err = s.db.Exec(` - INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - `, userID, folderID, cleanMount, now, now) - if err != nil { - return nil, err - } - - // Link all existing documents in cleanMount to this shared folder + // 1. Link all documents owned by ownerID in cleanMount to this shared folder rows, err := s.db.Query(`SELECT id, folder_path FROM documents WHERE user_id = ? AND is_archived = 0 AND (folder_path = ? OR folder_path LIKE ?)`, - userID, cleanMount, cleanMount+"/%") + ownerID, cleanMount, cleanMount+"/%") if err == nil { type docEntry struct{ id, fp string } var entries []docEntry @@ -2436,8 +2839,9 @@ func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) } } - // Migrate any existing workspace_folders created by this user in cleanMount - fRows, err := s.db.Query(`SELECT id, path FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, cleanMount, cleanMount+"/%") + // 2. Link any workspace_folders created by ownerID in cleanMount + fRows, err := s.db.Query(`SELECT id, path FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, + ownerID, cleanMount, cleanMount+"/%") if err == nil { type wfEntry struct{ id, path string } var wfEntries []wfEntry @@ -2454,9 +2858,54 @@ func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) if strings.HasPrefix(wfe.path, cleanMount+"/") { subP = strings.TrimPrefix(wfe.path, cleanMount+"/") } - _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, folderID, subP, wfe.id) + _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, + folderID, subP, wfe.id) } } +} + +func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) (*domain.SharedFolder, error) { + cleanName := strings.TrimSpace(folderName) + if cleanName == "" { + return nil, fmt.Errorf("ordnername darf nicht leer sein") + } + cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(initialMountPath))) + if cleanMount == "." || cleanMount == "" { + cleanMount = cleanName + } + + // Check if already shared by this user at this mount path + var existingID string + err := s.db.QueryRow(` + SELECT f.id FROM workspace_shared_folders f + JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ? + WHERE f.owner_id = ? AND (m.mount_path = ? OR f.name = ?) + `, userID, userID, cleanMount, cleanName).Scan(&existingID) + if err == nil && existingID != "" { + s.syncSharedFolderContents(userID, existingID, cleanMount) + return s.GetSharedFolderByID(userID, existingID) + } + + now := domain.NowISO() + folderID := domain.NewID("fld") + + _, err = s.db.Exec(` + INSERT INTO workspace_shared_folders (id, owner_id, name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, folderID, userID, cleanName, now, now) + if err != nil { + return nil, err + } + + _, err = s.db.Exec(` + INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, userID, folderID, cleanMount, now, now) + if err != nil { + return nil, err + } + + s.syncSharedFolderContents(userID, folderID, cleanMount) var ownerName string _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName) @@ -2676,11 +3125,36 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde return nil, err } + var ownerMount string + _ = s.db.QueryRow(`SELECT COALESCE(m.mount_path, f.name) FROM workspace_shared_folders f LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = f.owner_id WHERE f.id = ?`, folderID).Scan(&ownerMount) + if ownerMount == "" { + ownerMount = f.Name + } + s.syncSharedFolderContents(f.OwnerID, folderID, ownerMount) + if cleanType == "user" { _, _ = s.db.Exec(` INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?) `, targetID, folderID, f.Name, now, now) + } else if cleanType == "team" { + mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, targetID) + if err == nil { + var memberIDs []string + for mRows.Next() { + var uid string + if err := mRows.Scan(&uid); err == nil && uid != "" { + memberIDs = append(memberIDs, uid) + } + } + _ = mRows.Close() + for _, uid := range memberIDs { + _, _ = s.db.Exec(` + INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, uid, folderID, f.Name, now, now) + } + } } return &domain.FolderShare{ diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 2eb8bcc..e28b60e 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -512,3 +512,158 @@ func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) { t.Fatalf("expected vorrat and kühlregal, got %v", tagMapAfter) } } + +func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test_db_first.db") + + store, err := NewStore(dbPath) + if err != nil { + t.Fatalf("NewStore failed: %v", err) + } + defer store.Close() + + userA, err := store.EnsureDefaultUser() + if err != nil { + t.Fatalf("EnsureDefaultUser failed: %v", err) + } + userB, err := store.CreateUser("userb@example.com", "hash", "User B", "member") + if err != nil { + t.Fatalf("CreateUser userB failed: %v", err) + } + + // 1. Create a document in User A's folder "Privat" and share with User B (write) + initialBlocks := []domain.DocumentBlock{ + {ID: "b1", Type: "bullet", Content: "Zeile 1 Original", Level: 0}, + {ID: "b2", Type: "bullet", Content: "Zeile 2 Original", Level: 0}, + } + doc, err := store.CreateDocument(userA.ID, "", "Gemeinsame Notiz", "📝", "Privat", initialBlocks, []string{"folder:Privat", "notiz"}, false) + if err != nil { + t.Fatalf("CreateDocument failed: %v", err) + } + // Verify folder:* tag was stripped + for _, tg := range doc.Tags { + if tg == "folder:Privat" { + t.Fatalf("expected folder:* tag to be stripped, got tags: %v", doc.Tags) + } + } + + _, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "write") + if err != nil { + t.Fatalf("CreateDocShare failed: %v", err) + } + + // User B moves the individually shared note into "Arbeit/Eingang" + _, err = store.MoveDocument(userB.ID, doc.ID, "Arbeit/Eingang") + if err != nil { + t.Fatalf("MoveDocument by userB failed: %v", err) + } + + // User B edits the document via PatchDocumentBlocks (modifying b2 and adding b3) + // Simultaneously, User A edits b1 and adds b4 using a slightly stale order + _, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{ + Upserted: []domain.DocumentBlock{ + {ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0}, + {ID: "b3", Type: "bullet", Content: "Zeile 3 neu von B", Level: 0}, + }, + BlockOrder: []string{"b1", "b2", "b3"}, + }) + if err != nil { + t.Fatalf("PatchDocumentBlocks by userB failed: %v", err) + } + + // User A's client only knew about b1, b2 when sending its patch (adds b4) + patchedDocA, _, err := store.PatchDocumentBlocks(userA.ID, doc.ID, domain.DocBlocksPatchRequest{ + Upserted: []domain.DocumentBlock{ + {ID: "b1", Type: "bullet", Content: "Zeile 1 von A bearbeitet", Level: 0}, + {ID: "b4", Type: "bullet", Content: "Zeile 4 neu von A", Level: 0}, + }, + BlockOrder: []string{"b1", "b2", "b4"}, // stale order without b3! + }) + if err != nil { + t.Fatalf("PatchDocumentBlocks by userA failed: %v", err) + } + + // Verify NO blocks were lost! All 4 blocks (b1, b2, b3, b4) must exist with both users' edits preserved + if len(patchedDocA.Blocks) != 4 { + t.Fatalf("expected 4 blocks after concurrent patches, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks) + } + blockContent := make(map[string]string) + for _, b := range patchedDocA.Blocks { + blockContent[b.ID] = b.Content + } + if blockContent["b1"] != "Zeile 1 von A bearbeitet" { + t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"]) + } + if blockContent["b2"] != "Zeile 2 von B bearbeitet" { + t.Fatalf("expected b2 updated by B to be preserved, got %q", blockContent["b2"]) + } + if blockContent["b3"] != "Zeile 3 neu von B" { + t.Fatalf("expected b3 added by B to be preserved even with stale order from A, got %q", blockContent["b3"]) + } + if blockContent["b4"] != "Zeile 4 neu von A" { + t.Fatalf("expected b4 added by A, got %q", blockContent["b4"]) + } + + // Verify folder independence: User A still sees "Privat", User B still sees "Arbeit/Eingang" + docForA, err := store.GetDocumentByID(userA.ID, doc.ID) + if err != nil || docForA.FolderPath != "Privat" { + t.Fatalf("expected User A FolderPath 'Privat', got %q (err=%v)", docForA.FolderPath, err) + } + docForB, err := store.GetDocumentByID(userB.ID, doc.ID) + if err != nil || docForB.FolderPath != "Arbeit/Eingang" { + t.Fatalf("expected User B FolderPath 'Arbeit/Eingang', got %q (err=%v)", docForB.FolderPath, err) + } + + foldersA, err := store.GetFolders(userA.ID) + if err != nil { + t.Fatalf("GetFolders for userA failed: %v", err) + } + for _, f := range foldersA { + if f == "Arbeit" || f == "Arbeit/Eingang" { + t.Fatalf("User B's private folder leaked into User A's folder list: %v", foldersA) + } + } + + // 2. Shared Folder & Empty Subfolder Sync + _, err = store.CreateFolder(userA.ID, "TeamProjekt") + if err != nil { + t.Fatalf("CreateFolder TeamProjekt failed: %v", err) + } + sf, err := store.CreateSharedFolder(userA.ID, "TeamProjekt", "TeamProjekt") + if err != nil { + t.Fatalf("CreateSharedFolder failed: %v", err) + } + _, err = store.CreateFolderShare(userA.ID, sf.ID, "user", userB.ID, "write") + if err != nil { + t.Fatalf("CreateFolderShare failed: %v", err) + } + + // User B moves their mount root of the shared folder to "ExterneProjekte/TeamProjekt" + err = store.UpdateFolderMount(userB.ID, sf.ID, "ExterneProjekte/TeamProjekt") + if err != nil { + t.Fatalf("UpdateFolderMount failed: %v", err) + } + + // User A creates an EMPTY subfolder inside "TeamProjekt/Konzepte" + _, err = store.CreateFolder(userA.ID, "TeamProjekt/Konzepte") + if err != nil { + t.Fatalf("CreateFolder TeamProjekt/Konzepte failed: %v", err) + } + + // User B must immediately see "ExterneProjekte/TeamProjekt/Konzepte" in GetFolders without any document inside! + foldersB, err := store.GetFolders(userB.ID) + if err != nil { + t.Fatalf("GetFolders for userB failed: %v", err) + } + foundSubfolderForB := false + for _, f := range foldersB { + if f == "ExterneProjekte/TeamProjekt/Konzepte" { + foundSubfolderForB = true + } + } + if !foundSubfolderForB { + t.Fatalf("expected User B to see empty shared subfolder 'ExterneProjekte/TeamProjekt/Konzepte', got folders: %v", foldersB) + } +} + -- 2.54.0 From e088ef6974017a8d76d8bad58b7f17125e249c83 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 11:29:03 +0100 Subject: [PATCH 12/19] fix(workspace): dedicated reconnecting websocket, restore editing lock blocking & amber banner, broadcast live doc/folder moves --- .../src/features/workspace/WorkspaceView.tsx | 212 +++++++++--------- frontend/src/lib/api.ts | 7 + internal/api/server.go | 56 +++++ internal/storage/sqlite.go | 132 +++++++++++ internal/storage/sqlite_test.go | 24 ++ 5 files changed, 323 insertions(+), 108 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index e7f2524..0310ca9 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -50,7 +50,7 @@ import { Radio, } from 'lucide-react'; import { BlockType, Document, DocumentBlock, DocBlocksPatchEvent, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types'; -import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected } from '../../lib/api'; +import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected, buildWebSocketURL } from '../../lib/api'; import { CodeBlockItem } from './CodeBlockItem'; import { UserAutocomplete } from '../UserAutocomplete'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; @@ -129,20 +129,10 @@ export const WorkspaceView: React.FC = ({ const [showIconPicker, setShowIconPicker] = useState(false); const [showWikiLinkMenu, setShowWikiLinkMenu] = useState(false); - // WebSocket connection status for live indicator - const [wsConnected, setWsConnected] = useState(() => isWebSocketConnected()); - - useEffect(() => { - const unsub = onWebSocketStatus((connected) => { - setWsConnected(connected); - if (connected) { - // Fresh re-fetch when reconnecting - api.getFolders().then(setCustomFolders).catch(() => {}); - api.getSharedFolders().then(setSharedFolders).catch(() => {}); - } - }); - return unsub; - }, []); + // Dedicated WebSocket connection & status for live indicator + const wsRef = useRef(null); + const wsReconnectTimerRef = useRef(null); + const [wsConnected, setWsConnected] = useState(false); // Folder state (synchronized with server) const [customFolders, setCustomFolders] = useState([]); @@ -237,7 +227,7 @@ export const WorkspaceView: React.FC = ({ const blockInputRefs = useRef>({}); const isLockedByOther = !!(activeLock && activeLock.userId !== currentUser?.id); - const isReadOnly = selectedDoc?.permission === 'read'; + const isReadOnly = selectedDoc?.permission === 'read' || isLockedByOther; // Track server-synced blocks & metadata per document, plus local unsaved block deltas const syncedDocMetaRef = useRef>(new Map()); @@ -820,10 +810,11 @@ export const WorkspaceView: React.FC = ({ }; }, []); - // Real-time WebSocket listener for Document, Block Delta & Folder events + // Dedicated Real-time WebSocket connection for Document, Block Delta, Lock & Folder events useEffect(() => { - const handleWs = (e: any) => { - const msg = e.detail; + let isMounted = true; + + const handleWsMsg = (msg: any) => { if (!msg || !msg.event) return; if (msg.event === 'DOC_LOCKED') { @@ -957,8 +948,80 @@ export const WorkspaceView: React.FC = ({ } }; - window.addEventListener('mindsift:ws', handleWs); - return () => window.removeEventListener('mindsift:ws', handleWs); + const connect = () => { + if (!isMounted) return; + const token = getToken(); + if (!token) { + setWsConnected(false); + return; + } + + if (wsRef.current) { + try { + wsRef.current.close(); + } catch {} + wsRef.current = null; + } + + try { + const url = buildWebSocketURL(); + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + if (!isMounted) return; + setWsConnected(true); + api.getFolders().then(setCustomFolders).catch(() => {}); + api.getSharedFolders().then(setSharedFolders).catch(() => {}); + }; + + ws.onclose = () => { + if (!isMounted) return; + setWsConnected(false); + clearTimeout(wsReconnectTimerRef.current); + wsReconnectTimerRef.current = setTimeout(connect, 2000); + }; + + ws.onerror = () => { + if (!isMounted) return; + setWsConnected(false); + }; + + ws.onmessage = (evt) => { + try { + const msg = JSON.parse(evt.data); + handleWsMsg(msg); + } catch {} + }; + } catch (err) { + if (!isMounted) return; + setWsConnected(false); + clearTimeout(wsReconnectTimerRef.current); + wsReconnectTimerRef.current = setTimeout(connect, 3000); + } + }; + + connect(); + + // Fallback: also listen for window custom event 'mindsift:ws' + const handleGlobalCustomEvent = (e: any) => { + if (e.detail) { + handleWsMsg(e.detail); + } + }; + window.addEventListener('mindsift:ws', handleGlobalCustomEvent); + + return () => { + isMounted = false; + clearTimeout(wsReconnectTimerRef.current); + if (wsRef.current) { + try { + wsRef.current.close(); + } catch {} + wsRef.current = null; + } + window.removeEventListener('mindsift:ws', handleGlobalCustomEvent); + }; }, [onStatsChange]); const openShareModal = async () => { @@ -1278,91 +1341,24 @@ export const WorkspaceView: React.FC = ({ return; } - // Name of the folder being moved - const folderName = cleanSource.split('/').pop() || cleanSource; - const newFolderPath = cleanTarget ? `${cleanTarget}/${folderName}` : folderName; - - if (newFolderPath === cleanSource) return; - triggerHaptic(15); - - // Check if sourcePath is the root mount of a shared folder - const matchingSF = sharedFolders.find((sf) => sf.mountPath === cleanSource); - if (matchingSF) { - try { - await api.updateFolderMount(matchingSF.id, newFolderPath); - const sfList = await api.getSharedFolders(); - setSharedFolders(sfList); - await loadDocs(selectedDoc?.id); - setExpandedFolders((prev) => { - const next = new Set(prev); - next.add(newFolderPath); - if (cleanTarget) next.add(cleanTarget); - return next; - }); - } catch (err: any) { - console.error('Failed to move shared folder mount', err); - alert(err.message || 'Fehler beim Verschieben des geteilten Ordners'); - } - return; - } - - // 1. Update all documents that are in cleanSource or any subfolder of cleanSource - const docsToUpdate = docs.filter((d) => { - const f = getDocFolder(d); - return f === cleanSource || f.startsWith(cleanSource + '/'); - }); - - const updatedDocsList = docs.map((d) => { - const f = getDocFolder(d); - if (f === cleanSource) { - return { ...d, folderPath: newFolderPath }; - } else if (f.startsWith(cleanSource + '/')) { - const suffix = f.slice(cleanSource.length); - return { ...d, folderPath: `${newFolderPath}${suffix}` }; - } - return d; - }); - - setDocs(updatedDocsList); - if (selectedDoc) { - const found = updatedDocsList.find((d) => d.id === selectedDoc.id); - if (found) setSelectedDoc(found); - } - try { - await api.createFolder(newFolderPath); - const subFoldersToRecreate = customFolders.filter((cf) => cf.startsWith(cleanSource + '/')); - for (const sf of subFoldersToRecreate) { - const suffix = sf.slice(cleanSource.length); - await api.createFolder(`${newFolderPath}${suffix}`); - } - // Persist all affected docs to backend BEFORE deleting the old empty folder - await Promise.all( - docsToUpdate.map((d) => { - const f = getDocFolder(d); - let nextFolder = newFolderPath; - if (f.startsWith(cleanSource + '/')) { - const suffix = f.slice(cleanSource.length); - nextFolder = `${newFolderPath}${suffix}`; - } - return api.moveDocument(d.id, nextFolder); - }) - ); - await api.deleteFolder(cleanSource); - const freshFolders = await api.getFolders(); - setCustomFolders(freshFolders); - } catch (err) { - console.error('Error syncing moved folders with server', err); + const res = await api.moveFolder(cleanSource, cleanTarget); + await Promise.all([ + api.getFolders().then(setCustomFolders), + api.getSharedFolders().then(setSharedFolders), + loadDocs(selectedDoc?.id), + ]); + setExpandedFolders((prev) => { + const next = new Set(prev); + if (res.newPath) next.add(res.newPath); + if (cleanTarget) next.add(cleanTarget); + return next; + }); + } catch (err: any) { + console.error('Failed to move folder', err); + alert(err.message || 'Fehler beim Verschieben des Ordners'); } - - // Expand the new folder path and its parent - setExpandedFolders((prev) => { - const next = new Set(prev); - next.add(newFolderPath); - if (cleanTarget) next.add(cleanTarget); - return next; - }); }; const handleDropDocToFolder = async (folderPath: string, e: React.DragEvent) => { @@ -4216,16 +4212,16 @@ export const WorkspaceView: React.FC = ({ {isLockedByOther && ( -
- +
+
- {activeLock?.userName || 'Ein anderer Benutzer'} bearbeitet diese Notiz gerade ebenfalls. -

Live-Zusammenarbeit aktiv – Änderungen werden automatisch Punkt für Punkt synchronisiert.

+ Diese Notiz wird aktuell von {activeLock?.userName || 'einem anderen Benutzer'} bearbeitet. +

Die Bearbeitung ist vorübergehend gesperrt, um Zeilen- und Datenverlust zu vermeiden.

)} - {selectedDoc.permission === 'read' && ( + {!isLockedByOther && selectedDoc.permission === 'read' && (
Du hast Nur-Lesezugriff auf diese Notiz. Änderungen können nicht vorgenommen werden. diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index acf96fe..1288836 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -434,6 +434,13 @@ export const api = { ); }, + async moveFolder(sourcePath: string, targetParentPath: string): Promise<{ newPath: string; movedDocIds: string[] }> { + return request<{ newPath: string; movedDocIds: string[] }>('/api/v1/folders/move', { + method: 'POST', + body: JSON.stringify({ sourcePath, targetParentPath }), + }); + }, + // --- WORKSPACE SHARED FOLDERS --- async getSharedFolders(): Promise { const res = await request('/api/v1/workspace/folders/shared'); diff --git a/internal/api/server.go b/internal/api/server.go index 849865c..012a9a9 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -104,6 +104,7 @@ func (s *Server) Router() http.Handler { // Workspace Documents & Folders r.Get("/api/v1/folders", s.handleGetFolders) r.Post("/api/v1/folders", s.handleCreateFolder) + r.Post("/api/v1/folders/move", s.handleMoveFolder) r.Delete("/api/v1/folders", s.handleDeleteFolder) r.Get("/api/v1/workspace/folders/shared", s.handleGetSharedFolders) @@ -816,6 +817,7 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } + s.wsHub.BroadcastDocEvent(doc.ID, "DOC_CREATED", doc) s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED") writeJSON(w, http.StatusCreated, doc) } @@ -835,6 +837,16 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") + // Check document lock + if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID { + w.WriteHeader(http.StatusLocked) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Dokument wird aktuell bearbeitet von " + lock.UserName, + "currentLock": lock, + }) + return + } + var req struct { Title *string `json:"title"` Icon *string `json:"icon"` @@ -854,6 +866,7 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { return } if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil { + s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc) s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") } writeJSON(w, http.StatusOK, doc) @@ -863,6 +876,16 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") + // Check document lock + if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID { + w.WriteHeader(http.StatusLocked) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Dokument wird aktuell bearbeitet von " + lock.UserName, + "currentLock": lock, + }) + return + } + var req domain.DocBlocksPatchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") @@ -883,6 +906,7 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques DeletedIDs: req.DeletedIDs, BlockOrder: mergedOrder, } + s.wsHub.BroadcastDocEvent(docID, "DOC_BLOCKS_PATCHED", patchEvent) if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { for _, uid := range users { s.wsHub.BroadcastToUser(uid, "DOC_BLOCKS_PATCHED", patchEvent) @@ -959,6 +983,37 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleMoveFolder(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + var req struct { + SourcePath string `json:"sourcePath"` + TargetParentPath string `json:"targetParentPath"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiges JSON") + return + } + newPath, movedDocIDs, err := s.store.MoveFolder(userID, req.SourcePath, req.TargetParentPath) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + for _, id := range movedDocIDs { + if doc, err := s.store.GetDocumentByID(userID, id); err == nil && doc != nil { + s.wsHub.BroadcastDocEvent(id, "DOC_UPDATED", doc) + s.broadcastDocToAuthorizedUsers(id, "DOC_UPDATED") + } + } + s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{ + "sourcePath": req.SourcePath, + "newPath": newPath, + }) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "newPath": newPath, + "movedDocIds": movedDocIDs, + }) +} + // --- WORKSPACE SHARED FOLDERS --- func (s *Server) handleGetSharedFolders(w http.ResponseWriter, r *http.Request) { @@ -1107,6 +1162,7 @@ func (s *Server) handleMoveDocument(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } + s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc) s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", nil) writeJSON(w, http.StatusOK, doc) diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 60b95e9..f06db3b 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -2807,6 +2807,138 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do return doc, nil } +// MoveFolder moves a folder (and all its documents/subfolders) to a new target parent path. +func (s *Store) MoveFolder(userID, sourcePath, targetParentPath string) (string, []string, error) { + cleanSource := filepath.ToSlash(filepath.Clean(strings.TrimSpace(sourcePath))) + if cleanSource == "" || cleanSource == "." || strings.HasPrefix(cleanSource, "/") || strings.Contains(cleanSource, "..") { + return "", nil, fmt.Errorf("ungültiger Quell-Ordnerpfad") + } + cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetParentPath))) + if cleanTarget == "." { + cleanTarget = "" + } + if cleanTarget != "" && (cleanTarget == cleanSource || strings.HasPrefix(cleanTarget, cleanSource+"/")) { + return "", nil, fmt.Errorf("ein Ordner kann nicht in sich selbst verschoben werden") + } + + folderName := filepath.Base(cleanSource) + var newFolderPath string + if cleanTarget == "" { + newFolderPath = folderName + } else { + newFolderPath = cleanTarget + "/" + folderName + } + + if newFolderPath == cleanSource { + return cleanSource, []string{}, nil + } + + // 1. Check if sourcePath is the root mount of a shared folder + mounts, _, err := s.getUserFolderMounts(userID) + if err == nil { + for sfID, mPath := range mounts { + if mPath == cleanSource { + err := s.UpdateFolderMount(userID, sfID, newFolderPath) + if err != nil { + return "", nil, err + } + return newFolderPath, []string{}, nil + } + } + } + + // 2. Move all affected documents + docs, err := s.GetDocuments(userID) + if err != nil { + return "", nil, err + } + + var movedDocIDs []string + for _, doc := range docs { + if doc.FolderPath == cleanSource || strings.HasPrefix(doc.FolderPath, cleanSource+"/") { + var nextDocFolder string + if doc.FolderPath == cleanSource { + nextDocFolder = newFolderPath + } else { + suffix := strings.TrimPrefix(doc.FolderPath, cleanSource) + nextDocFolder = newFolderPath + suffix + } + _, moveErr := s.MoveDocument(userID, doc.ID, nextDocFolder) + if moveErr == nil { + movedDocIDs = append(movedDocIDs, doc.ID) + } + } + } + + // 3. Re-map workspace_folders entries + targetSFID, targetSub, isTargetSF := matchMountPath(newFolderPath, mounts) + sourceSFID, sourceSub, isSourceSF := matchMountPath(cleanSource, mounts) + + wfRows, qErr := s.db.Query(`SELECT id, user_id, shared_folder_id, path, sub_path FROM workspace_folders`) + if qErr == nil { + type wfRecord struct { + id, uid, sfID, path, subPath string + } + var records []wfRecord + for wfRows.Next() { + var r wfRecord + if err := wfRows.Scan(&r.id, &r.uid, &r.sfID, &r.path, &r.subPath); err == nil { + records = append(records, r) + } + } + _ = wfRows.Close() + + for _, r := range records { + if isSourceSF && r.sfID == sourceSFID { + matches := false + var suffix string + if sourceSub == "" { + matches = true + suffix = "/" + r.subPath + } else if r.subPath == sourceSub { + matches = true + suffix = "" + } else if strings.HasPrefix(r.subPath, sourceSub+"/") { + matches = true + suffix = strings.TrimPrefix(r.subPath, sourceSub) + } + if matches { + var newSub string + if targetSub == "" { + newSub = strings.TrimPrefix(folderName+suffix, "/") + } else { + newSub = targetSub + "/" + folderName + suffix + } + if isTargetSF { + _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, targetSFID, newSub, r.id) + } else { + newP := newFolderPath + suffix + _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = '', sub_path = '', path = ? WHERE id = ?`, newP, r.id) + } + } + } else if r.path == cleanSource || strings.HasPrefix(r.path, cleanSource+"/") { + suffix := strings.TrimPrefix(r.path, cleanSource) + newP := newFolderPath + suffix + if isTargetSF { + var newSub string + if targetSub == "" { + newSub = strings.TrimPrefix(folderName+suffix, "/") + } else { + newSub = targetSub + "/" + folderName + suffix + } + _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ?, path = ? WHERE id = ?`, targetSFID, newSub, newP, r.id) + } else { + _, _ = s.db.Exec(`UPDATE workspace_folders SET path = ? WHERE id = ?`, newP, r.id) + } + } + } + } + + _, _ = s.CreateFolder(userID, newFolderPath) + + return newFolderPath, movedDocIDs, nil +} + // syncSharedFolderContents ensures all documents and subfolders under cleanMount owned by ownerID are linked to folderID. func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) { if ownerID == "" || folderID == "" || cleanMount == "" { diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index e28b60e..07667c0 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -665,5 +665,29 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) { if !foundSubfolderForB { t.Fatalf("expected User B to see empty shared subfolder 'ExterneProjekte/TeamProjekt/Konzepte', got folders: %v", foldersB) } + + // 3. MoveFolder inside a shared folder + newPath, _, err := store.MoveFolder(userA.ID, "TeamProjekt/Konzepte", "TeamProjekt/Archiv") + if err != nil { + t.Fatalf("MoveFolder failed: %v", err) + } + if newPath != "TeamProjekt/Archiv/Konzepte" { + t.Fatalf("expected newPath 'TeamProjekt/Archiv/Konzepte', got %q", newPath) + } + + // User B must now see "ExterneProjekte/TeamProjekt/Archiv/Konzepte" + foldersBAfterMove, err := store.GetFolders(userB.ID) + if err != nil { + t.Fatalf("GetFolders after MoveFolder failed: %v", err) + } + foundMovedForB := false + for _, f := range foldersBAfterMove { + if f == "ExterneProjekte/TeamProjekt/Archiv/Konzepte" { + foundMovedForB = true + } + } + if !foundMovedForB { + t.Fatalf("expected User B to see moved shared subfolder 'ExterneProjekte/TeamProjekt/Archiv/Konzepte', got %v", foldersBAfterMove) + } } -- 2.54.0 From c2ed76c6a4dda7a5fe3aea302b82a740aad62e1a Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 11:42:57 +0100 Subject: [PATCH 13/19] fix(workspace): add http polling fallback for proxy/firewall environments and exponential ws reconnect backoff --- .../src/features/workspace/WorkspaceView.tsx | 68 ++++++++++++++++--- frontend/src/lib/api.ts | 6 +- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 0310ca9..8b5eb78 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -132,6 +132,7 @@ export const WorkspaceView: React.FC = ({ // Dedicated WebSocket connection & status for live indicator const wsRef = useRef(null); const wsReconnectTimerRef = useRef(null); + const wsReconnectDelayRef = useRef(2000); const [wsConnected, setWsConnected] = useState(false); // Folder state (synchronized with server) @@ -970,6 +971,7 @@ export const WorkspaceView: React.FC = ({ ws.onopen = () => { if (!isMounted) return; + wsReconnectDelayRef.current = 2000; setWsConnected(true); api.getFolders().then(setCustomFolders).catch(() => {}); api.getSharedFolders().then(setSharedFolders).catch(() => {}); @@ -979,7 +981,9 @@ export const WorkspaceView: React.FC = ({ if (!isMounted) return; setWsConnected(false); clearTimeout(wsReconnectTimerRef.current); - wsReconnectTimerRef.current = setTimeout(connect, 2000); + const delay = wsReconnectDelayRef.current; + wsReconnectDelayRef.current = Math.min(30000, Math.round(wsReconnectDelayRef.current * 1.5)); + wsReconnectTimerRef.current = setTimeout(connect, delay); }; ws.onerror = () => { @@ -997,7 +1001,9 @@ export const WorkspaceView: React.FC = ({ if (!isMounted) return; setWsConnected(false); clearTimeout(wsReconnectTimerRef.current); - wsReconnectTimerRef.current = setTimeout(connect, 3000); + const delay = wsReconnectDelayRef.current; + wsReconnectDelayRef.current = Math.min(30000, Math.round(wsReconnectDelayRef.current * 1.5)); + wsReconnectTimerRef.current = setTimeout(connect, delay); } }; @@ -1024,6 +1030,44 @@ export const WorkspaceView: React.FC = ({ }; }, [onStatsChange]); + // Fallback HTTP polling when WebSocket is disconnected (e.g. corporate proxy / firewall blocking WebSockets) + useEffect(() => { + if (wsConnected) return; + + let isMounted = true; + const pollInterval = setInterval(async () => { + if (!isMounted || document.visibilityState !== 'visible') return; + + // 1. Poll lock status of currently selected document + if (selectedDocIdRef.current) { + try { + const res = await api.getDocLock(selectedDocIdRef.current); + if (res.locked && res.lock && res.lock.userId !== currentUserIdRef.current) { + setActiveLock(res.lock); + } else { + setActiveLock(null); + } + } catch {} + } + + // 2. Poll document content if not actively typing and document has changed on server + if (selectedDocIdRef.current && !isEditorFocusedRef.current && !pendingSaveDocRef.current) { + try { + const fresh = await api.getDocument(selectedDocIdRef.current); + if (fresh && (!selectedDoc || fresh.updatedAt !== selectedDoc.updatedAt)) { + setSelectedDoc(fresh); + setDocs((prev) => prev.map((d) => (d.id === fresh.id ? fresh : d))); + } + } catch {} + } + }, 4000); + + return () => { + isMounted = false; + clearInterval(pollInterval); + }; + }, [wsConnected, selectedDoc?.updatedAt]); + const openShareModal = async () => { if (!selectedDoc) return; setShowShareModal(true); @@ -4001,11 +4045,15 @@ export const WorkspaceView: React.FC = ({ className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${ wsConnected ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500' - : 'bg-canvas border-border text-muted' + : 'bg-amber-500/10 border-amber-500/30 text-amber-500' }`} - title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline - Verbindung wird wiederhergestellt'} + title={ + wsConnected + ? 'Live synchronisiert (WebSocket online)' + : 'Proxy/Offline-Fallback: Synchronisation über HTTP aktiv' + } > - +
@@ -4037,11 +4085,15 @@ export const WorkspaceView: React.FC = ({ className={`h-9 w-9 flex items-center justify-center rounded-btn border transition-colors ${ wsConnected ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500' - : 'bg-canvas border-border text-muted' + : 'bg-amber-500/10 border-amber-500/30 text-amber-500' }`} - title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline - Verbindung wird wiederhergestellt'} + title={ + wsConnected + ? 'Live synchronisiert (WebSocket online)' + : 'Proxy/Offline-Fallback: Synchronisation über HTTP aktiv' + } > - +
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1288836..937e1c9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1048,6 +1048,7 @@ let wsListeners: ((msg: WSMessage) => void)[] = []; let wsStatusListeners: ((connected: boolean) => void)[] = []; let isWSConnected = false; let manualDisconnect = false; +let wsReconnectDelay = 2000; export function buildWebSocketURL(listId?: string): string { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -1097,6 +1098,7 @@ export function connectWebSocket(): void { activeGlobalWS = ws; ws.onopen = () => { + wsReconnectDelay = 2000; notifyStatusChange(true); }; @@ -1121,9 +1123,11 @@ export function connectWebSocket(): void { notifyStatusChange(false); if (!manualDisconnect && getToken()) { clearTimeout(wsReconnectTimer); + const delay = wsReconnectDelay; + wsReconnectDelay = Math.min(30000, Math.round(wsReconnectDelay * 1.5)); wsReconnectTimer = setTimeout(() => { connectWebSocket(); - }, 3000); + }, delay); } }; -- 2.54.0 From 155d2daa020b4a976da20ad9469d7d47ee749e7b Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 12:06:55 +0100 Subject: [PATCH 14/19] fix(workspace): normalize websocket headers for reverse proxies and show detailed disconnect reason --- .../src/features/workspace/WorkspaceView.tsx | 38 ++++++++++++++++--- frontend/src/lib/api.ts | 6 ++- internal/api/server.go | 12 ++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 8b5eb78..80143db 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -134,6 +134,7 @@ export const WorkspaceView: React.FC = ({ const wsReconnectTimerRef = useRef(null); const wsReconnectDelayRef = useRef(2000); const [wsConnected, setWsConnected] = useState(false); + const [wsDisconnectReason, setWsDisconnectReason] = useState(null); // Folder state (synchronized with server) const [customFolders, setCustomFolders] = useState([]); @@ -972,22 +973,37 @@ export const WorkspaceView: React.FC = ({ ws.onopen = () => { if (!isMounted) return; wsReconnectDelayRef.current = 2000; + setWsDisconnectReason(null); setWsConnected(true); api.getFolders().then(setCustomFolders).catch(() => {}); api.getSharedFolders().then(setSharedFolders).catch(() => {}); }; - ws.onclose = () => { + ws.onclose = (evt: CloseEvent) => { if (!isMounted) return; setWsConnected(false); + const reasonText = + evt.reason || + (evt.code === 1006 + ? 'Verbindung durch Firmen-Proxy oder Netzwerk getrennt (Code 1006)' + : evt.code === 1008 + ? 'Richtlinien-Verletzung durch Proxy/Firewall (Code 1008)' + : evt.code === 1002 + ? 'Protokollfehler beim Handshake (Code 1002)' + : evt.code === 1000 + ? 'Normal geschlossen (Code 1000)' + : `Code ${evt.code}`); + console.warn('[MindSift Workspace WS] getrennt:', evt.code, evt.reason || reasonText); + setWsDisconnectReason(reasonText); clearTimeout(wsReconnectTimerRef.current); const delay = wsReconnectDelayRef.current; wsReconnectDelayRef.current = Math.min(30000, Math.round(wsReconnectDelayRef.current * 1.5)); wsReconnectTimerRef.current = setTimeout(connect, delay); }; - ws.onerror = () => { + ws.onerror = (e) => { if (!isMounted) return; + console.warn('[MindSift Workspace WS] Fehler aufgetreten:', e); setWsConnected(false); }; @@ -997,9 +1013,11 @@ export const WorkspaceView: React.FC = ({ handleWsMsg(msg); } catch {} }; - } catch (err) { + } catch (err: any) { if (!isMounted) return; setWsConnected(false); + const errText = err?.message || 'Verbindungsfehler'; + setWsDisconnectReason(errText); clearTimeout(wsReconnectTimerRef.current); const delay = wsReconnectDelayRef.current; wsReconnectDelayRef.current = Math.min(30000, Math.round(wsReconnectDelayRef.current * 1.5)); @@ -1009,6 +1027,15 @@ export const WorkspaceView: React.FC = ({ connect(); + // Also synchronize with global WebSocket status + const unsubGlobalStatus = onWebSocketStatus((connected) => { + if (!isMounted) return; + if (connected) { + setWsConnected(true); + setWsDisconnectReason(null); + } + }); + // Fallback: also listen for window custom event 'mindsift:ws' const handleGlobalCustomEvent = (e: any) => { if (e.detail) { @@ -1019,6 +1046,7 @@ export const WorkspaceView: React.FC = ({ return () => { isMounted = false; + unsubGlobalStatus(); clearTimeout(wsReconnectTimerRef.current); if (wsRef.current) { try { @@ -4050,7 +4078,7 @@ export const WorkspaceView: React.FC = ({ title={ wsConnected ? 'Live synchronisiert (WebSocket online)' - : 'Proxy/Offline-Fallback: Synchronisation über HTTP aktiv' + : `Proxy/Offline-Fallback: Synchronisation über HTTP aktiv${wsDisconnectReason ? ` (${wsDisconnectReason})` : ''}` } > @@ -4090,7 +4118,7 @@ export const WorkspaceView: React.FC = ({ title={ wsConnected ? 'Live synchronisiert (WebSocket online)' - : 'Proxy/Offline-Fallback: Synchronisation über HTTP aktiv' + : `Proxy/Offline-Fallback: Synchronisation über HTTP aktiv${wsDisconnectReason ? ` (${wsDisconnectReason})` : ''}` } > diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 937e1c9..13f2508 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1118,8 +1118,9 @@ export function connectWebSocket(): void { } }; - ws.onclose = () => { + ws.onclose = (evt: CloseEvent) => { activeGlobalWS = null; + console.warn('[MindSift Global WS] Closed with code:', evt?.code, 'reason:', evt?.reason); notifyStatusChange(false); if (!manualDisconnect && getToken()) { clearTimeout(wsReconnectTimer); @@ -1131,7 +1132,8 @@ export function connectWebSocket(): void { } }; - ws.onerror = () => { + ws.onerror = (e) => { + console.warn('[MindSift Global WS] Error:', e); notifyStatusChange(false); try { ws.close(); diff --git a/internal/api/server.go b/internal/api/server.go index 012a9a9..d7993fe 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1585,6 +1585,18 @@ func (s *Server) handleReorderListItems(w http.ResponseWriter, r *http.Request) } func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + // Some corporate proxies and reverse proxies (e.g. OpenResty/Nginx or HTTP/2 intermediaries) + // strip or alter the Connection header (e.g. "close" or missing) while passing + // Sec-WebSocket-Key or Upgrade: websocket. Normalize it so Gorilla Upgrader succeeds. + if r.Header.Get("Sec-Websocket-Key") != "" { + if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") { + r.Header.Set("Connection", "Upgrade") + } + if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") { + r.Header.Set("Upgrade", "websocket") + } + } + roomID := chi.URLParam(r, "listId") if roomID == "" { roomID = chi.URLParam(r, "spaceId") -- 2.54.0 From 832f2f9c2b72d0c9df50f6aebb31408280a457a6 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 12:27:54 +0100 Subject: [PATCH 15/19] style(workspace): remove large editing lock banner to prevent layout shift --- .../src/features/workspace/WorkspaceView.tsx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 80143db..b8973fc 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -4223,7 +4223,10 @@ export const WorkspaceView: React.FC = ({ )} {isLockedByOther && ( - + Wird bearbeitet ({activeLock?.userName || 'anderer Nutzer'}) @@ -4291,17 +4294,7 @@ export const WorkspaceView: React.FC = ({ - {isLockedByOther && ( -
- -
- Diese Notiz wird aktuell von {activeLock?.userName || 'einem anderen Benutzer'} bearbeitet. -

Die Bearbeitung ist vorübergehend gesperrt, um Zeilen- und Datenverlust zu vermeiden.

-
-
- )} - - {!isLockedByOther && selectedDoc.permission === 'read' && ( + {selectedDoc.permission === 'read' && (
Du hast Nur-Lesezugriff auf diese Notiz. Änderungen können nicht vorgenommen werden. -- 2.54.0 From baac20ea104c45b7643883db10ccbc212bffaad9 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 12:56:11 +0100 Subject: [PATCH 16/19] feat(mobile): simplify triage quick dump capture and convert priority to 4 buttons across tasks --- frontend/src/App.tsx | 8 +- .../features/capture/InstantCaptureBar.tsx | 343 ++++++++++-------- .../src/features/triage/TaskDetailModal.tsx | 49 ++- frontend/src/features/triage/TasksView.tsx | 68 ++-- 4 files changed, 269 insertions(+), 199 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3ab213..21b1f46 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1034,9 +1034,11 @@ export const App: React.FC = () => {
- + {activeTab === 'triage' ? : }
-

Schnell erfassen

+

+ {activeTab === 'triage' ? 'Quick Dump erfassen' : 'Schnell erfassen'} +

+ {/* Target & Options Toggle Pill (Hidden when target picker is disabled) */} + {!hideTargetPicker && ( + + )} {/* Voice Dictation Button */} - - {/* Option 2: Direct Task */} - - - {/* Option 3: Direct List Item */} - - - {/* Option 4: Direct Workspace Note */} - -
-
- - {/* Optional Sub-Row: Shared List Picker */} - {lists.length > 0 && ( -
- - - Liste: - - {lists.map((lst) => { - const isSelected = target === 'list_item' && targetListId === lst.id; - return ( +
+ {/* Option 1: Quick Dump (Triage) */} - ); - })} -
+ + {/* Option 2: Direct Task */} + + + {/* Option 3: Direct List Item */} + + + {/* Option 4: Direct Workspace Note */} + +
+
+ + {/* Optional Sub-Row: Shared List Picker */} + {lists.length > 0 && ( +
+ + + Liste: + + {lists.map((lst) => { + const isSelected = target === 'list_item' && targetListId === lst.id; + return ( + + ); + })} +
+ )} + )} {/* Row 2: Priority & Due Date */} -
-
- Priorität: +
+
+ Priorität: {(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => { - const labels: Record = { - low: 'Niedrig', - normal: 'Normal', - high: 'Hoch', - urgent: 'Dringend', + const config: Record = { + low: { label: 'Niedrig', activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs' }, + normal: { label: 'Normal', activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs' }, + high: { label: 'Wichtig', icon: , activeClass: 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs' }, + urgent: { label: 'Dringend', icon: , activeClass: 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs' }, }; const active = priority === p; + const item = config[p]; return ( ); })}
-
- - +
+ + Fällig am: = ({
- {/* Priority Buttons */} + {/* Priority Buttons (4 Buttons) */}
-
+
+
)} - {/* Priority & Due Date */} -
-
- - + {/* Priority - 4 Buttons */} +
+ +
+ {(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => { + const config: Record = { + low: { label: 'Niedrig', activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs' }, + normal: { label: 'Normal', activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs' }, + high: { label: 'Wichtig', icon: , activeClass: 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs' }, + urgent: { label: 'Dringend', icon: , activeClass: 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs' }, + }; + const active = mobileCreatePriority === p; + const item = config[p]; + return ( + + ); + })}
+
-
- - setMobileCreateDueDate(e.target.value)} - className="w-full input-field px-2 py-2 text-xs" - /> -
+ {/* Due Date */} +
+ + setMobileCreateDueDate(e.target.value)} + className="w-full input-field px-3 py-2 text-xs" + />
-- 2.54.0 From 0e06f6761d413b5f7bbaf027a5a3604df81c2e67 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 13:12:57 +0100 Subject: [PATCH 17/19] feat(webhook): ensure webhook source badge displays for triage thoughts and tasks --- .../features/settings/UserSettingsView.tsx | 57 ++++++++---- .../src/features/triage/TaskDetailModal.tsx | 7 ++ frontend/src/features/triage/TasksView.tsx | 20 ++++ frontend/src/features/triage/TriageDeck.tsx | 24 ++++- internal/api/server.go | 61 +++++++++++-- internal/api/server_test.go | 91 +++++++++++++++++++ 6 files changed, 233 insertions(+), 27 deletions(-) diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index a90a97f..ec8e15b 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -264,6 +264,11 @@ export const UserSettingsView: React.FC = ({ const activeKeyPlaceholder = newlyCreatedKey || (apiKeys[0]?.keyPrefix ? apiKeys[0].keyPrefix + '••••••••' : 'DEIN_API_KEY'); + const curlTriageExample = `curl -X POST ${baseUrl}/api/v1/inbox \\ + -H "X-API-Key: ${activeKeyPlaceholder}" \\ + -H "Content-Type: application/json" \\ + -d '{"content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", "priority": "urgent"}'`; + const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\ -H "X-API-Key: ${activeKeyPlaceholder}" \\ -H "Content-Type: application/json" \\ @@ -920,23 +925,43 @@ export const UserSettingsView: React.FC = ({ )}
- {/* Curl Example */} -
-
- - - Curl-Beispiel: Direkt als Aufgabe einspeisen - - + {/* Curl Examples */} +
+
+
+ + + Curl: In Triage-Deck einspeisen (erhält Webhook-Badge) + + +
+
+                    {curlTriageExample}
+                  
+
+ +
+
+ + + Curl: Direkt als Aufgabe anlegen + + +
+
+                    {curlTaskExample}
+                  
-
-                  {curlTaskExample}
-                
{/* Full Data Backup */} diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index 6ce2e3e..7e1b3ef 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -14,6 +14,7 @@ import { Flame, ChevronRight, Folder, + Webhook, } from 'lucide-react'; import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types'; import { triggerHaptic } from '../../lib/api'; @@ -192,6 +193,12 @@ export const TaskDetailModal: React.FC = ({ {task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'} + {task.source?.toLowerCase() === 'webhook' && ( + + + Webhook + + )}
diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 75ea26b..de19dd4 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -24,6 +24,7 @@ import { ChevronDown, Folder, Filter, + Webhook, } from 'lucide-react'; import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn, PriorityLevel } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; @@ -1170,6 +1171,16 @@ export const TasksView: React.FC = ({ )} + {task.source?.toLowerCase() === 'webhook' && ( + + + Webhook + + )} + {/* Note indicator */} {task.notes && ( )} + {task.source?.toLowerCase() === 'webhook' && ( + + + Webhook + + )} {task.dueDate && ( diff --git a/frontend/src/features/triage/TriageDeck.tsx b/frontend/src/features/triage/TriageDeck.tsx index 22aa1f4..51ccec9 100644 --- a/frontend/src/features/triage/TriageDeck.tsx +++ b/frontend/src/features/triage/TriageDeck.tsx @@ -139,11 +139,21 @@ const SwipeableQueueItem: React.FC = ({ }} className="relative bg-panel px-4 py-3.5 sm:px-3.5 sm:py-2.5 min-h-[56px] sm:min-h-[44px] flex items-center justify-between gap-3 hover:bg-panel-hover transition-colors group cursor-pointer" > -
+
{item.content} + {item.source?.toLowerCase() === 'webhook' && ( + + Webhook + + )} + {item.source?.toLowerCase() === 'voice' && ( + + Diktat + + )}
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */} @@ -413,15 +423,16 @@ export const TriageDeck: React.FC = ({ } }; - const sourceBadge = (source: string) => { - if (source === 'voice') { + const sourceBadge = (source?: string) => { + const s = (source || '').toLowerCase().trim(); + if (s === 'voice') { return ( Diktat ); } - if (source === 'webhook') { + if (s === 'webhook') { return ( Webhook @@ -500,7 +511,10 @@ export const TriageDeck: React.FC = ({ key={nextCard.id + '-preview'} className="absolute inset-x-3 top-3 bottom-0 panel-surface p-4 opacity-40 scale-[0.97] pointer-events-none flex flex-col justify-between" > -
{priorityBadge(nextCard.priority)}
+
+ {priorityBadge(nextCard.priority)} + {sourceBadge(nextCard.source)} +

{nextCard.content}

Nächster Quick Dump…
diff --git a/internal/api/server.go b/internal/api/server.go index d7993fe..fc0f54f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + "io" "io/fs" "net/http" "net/smtp" @@ -586,7 +587,25 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) { taskListID = req.TaskListID2 } - thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, req.Source, dueDate, taskListID, "todo", "") + source := strings.ToLower(strings.TrimSpace(req.Source)) + if source == "" { + source = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("source"))) + } + if source == "" { + source = strings.ToLower(strings.TrimSpace(r.Header.Get("X-Source"))) + } + if source == "" { + // When called via API key or webhook token, automatically default source to "webhook" + if r.Header.Get("X-API-Key") != "" || + strings.HasPrefix(r.Header.Get("Authorization"), "Bearer mindsift_sec_") || + strings.HasPrefix(r.URL.Query().Get("token"), "mindsift_sec_") { + source = "webhook" + } else { + source = "manual" + } + } + + thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "") if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -1689,13 +1708,36 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { var req struct { Title string `json:"title"` Content string `json:"content"` + Text string `json:"text"` + Message string `json:"message"` Priority string `json:"priority"` + Source string `json:"source"` Tags []string `json:"tags"` DueDate string `json:"dueDate"` + DueDate2 string `json:"due_date"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "Ungültiges JSON-Payload") - return + + bodyBytes, _ := io.ReadAll(r.Body) + if len(bodyBytes) > 0 { + if jsonErr := json.Unmarshal(bodyBytes, &req); jsonErr != nil { + plain := strings.TrimSpace(string(bodyBytes)) + if plain != "" { + req.Content = plain + } + } + } + + if req.Content == "" { + if req.Text != "" { + req.Content = req.Text + } else if req.Message != "" { + req.Content = req.Message + } + } + + dueDate := req.DueDate + if dueDate == "" { + dueDate = req.DueDate2 } bodyText := strings.TrimSpace(req.Title) @@ -1707,7 +1749,7 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { } } if bodyText == "" { - writeError(w, http.StatusBadRequest, "Feld 'title' oder 'content' ist erforderlich") + writeError(w, http.StatusBadRequest, "Feld 'title', 'content', 'text' oder 'message' ist erforderlich") return } @@ -1722,12 +1764,19 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { req.Priority = "high" } - thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, "webhook", req.DueDate) + source := strings.ToLower(strings.TrimSpace(req.Source)) + if source == "" { + source = "webhook" + } + + thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, source, dueDate) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } + s.wsHub.BroadcastTaskEvent(thought.UserID, "TASK_CREATED", thought) + writeJSON(w, http.StatusCreated, map[string]interface{}{ "status": "ingested", "thought": thought, diff --git a/internal/api/server_test.go b/internal/api/server_test.go index cf96baf..69d228d 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -747,3 +747,94 @@ func TestWorkspaceTagsAPI(t *testing.T) { t.Fatalf("expected 200 OK from DELETE /api/v1/folders, got %d", rec.Code) } } + +func TestWebhookTriageBadgeAndIngestion(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test_webhook.db") + store, err := storage.NewStore(dbPath) + if err != nil { + t.Fatalf("failed to init store: %v", err) + } + defer store.Close() + + cfg := &config.Config{ + JWTSecret: "test-jwt-secret-key", + AllowRegistration: false, + } + srv := NewServer(cfg, store, ws.NewHub(), nil) + router := srv.Router() + + // 1. Setup user + setupBody := []byte(`{ + "email": "smarthome_owner@example.com", + "password": "secretpassword", + "displayName": "Home User", + "instanceName": "MindSift Home", + "allowRegistration": false, + "seedSampleData": false + }`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("setup failed: %s", rec.Body.String()) + } + var authResp struct { + Token string `json:"token"` + User *domain.User `json:"user"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &authResp) + + // 2. Create API key for user + apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook") + if err != nil || apiKey == nil || apiKey.RawKey == "" { + t.Fatalf("failed to create API key: %v", err) + } + plainKey := apiKey.RawKey + + // 3. Post to /api/v1/inbox using X-API-Key without passing "source" + inboxBody := []byte(`{ + "content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", + "priority": "urgent" + }`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(inboxBody)) + req.Header.Set("X-API-Key", plainKey) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 Created from /api/v1/inbox with API key, got %d: %s", rec.Code, rec.Body.String()) + } + var createdThought domain.Thought + _ = json.Unmarshal(rec.Body.Bytes(), &createdThought) + if createdThought.Source != "webhook" { + t.Fatalf("expected created thought to have source='webhook' when called with API key, got '%s'", createdThought.Source) + } + + // 4. Post to /api/v1/webhooks/ingest using ?token=... with message alias + webhookBody := []byte(`{ + "message": "Waschmaschine im Keller fertig", + "priority": "high", + "tags": ["automation"] + }`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/ingest?token="+plainKey, bytes.NewReader(webhookBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 Created from /api/v1/webhooks/ingest, got %d: %s", rec.Code, rec.Body.String()) + } + var ingestResp struct { + Status string `json:"status"` + Thought domain.Thought `json:"thought"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &ingestResp) + if ingestResp.Thought.Source != "webhook" { + t.Fatalf("expected ingested webhook thought to have source='webhook', got '%s'", ingestResp.Thought.Source) + } + if !strings.Contains(ingestResp.Thought.Content, "Waschmaschine") { + t.Fatalf("expected thought content to contain 'Waschmaschine', got '%s'", ingestResp.Thought.Content) + } +} -- 2.54.0 From 10d5c6d563d4a8eda3e75312199d010d6d640715 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 13:19:22 +0100 Subject: [PATCH 18/19] feat(mobile): align triage quick dump modal design with task creation modal --- frontend/src/App.tsx | 239 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 212 insertions(+), 27 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 21b1f46..a0bfd16 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,8 +18,13 @@ import { Smartphone, Share, Users, + Calendar, + AlertTriangle, + Flame, + Mic, + MicOff, } from 'lucide-react'; -import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types'; +import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User } from './lib/types'; import { api, clearToken, @@ -132,12 +137,93 @@ export const App: React.FC = () => { // Mobile Navigation & Quick-Action State const [showMobileCapture, setShowMobileCapture] = useState(false); const [showMobileProfile, setShowMobileProfile] = useState(false); + const [mobileTriageContent, setMobileTriageContent] = useState(''); + const [mobileTriagePriority, setMobileTriagePriority] = useState('normal'); + const [mobileTriageDueDate, setMobileTriageDueDate] = useState(''); + const [mobileVoiceActive, setMobileVoiceActive] = useState(false); + const [isVoiceCaptured, setIsVoiceCaptured] = useState(false); + const mobileRecognitionRef = useRef(null); + + const toggleMobileVoice = () => { + const SpeechRecognition = + (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + if (!SpeechRecognition) { + alert('Spracheingabe wird von diesem Browser leider nicht unterstützt.'); + return; + } + if (mobileVoiceActive && mobileRecognitionRef.current) { + mobileRecognitionRef.current.stop(); + setMobileVoiceActive(false); + return; + } + try { + const recognition = new SpeechRecognition(); + recognition.lang = 'de-DE'; + recognition.continuous = false; + recognition.interimResults = true; + recognition.onstart = () => { + setMobileVoiceActive(true); + triggerHaptic(20); + }; + recognition.onresult = (event: any) => { + let transcript = ''; + for (let i = 0; i < event.results.length; i++) { + transcript += event.results[i][0].transcript; + } + setMobileTriageContent((prev) => { + const base = prev.replace(/\s*\[…\]$/, '').trim(); + return base ? `${base} ${transcript}` : transcript; + }); + setIsVoiceCaptured(true); + }; + recognition.onerror = () => setMobileVoiceActive(false); + recognition.onend = () => setMobileVoiceActive(false); + mobileRecognitionRef.current = recognition; + recognition.start(); + } catch { + setMobileVoiceActive(false); + } + }; + + const handleMobileCreateTriage = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = mobileTriageContent.trim(); + if (!trimmed) return; + try { + const thought = await api.captureThought( + trimmed, + mobileTriagePriority, + isVoiceCaptured ? 'voice' : 'manual', + mobileTriageDueDate, + { target: 'inbox' } + ); + handleCaptured(thought, 'inbox'); + setMobileTriageContent(''); + setMobileTriagePriority('normal'); + setMobileTriageDueDate(''); + setIsVoiceCaptured(false); + setShowMobileCapture(false); + triggerHaptic(20); + } catch (err: any) { + alert(`Fehler beim Speichern: ${err.message || 'Unbekannter Fehler'}`); + } + }; + + const openMobileTriageCapture = () => { + setMobileTriageContent(''); + setMobileTriagePriority('normal'); + setMobileTriageDueDate(''); + setIsVoiceCaptured(false); + setMobileVoiceActive(false); + setShowMobileCapture(true); + }; + const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups, primaryAction, setPrimaryAction } = useMobileActions(); useEffect(() => { closeMenu(); if (activeTab === 'triage') { - setPrimaryAction(() => setShowMobileCapture(true)); + setPrimaryAction(() => openMobileTriageCapture); setGroups([ { id: 'triage-actions', @@ -148,7 +234,7 @@ export const App: React.FC = () => { label: 'Neuen Gedanken erfassen', icon: , variant: 'accent', - onClick: () => setShowMobileCapture(true), + onClick: () => openMobileTriageCapture(), }, { id: 'refresh-deck', @@ -471,7 +557,7 @@ export const App: React.FC = () => { const handleTriggerAddButton = () => { triggerHaptic(20); if (window.innerWidth < 640) { - setShowMobileCapture(true); + openMobileTriageCapture(); } else { window.scrollTo({ top: 0, behavior: 'smooth' }); setCaptureFocusCount((c) => c + 1); @@ -970,7 +1056,7 @@ export const App: React.FC = () => { if (primaryAction) { primaryAction(); } else { - setShowMobileCapture(true); + openMobileTriageCapture(); } }} className="w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer" @@ -1020,7 +1106,7 @@ export const App: React.FC = () => { - {/* MOBILE QUICK-ADD BOTTOM SHEET / MODAL */} + {/* MOBILE QUICK-ADD BOTTOM SHEET / MODAL (DESIGN-ALIGNED WITH TASKS VIEW) */} {showMobileCapture && (
{ {/* Header */}
-
- {activeTab === 'triage' ? : } +
+
-

- {activeTab === 'triage' ? 'Quick Dump erfassen' : 'Schnell erfassen'} -

+

Quick Dump erfassen

- {/* InstantCaptureBar */} -
- { - handleCaptured(thought, target); - setShowMobileCapture(false); - }} - lists={lists} - autoFocusTrigger={Date.now()} - /> -
+
+ {/* Text Input with Voice Dictation */} +
+ +
+ setMobileTriageContent(e.target.value)} + placeholder="Was geht dir durch den Kopf?…" + className="w-full input-field px-3 py-2 pr-10 text-sm" + /> + +
+
+ + {/* Priority - 4 Buttons (Identical to TasksView) */} +
+ +
+ {(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => { + const config: Record< + PriorityLevel, + { label: string; icon?: React.ReactNode; activeClass: string } + > = { + low: { + label: 'Niedrig', + activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs', + }, + normal: { + label: 'Normal', + activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs', + }, + high: { + label: 'Wichtig', + icon: , + activeClass: + 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs', + }, + urgent: { + label: 'Dringend', + icon: , + activeClass: + 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs', + }, + }; + const active = mobileTriagePriority === p; + const item = config[p]; + return ( + + ); + })} +
+
+ + {/* Due Date (Optional) */} +
+ + setMobileTriageDueDate(e.target.value)} + className="w-full input-field px-3 py-2 text-xs" + /> +
+ + {/* Bottom Buttons */} +
+ + +
+
)} -- 2.54.0 From 3480859d8a05e323d16ebfbd613dfe5984bc08e7 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 13:27:35 +0100 Subject: [PATCH 19/19] docs: add comprehensive technical wiki documentation and flaws analysis; remove legacy BRAIN.md --- BRAIN.md | 317 ----------------------------- Doku/README.md | 133 ++++++++++++ Doku/api/rest.md | 185 +++++++++++++++++ Doku/api/websocket.md | 113 +++++++++++ Doku/architecture.md | 142 +++++++++++++ Doku/database.md | 348 ++++++++++++++++++++++++++++++++ Doku/flaws.md | 136 +++++++++++++ Doku/modules/lists.md | 62 ++++++ Doku/modules/tasks.md | 86 ++++++++ Doku/modules/triage.md | 88 ++++++++ Doku/modules/workspace.md | 82 ++++++++ Doku/sharing-and-permissions.md | 93 +++++++++ Doku/theming.md | 109 ++++++++++ 13 files changed, 1577 insertions(+), 317 deletions(-) delete mode 100644 BRAIN.md create mode 100644 Doku/README.md create mode 100644 Doku/api/rest.md create mode 100644 Doku/api/websocket.md create mode 100644 Doku/architecture.md create mode 100644 Doku/database.md create mode 100644 Doku/flaws.md create mode 100644 Doku/modules/lists.md create mode 100644 Doku/modules/tasks.md create mode 100644 Doku/modules/triage.md create mode 100644 Doku/modules/workspace.md create mode 100644 Doku/sharing-and-permissions.md create mode 100644 Doku/theming.md diff --git a/BRAIN.md b/BRAIN.md deleted file mode 100644 index a899806..0000000 --- a/BRAIN.md +++ /dev/null @@ -1,317 +0,0 @@ -# 🧠 BRAIN.md — MindSift Internal Dev Cheat Sheet & Architecture Log -> **Zweck:** Zentrales, technisch präzises Nachschlagewerk für Entwicklung, Design-System, Datenbank-Architektur, State-Machines, Backend-APIs und aktuellen Umsetzungsstand. Wird bei jeder Änderung aktuell gehalten. - ---- - -## 📋 0. Aktueller Projektplan & Umsetzungs-Status - -### Erledigte Meilensteine -- [x] **CI/CD & Multi-Stage Docker Build (`Dockerfile`, `.gitea/workflows/build-and-publish.yaml`):** - - Alle Frontend-Checks (`tsc --noEmit`, `vite build`) und Backend-Tests (`go test -v ./...`) laufen direkt im Multi-Stage Container-Build ohne doppelte Vorab-Steps. - - Push auf `dev` baut und überschreibt `git.dsit.me/sandrodach/mindsift:dev`. -- [x] **Single-File CSS Theming Engine & Design-System (`frontend/src/themes/`, `theme.ts`, `tailwind.config.js`):** - - Jedes Theme ist als **genau eine einzige, eigenständige CSS-Datei** unter `frontend/src/themes/` definiert. - - Alle Farben, Kontraste, Oberflächen, Rahmen, Statusfarben und Rundungen sind in 6 klaren Gruppen organisiert. - - 4 vorkonfigurierte Themes (Dark, Light, High Contrast, Soft) plus saubere `theme-template.css`-Vorlage für eigene Themes. - - Vollständige Trennung von Struktur und Styling: React-Komponenten enthalten keine hardcodierten Farbwerte mehr, sondern ausschließlich semantische Klassen und CSS-Variablen (`text-main`, `text-muted`, `bg-canvas`, `panel-surface`, `checkbox-square`, etc.). - - Schnellauswahl im Profil-Dropdown im Header sowie visueller Theme-Manager in den Benutzereinstellungen. -- [x] **Monochromes, eckiges Desktop-Design & Zentrale CSS-Steuerung:** - - Zentrale CSS-Variablen (`--bg-canvas`, `--bg-panel`, `--accent`, `--border-color`, `--radius-card`, `--radius-btn`, `--radius-input`) in den Theme-Dateien. - - Eckige Tabs, eckige Panels/Karten (`0px` Radius im Dark/Monochrome-Theme), nur die Schnell-Eingabe-Blase (`InstantCaptureBar`) oben im Header nutzt `--radius-pill`. - - Untere doppelte Navigationsleiste entfernt; Schnell-Notiz-Blase sitzt ganz oben im Header über den Tabs. - - Workspace links als eckiger, monochromer Datei-/Ordner-Baum (File Explorer Tree mit Unterordnern). -- [x] **Quick Dump Notes + Triage Swipen + Direkt-Zuordnung (`InstantCaptureBar.tsx`, `TriageDeck.tsx`, `server.go`):** - - **Quick Dump (Standard):** Text oben eingeben + `Enter` wirft den Gedanken sofort als Quick Dump (`status: 'inbox'`) ins Triage-Deck. - - **Direkt-Zuordnung bei der Eingabe:** Über das Auswahl-Menü (`Quick Dump ▾`) in der Eingabeleiste kann ein Eintrag alternativ direkt als **Aufgabe** (`target: 'task'`), direkt auf eine **Geteilte Liste** (`target: 'list_item'`) oder direkt als **Workspace-Notiz** (`target: 'document'`) gespeichert werden. - - **Triage-Entscheidung & Warteschlange:** Im Triage-Deck per Swipe/Klick **Wegwerfen** (`←` / `H`), **Später** (`Leertaste` – ans Ende schieben), **Auf Liste**, **Als Notiz** (`D`), **Mit Termin** (`T`) oder **Als Aufgabe** (`→` / `L`). Inklusive Inline-Textbearbeitung und Warteschlangen-Liste unter der aktiven Karte. -- [x] **Mehrere Aufgabenlisten & Kanban-Board (`TasksView.tsx`, `models.go`, `sqlite.go`):** - - Beliebig viele Aufgabenlisten (z.B. Privat, Arbeit, Homelab) mit eigenem Farb- und Icon-Code. - - Teilen von Aufgabenlisten mit Teams/Spaces. - - Umschalter zwischen kompakter Listenansicht und 3-Spalten-Kanban-Board (`Zu erledigen`, `In Arbeit`, `Erledigt`) mit 1-Klick-Statuswechsel, Fälligkeitsdaten und Notizfeldern. -- [x] **Ersteinrichtung (First-Boot Setup Wizard), Echter Login & Einladungscodes (`SetupAndAuthView.tsx`, `server.go`, `sqlite.go`):** - - Kein hartcodierter Auto-Seed-Benutzer (`admin@mindsift.local` / `"Sandro"`) und kein automatischer `quickStart`-Login mehr beim Seitenaufruf. - - Beim ersten Start (`0` Benutzer in der Datenbank) erkennt `GET /api/v1/auth/status` automatisch `needsSetup: true` und zeigt den **Ersteinrichtungs-Assistenten** (`POST /api/v1/auth/setup`) zum Anlegen des ersten Server-Admin-Kontos und der Instanz-Grundeinstellungen. - - Öffentliche Selbstregistrierung ist standardmäßig deaktiviert (`allowRegistration: false`). Neue Benutzer können nur über einen **Einladungscode** (`MS-XXXXXXXX`, Tabelle `user_invites`) oder direkt durch einen Admin (`admin`) angelegt werden. -- [x] **Profile-Dropdown & Vollbild-Einstellungsseiten (`App.tsx`, `UserSettingsView.tsx`, `GroupsView.tsx`, `AdminSettingsView.tsx`):** - - Klick auf den Benutzer oben rechts öffnet ein eckiges, monochromes Dropdown-Menü: - - **Kontoeinstellungen** (`UserSettingsView.tsx` — Vollbild-Ansicht für Profil & Passwort, Darstellung & Themes, Multi-Channel-Alarme, Private Webhooks & Daten-Export). - - **Gruppen** (`GroupsView.tsx` — für alle Benutzer; Self-Service Erstellung und Verwaltung von Gruppen, Mitgliedern und Rollen). - - **Administration** (`AdminSettingsView.tsx` — nur sichtbar für `admin`; Vollbild-Ansicht für Benutzer, Einladungscodes, Server-Isolation und SMTP-Mailserver). - - **Abmelden** (bereinigt JWT-Token und führt zurück zum Login-Bildschirm). - - Klares Rollensystem: Global `admin` (Server-Admin) und `member` (Standard-Benutzer). Innerhalb von Gruppen: `admin` (Gruppen-Admin) und `member` (Gruppen-Mitglied). - - Layout-Fix im Benutzer-Erstellungsformular (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3` mit `min-w-0`), sodass das Rollen-Dropdown niemals über den Container hinausragt. -- [x] **Multi-Channel-Alarme: E-Mail (SMTP), Discord & Eigener Webhook (`UserSettingsView.tsx`, `AdminSettingsView.tsx`, `server.go`):** - - Tab heißt klar **Alarme** mit frei kombinierbaren Zielen (`alarmChannels`: `'email'`, `'discord'`, `'webhook'`). - - SMTP-Konfiguration (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS`) sowohl über `docker-compose.yml` Environment-Variablen als auch direkt in der Admin-Oberfläche (`system_settings` in SQLite) einstellbar, inklusive Live-Testversand. -- [x] **Crash-Fix für „Aufgaben" & „Organisationen & Teams" (`sqlite.go`, `api.ts`, `TasksView.tsx`):** - - Alle Go-Storage-Methoden initialisieren Slices strikt mit `make([]T, 0)` statt `var s []T`, damit `json.Marshal` niemals `null` statt `[]` liefert. - - Zusätzliche `ensureArray()`-Normalisierung in `frontend/src/lib/api.ts` sowie `ViewErrorBoundary` in `App.tsx`. -- [x] **Workspace Markdown-Wiki mit Notion-/Word-Toolbar & Disk-Sync (`WorkspaceView.tsx`, `sqlite.go`):** - - Word-/Notion-artige Formatierungsleiste oben (Fett, Kursiv, Durchgestrichen, Code, Links, Überschriften H1-H3, Checklisten, Aufzählungen, Callouts, Markdown-Tabellen, Fortschrittsbalken). - - Interne Wiki-Links (`[[Dokumenttitel]]`) mit Klick-Navigation und Auto-Erkennung. - - Physische Speicherung auf Festplatte unter `/data/workspace//...` als Standard-`.md`-Dateien zur einfachen Daten-Extraktion, synchronisiert mit SQLite. -- [x] **Workspace Logseq Outliner-Engine, Zoom-Hoisting & Drag & Drop (`WorkspaceView.tsx`, `models.go`, `sqlite.go`):** - - Bullet-Point-basiertes hierarchisches Notizensystem inspiriert von Logseq. - - Jeder Block ist ein Bullet-Point (`•`) mit flexibler Einrückungstiefe (`Level: 0, 1, 2...`). - - Schnelle Tastaturnavigation: - - `Enter`: Neuer Bullet-Point auf gleicher Ebene. - - `Tab`: Block dem vorherigen unterordnen (einrücken). - - `Shift+Tab`: Block ausrücken. - - `Shift+Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks. - - `Backspace`: Auf leerem Block rückt aus oder entfernt den Bullet-Point. - - **Zoom-in Hoisting:** Klick auf den Bullet-Dot zoomt tief in diesen Ast hinein (Fokusmodus) mit Breadcrumbs-Navigation (zurückspringen per Klick auf die grauen Breadcrumbs). - - **Drag & Drop:** Bullet-Dots dienen als Anfasser zum Verschieben und Umsortieren von Blöcken und ganzen Unterbäumen. - - **Slash-Commands (`/`):** Schnelle Umwandlung in `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/table`, `/callout`, `/progress`, `/divider`. - - Physische Speicherung auf Festplatte unter `/data/workspace//...` als Standard-`.md`-Dateien mit portabler 2-Leerzeichen-Einrückung (` - [ ] ` bzw. ` - `), vollständig kompatibel mit externen Outlinern und Markdown-Viewern. -- [x] **Zentrale Hilfeseite & Dokumentation (`HelpView.tsx`, `App.tsx`):** - - Vollständige, übersichtliche Hilfe- und Tastatur-Dokumentation für Outliner, Triage, Aufgaben, geteilte Listen und Tastatur-Shortcuts. - - Direkt erreichbar über das Benutzer-Profil-Menü („Hilfe & Anleitung") sowie den „Hilfe"-Button im Workspace-Header. -- [x] **Workspace UX-Refinements & Datei-Explorer Drag & Drop (`WorkspaceView.tsx`, `sqlite.go`):** - - **Dateien in Ordner ziehen:** Notizen können im Datei-Explorer per Drag & Drop direkt auf beliebige Ordner oder in das Hauptverzeichnis gezogen werden. Das alte Ordner-Dropdown im Menü wurde entfernt. - - **Angepinnte Notizen (Favoriten):** Dokumente mit `isPinned: true` werden direkt ganz oben im Datei-Explorer in einer eigenen Schnellzugriffs-Sektion dargestellt. - - **Bereinigter Editor:** Störende Tasten-Legende unter den Notizen entfernt; klobiger Rahmen um das Notiz-Icon entfernt; Standard-Icon ist der Zettel (`📄`). - - **Breadcrumbs & Zoom:** Überflüssiger „Zoom aufheben"-Button entfernt; alle Breadcrumbs-Elemente inklusive Dokumententitel erscheinen einheitlich dezent in Grau ohne störende Link-Unterstreichung. - - **Globale To-Dos:** Tab- und Bereichs-Titel auf prägnantes „Globale To-Dos" gekürzt, unnötiger Erklärungstext entfernt. -- [x] **Globaler Workspace To-Do Rollup Dashboard (`WorkspaceView.tsx`):** - - Aggregierte Übersicht aller To-Dos (`- [ ] ` / `- [x] `) aus sämtlichen Notiz-Dateien über alle Ordner hinweg. - - Übersichtliche Gruppierung nach Ordner ➔ Dokument ➔ Aufgaben mit einklappbaren Akkordeons und Zähler für offene Aufgaben. - - **Ordner-Drilldown:** Klick auf einen Ordner isoliert nur diesen Zweig für fokussiertes Abarbeiten, inklusive Zurücksetzen-Filter. - - **Live 2-Wege-Sync:** Abhaken oder Aktivieren eines To-Dos im Rollup synchronisiert direkt in Echtzeit zurück in die SQLite-Datenbank und die physische `.md`-Datei auf Festplatte. -- [x] **Aufgabenlisten-Teilen & Avatar-Stack (`TasksView.tsx`, `models.go`, `sqlite.go`):** - - Aufgabenlisten können direkt mit Teams/Spaces geteilt werden. - - Avatar-Stack (farbige Initialen-Bubbles mit Hover-Tooltips) im Header der Liste und in den Tabs visualisiert, wem eine Liste gehört und mit welchen Teammitgliedern sie geteilt ist. -- [x] **Admin-Schalter für öffentliche Selbstregistrierung (`AdminSettingsView.tsx`):** - - Unintuitiver Button in den Admin-Einstellungen unter Server & Isolation durch einen flüssig animierten Schiebeschalter (`rounded-pill`) mit eindeutigen Status-Beschriftungen ersetzt. -- [x] **Cross-Browser & Performance Fixes (Chrome & Firefox):** - - SQLite Connection-Pool Deadlock durch striktes Schließen von `sql.Rows` vor Unterabfragen behoben. - - `Cache-Control: no-store` auf allen API-Routen und `index.html`. - - Optimistisches Leeren des Capture-Inputs und 12-Sekunden-Fetch-Timeout. - ---- - -## 🎨 1. Single-File CSS Theming Engine & Design-System - -### Philosophie: 1 Datei = 1 komplettes Theme -Anstatt Styling in Dutzenden Tailwind-Klassen über hunderte Komponenten zu verstreuen, steuert **eine einzige CSS-Datei** das komplette Erscheinungsbild der Anwendung. - -Jedes Theme liegt unter `frontend/src/themes/` und gruppiert **6 standardisierte Bereiche**: - -```css -:root[data-theme="dein-theme"] { - /* ========================================================================== - 1. AKZENTFARBE (Fokus, Primäraktionen, Highlights) - ========================================================================== */ - --accent: #2563eb; - --accent-hover: #3b82f6; - --accent-dim: rgba(37, 99, 235, 0.15); - --accent-contrast: #ffffff; - - /* ========================================================================== - 2. OBERFLÄCHEN & HINTERGRÜNDE - ========================================================================== */ - --bg-canvas: #0e1013; - --bg-panel: #16181d; - --bg-panel-elevated: #1f2229; - --bg-input: #121418; - --bg-code: #0b0c0f; - --bg-overlay: rgba(0, 0, 0, 0.75); - - /* ========================================================================== - 3. TEXT & KONTRASTE - ========================================================================== */ - --text-main: #f4f4f5; - --text-muted: #a1a1aa; - --text-faint: #71717a; - --text-on-accent: #ffffff; - - /* ========================================================================== - 4. RAHMEN, LINIEN & SCHATTEN - ========================================================================== */ - --border-color: rgba(255, 255, 255, 0.08); - --border-strong: rgba(255, 255, 255, 0.18); - --shadow-panel: 0 4px 20px -2px rgba(0, 0, 0, 0.45); - - /* ========================================================================== - 5. STATUS- & SIGNALFARBEN - ========================================================================== */ - --status-ok: #22c55e; - --status-err: #ef4444; - --status-warn: #eab308; - --status-info: #3b82f6; - - /* ========================================================================== - 6. RUNDUNGEN / GEOMETRIE - ========================================================================== */ - --radius-card: 0px; - --radius-btn: 2px; - --radius-input: 2px; - --radius-checkbox: 2px; - --radius-pill: 9999px; -} -``` - -### Verfügbare Themes - -| Theme-ID | Datei | Charakter | Standard-Geometrie | -| :--- | :--- | :--- | :--- | -| `dark` | [`theme-dark.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-dark.css) | Standard Monochrom / Dark Slate | Streng eckig (`0px` Cards, `2px` Buttons) | -| `light` | [`theme-light.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalist Light | Modern sauber (`4px` Cards, `4px` Buttons) | -| `high-contrast` | [`theme-high-contrast.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit maximaler Lesbarkeit (AAA Kontrast) | Streng eckig (`0px`, `2px` Rahmen) | -| `soft` | [`theme-soft.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-soft.css) | Soft Rounded Slate mit warmem Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) | -| *Template* | [`theme-template.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-template.css) | Dokumentierte Vorlage zur Erstellung eigener Themes | Voll anpassbar | - -### Semantische Komponenten-Klassen (`frontend/src/index.css`) -- `.panel-surface` / `.panel-elevated`: Automatisch über `--radius-card`, `--bg-panel` und `--border-color` gesteuert. -- `.btn-primary` / `.btn-action`: Aktionen und Buttons mit `--radius-btn`, `--accent` und `--text-on-accent`. -- `.input-field`: Formularfelder mit `--radius-input`, `--bg-input`, `--border-color` und `--text-main`. -- `.checkbox-square`: Kontrollkästchen mit `--radius-checkbox` und `--accent`. -- `.badge-accent`, `.badge-neutral`, `.badge-ok`, `.badge-err`: Pillen und Zähler. -- `.alert-ok`, `.alert-err`: Hinweiskästen mit Signalfarben. -- `.capture-pill`: Die markante Schnell-Eingabeleiste im Header mit `--radius-pill`. - ---- - -## 🗄️ 2. Datenbank-Architektur (SQLite Schema & Tabellen) - -MindSift speichert alle Daten lokal und hochperformant in SQLite (`/data/mindsift.db`). - -### Tabellen-Übersicht - -1. **`users`** - - `id TEXT PRIMARY KEY`, `email TEXT UNIQUE`, `password_hash TEXT`, `display_name TEXT` - - `role TEXT DEFAULT 'member'` (`admin`, `member`) - - `is_active INTEGER DEFAULT 1`, `created_at DATETIME`, `updated_at DATETIME` -2. **`system_settings`** (Instanzweite globale Konfiguration, 1 Row mit `id = 'singleton'`) - - `sharing_mode TEXT DEFAULT 'teams_only'` (`strict_private`, `teams_only`, `open`) - - `allow_registration INTEGER DEFAULT 0` (Öffentliche Registrierung erlaubt oder gesperrt) - - `instance_name TEXT DEFAULT 'MindSift'` - - `smtp_host TEXT`, `smtp_port INTEGER`, `smtp_user TEXT`, `smtp_pass TEXT`, `smtp_from TEXT`, `smtp_tls INTEGER` -3. **`user_invites`** (Einladungscodes für neue Registrierungen) - - `id TEXT PRIMARY KEY`, `code TEXT UNIQUE` (`MS-XXXXXXXX`), `email TEXT` (optional gebunden) - - `role TEXT DEFAULT 'member'` (`admin`, `member`) - - `created_by TEXT`, `used_by TEXT`, `expires_at DATETIME`, `created_at DATETIME` -4. **`organizations` & `organization_teams` & `team_members`** - - Strukturierung von Benutzern in Abteilungen, Familien oder Projekt-Teams. - - Bildet die Basis für den Sichtbarkeits-Modus `teams_only`. -5. **`spaces` & `space_members`** - - Geteilte Kollaborations-Räume mit Einladungscode (`invite_code`), Echtzeit-WebSocket Sync und Rollen (`owner`, `editor`, `viewer`). -6. **`shared_lists` & `list_items`** - - Geteilte Einkaufs-, Pack- und Inventarlisten mit Kategorien, Mengen und Echtzeit-Abhaken. -7. **`tasks` & `task_lists`** - - Persönliche und geteilte Aufgabenlisten mit Kanban-Spalten (`todo`, `in_progress`, `done`), Fälligkeit, Priorität und Alarm-Optionen. -8. **`quick_dumps` (Triage-Deck)** - - Schnelle Einfälle (`status = 'inbox'`, `'discarded'`, `'converted'`), Fälligkeitsdatum und Text-Notiz. -9. **`documents` (Workspace Markdown-Wiki)** - - Notizen und Dokumentationen mit Folder-Pfad (`folder_path`), Tags, Pinned-Status, JSON-Blocks und physischer `.md`-Spiegelung auf Disk. -10. **`user_alarms` & `user_webhooks`** - - Multi-Channel Alarme (Mail, Discord, Webhook) und ausgehende Event-Webhooks. - ---- - -## ⚡ 3. Triage & Quick-Dump State Machine - -```mermaid -stateDiagram-v2 - [*] --> InstantCapture: Gedanke eingetippt - InstantCapture --> QuickDump_Inbox: Enter (Standard) - InstantCapture --> TaskCreated: Ziel = 'task' - InstantCapture --> ListItemCreated: Ziel = 'list_item' - InstantCapture --> DocCreated: Ziel = 'document' - - QuickDump_Inbox --> TriageDeck: Stapel sichten - TriageDeck --> Discarded: Swipe links / H (Wegwerfen) - TriageDeck --> TaskCreated: Swipe rechts / L (Als Aufgabe) - TriageDeck --> TaskCreated: T (Termin-Aufgabe) - TriageDeck --> DocCreated: D (In Notiz umwandeln) - TriageDeck --> ListItemCreated: Auf Liste schieben - TriageDeck --> QuickDump_Inbox: Leertaste (Später - ans Ende) - - Discarded --> [*] - TaskCreated --> [*] - DocCreated --> [*] - ListItemCreated --> [*] -``` - ---- - -## 👥 4. Server-Administration & Isolation Modes - -Der Server-Administrator (`role = 'admin'`) legt unter `AdminSettingsView.tsx` global fest, wie Benutzer miteinander interagieren dürfen: - -1. **`strict_private` (Strikt Privat):** - - Jeder Benutzer arbeitet komplett autark. Keine teamübergreifende Benutzersuche, keine geteilten Spaces. -2. **`teams_only` (Teams isoliert — Empfohlener Standard):** - - Benutzer sehen im Adressbuch und bei geteilten Spaces ausschließlich Personen, mit denen sie mindestens in einem gemeinsamen Team sind. -3. **`open` (Offene Instanz):** - - Alle Benutzer können sich gegenseitig sehen, zu Spaces einladen und gemeinsam an Listen arbeiten. - ---- - -## 📝 5. Workspace: Word/Notion-Toolbar, Wiki-Links & Disk-Sync - -1. **Formatierungs-Werkzeuge:** - - Markdown-Syntax wird in Echtzeit formatiert und visuell aufbereitet. - - Buttons für H1, H2, H3, Fett (`**`), Kursiv (`*`), Durchgestrichen (`~~`), Code-Blöcke (```` ``` ````), Aufzählungen (`- `), Todos (`- [ ] `), Callouts (`> `), Markdown-Tabellen (`| Spalte 1 | Spalte 2 |`) und Fortschrittsbalken (`[progress:65%]`). -2. **Wiki-Links (`[[Dokumenttitel]]`):** - - Interne Querverweise werden als interaktive Links gerendert. - - Ein Klick auf `[[Dokumenttitel]]` durchsucht die Workspace-Dokumente und öffnet das verlinkte Dokument direkt im Editor. -3. **Physische Speicherung auf Festplatte:** - - Jede Notiz wird zusätzlich zur SQLite-Tabelle als reine `.md`-Datei unter `/data/workspace///.md` gespeichert. - - Ermöglicht jederzeitige manuelle Datensicherung, Git-Versionierung oder Backup-Extraktion ohne Datenbank-Abhängigkeit. - ---- - -## 🛡️ 6. Kritische Backend-Regeln (SQLite & HTTP) - -1. **NIEMALS verschachtelte Queries bei offenem `sql.Rows`-Cursor ausführen:** - - In [`internal/storage/sqlite.go`](file:///home/sandro/Dokumente/git/MindSift/internal/storage/sqlite.go) muss jede Schleife `for rows.Next()` zuerst alle Zeilen in ein Slice einlesen und sofort `_ = rows.Close()` aufrufen, **bevor** Folgeabfragen (`GetListItems`, `GetSpaceMembers`, `QueryRow` etc.) ausgeführt werden. -2. **SQLite DSN-Pragmas:** - - Jede Verbindung im `sql.DB`-Pool erhält über den DSN automatisch: - `?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)` - - `MaxOpenConns(8)` und `MaxIdleConns(4)` konfiguriert. -3. **Thread-Safe Default User (`EnsureDefaultUser`):** - - Durch `s.initMu.Lock()` abgesichert, damit parallele Browser-Requests beim ersten Laden niemals in einen `UNIQUE constraint`-Konflikt laufen. -4. **Kein Browser-Caching auf API & `index.html`:** - - `writeJSON` und der SPA-Fallback setzen `Cache-Control: no-store, no-cache, must-revalidate`, damit Chrome und Firefox nach jedem Container-Update sofort den aktuellen Stand zeigen. - ---- - -## 📡 7. WebSocket Realtime Protocol (Geteilte Listen) - -Verbindungs-URL: `ws(s):///api/v1/ws/spaces/:spaceId?token=` - -### Events -- `ITEM_CREATED` (auch ausgelöst, wenn ein Gedanke per Direkt-Zuordnung oder Triage auf eine Liste geschoben wird) -- `ITEM_TOGGLED` -- `ITEM_DELETED` -- `LIST_CREATED` / `LIST_DELETED` / `LIST_CLEARED` -- `USER_PRESENCE` - ---- - -## ⌨️ 8. Tastatur-Shortcuts (Desktop) - -| Taste | Kontext | Aktion | -| :--- | :--- | :--- | -| `C` oder `/` | Überall | Fokus sofort oben in die Quick-Dump-Eingabeleiste | -| `Enter` | Capture-Leiste | Sofort speichern (Quick Dump oder gewähltes Direkt-Ziel) | -| `Esc` | Capture-Leiste | Eingabe leeren & Dropdown schließen | -| `H` oder `←` | Triage-Deck | Aktiven Quick Dump wegwerfen (`discard`) | -| `L` oder `→` | Triage-Deck | Aktiven Quick Dump sofort zur Aufgabe machen (`task`) | -| `T` | Triage-Deck | Datums-Auswahl für Termin-Aufgabe öffnen | -| `D` | Triage-Deck | Aktiven Quick Dump sofort in Workspace-Notiz umwandeln | -| `Leertaste` | Triage-Deck | Karte überspringen („Später" – ans Ende des Stapels schieben) | -| `Enter` | Workspace Outliner | Neuen Bullet-Point auf gleicher Ebene erstellen | -| `Tab` | Workspace Outliner | Bullet-Point einrücken (dem vorherigen unterordnen) | -| `Shift + Tab` | Workspace Outliner | Bullet-Point ausrücken (Ebene verringern) | -| `Shift + Enter` | Workspace Outliner | Weicher Zeilenumbruch innerhalb des aktuellen Blocks | -| `Backspace` | Workspace Outliner | Bei leerem Block: Ebene ausrücken bzw. Bullet löschen | -| `/` | Workspace Outliner | Slash-Command-Menü öffnen (/todo, /h1, /code, etc.) | - ---- - -## 🚀 9. Gitea CI/CD & Docker Workflow - -- **Arbeits-Branch:** `dev` (`origin/dev`) -- **Ziel-Image:** `git.dsit.me/sandrodach/mindsift:dev` -- **Build-Prinzip:** Ein einziger Multi-Stage-Build ([`Dockerfile`](file:///home/sandro/Dokumente/git/MindSift/Dockerfile)), der Frontend-Typecheck, Vite-Build und Go-Unit-Tests (`go test -v ./...` inkl. [`internal/api/server_test.go`](file:///home/sandro/Dokumente/git/MindSift/internal/api/server_test.go)) direkt beim Image-Bau ausführt und bei Erfolg sofort pusht. diff --git a/Doku/README.md b/Doku/README.md new file mode 100644 index 0000000..66cbbe1 --- /dev/null +++ b/Doku/README.md @@ -0,0 +1,133 @@ +# 📚 MindSift Technische Dokumentation & Wiki + +Willkommen in der zentralen technischen Dokumentation von **MindSift**. Dieses Wiki dient als verbindliche, tiefgehende Referenz für Entwickler, DevOps-Ingenieure und Systemadministratoren. Es dokumentiert die Architektur, das Datenmodell, APIs, Echtzeit-Protokolle, Kernmodule sowie bekannte technische Schwachstellen der Plattform. + +--- + +## 🧭 Inhaltsverzeichnis (Wiki-Navigation) + +``` +Doku/ +├── README.md # Einstieg, Inhaltsverzeichnis & Gesamtübersicht (dieses Dokument) +├── architecture.md # Systemarchitektur, Schichtenmodell, Concurrency & Stack +├── database.md # SQLite-Datenbankdesign, Schemas, Indizes & Migrationen +├── api/ +│ ├── rest.md # Vollständige REST-API Referenz aller Endpunkte +│ └── websocket.md # WebSocket-Echtzeitprotokoll, Rooms & Document Locking +├── modules/ +│ ├── triage.md # Modul 1: Triage & Quick-Dump (State Machine & Audio) +│ ├── tasks.md # Modul 2: Aufgaben & Kanban-System (Alarme & Scheduler) +│ ├── workspace.md # Modul 3: Workspace Outliner-Engine, Wiki-Links & Disk-Sync +│ └── lists.md # Modul 4: Geteilte Listen (Live-Kollaboration & D&D) +├── sharing-and-permissions.md # Synchrones Teilen, Rollen, Isolation & Mount-Entkopplung +├── theming.md # Single-File CSS Theme-Engine & Design-System +└── flaws.md # Technische Schwachstellen, Risiken & zukünftige Optimierungen +``` + +### 📑 Kapitel im Detail +1. **[Systemarchitektur & Technologie-Stack](architecture.md)** + Schichtenmodell, Go-Backend, React-Frontend, Concurrency-Handling und Thread-Safety. +2. **[Datenbank-Design & SQLite Schema](database.md)** + Vollständiges relationales Datenmodell, ER-Diagramm, Tabellendefinitionen, Indizes und Migrationsstrategie. +3. **[REST-API Referenz](api/rest.md)** + Spezifikation aller Endpunkte für Authentifizierung, Gedanken, Aufgaben, Dokumente, Listen, Teams und Webhooks. +4. **[WebSocket & Echtzeit-Synchronisation](api/websocket.md)** + Gorilla WebSocket Hub, Rooms, Presence-Tracking, Document Locking Mechanismen und Polling-Fallback. +5. **[Kernmodul: Triage & Quick-Dump](modules/triage.md)** + Instant-Capture-Pipeline, Finite State Machine, Gestensteuerung, Spracheingabe und Webhook-Ingestion. +6. **[Kernmodul: Aufgaben & Kanban-System](modules/tasks.md)** + Multi-Listen-Konzept, 3-Spalten Kanban-Board, Subtasks, Fälligkeiten und Multi-Channel Alarm-Ticker (SMTP, Discord, Webhooks). +7. **[Kernmodul: Workspace & Outliner-Engine](modules/workspace.md)** + Logseq-inspirierte hierarchische Block-Engine, Zoom-Hoisting, Wiki-Links, Disk-Mirroring (`.md`) und Globales To-Do Rollup. +8. **[Kernmodul: Geteilte Listen & Kollaboration](modules/lists.md)** + Listen-Typen, Mengen, Kategorien, Live-Checkoff und Drag & Drop Sortierung. +9. **[Synchrones Teilen & Berechtigungskonzept](sharing-and-permissions.md)** + Rollenmodell (Admin/Member), System-Isolations-Modi (`strict_private`, `teams_only`, `open`), entkoppelte Ordner-Mounts und Notizen-Freigaben. +10. **[Single-File Theming & Design-System](theming.md)** + 1-Datei = 1-Theme Philosophie, die 6 CSS-Variablen-Blöcke, Semantische CSS-Klassen und Geometrie-Steuerung. +11. **[Technische Schwachstellen & Optimierungspotenziale (Flaws)](flaws.md)** + Kritische Analyse von Concurrency-Grenzen, Dual-Storage-Risiken, Undo/Redo, Volltextsuche und Skalierungs-Engpässen. + +--- + +## 🏛️ Systemübersicht (High-Level Architektur) + +```mermaid +graph TB + subgraph Client ["Client Layer (Browser / PWA)"] + UI["React 18 SPA (Vite, TS, Tailwind)"] + WSClient["WebSocket Client (Auto-Reconnect)"] + Cache["IndexedDB / Offline Queue"] + end + + subgraph ReverseProxy ["Reverse Proxy / Gateway"] + Traefik["Traefik / Nginx (HTTPS, WSS, Rate Limits)"] + end + + subgraph Backend ["Backend Layer (Go 1.23 Engine)"] + ChiRouter["Chi Router (REST API)"] + WSHub["Gorilla WebSocket Hub & Lock Manager"] + AlarmTicker["Alarm Scheduler (Background Ticker)"] + DiskSync["Disk Mirror Engine (/data/workspace)"] + end + + subgraph Storage ["Storage Layer"] + SQLite[("SQLite WAL Database (/data/mindsift.db)")] + DiskFiles[("Physische Markdown Files (/data/workspace)")] + end + + subgraph External ["Externe Schnittstellen"] + HomeAssistant["Home Assistant / Webhooks"] + Discord["Discord Webhooks"] + SMTPServer["SMTP Server (E-Mail Alarme)"] + end + + UI -->|HTTPS REST| Traefik + WSClient -->|WSS Realtime| Traefik + Traefik --> ChiRouter + Traefik --> WSHub + ChiRouter --> SQLite + ChiRouter --> DiskSync + DiskSync --> DiskFiles + AlarmTicker --> Discord + AlarmTicker --> SMTPServer + HomeAssistant -->|POST /api/v1/inbox| Traefik + HomeAssistant -->|POST /api/v1/webhooks/ingest| Traefik +``` + +--- + +## 🛠️ Schnellstart für Entwickler + +### Voraussetzungen +- **Go:** 1.22 oder 1.23+ +- **Node.js:** 20+ (LTS) & npm +- **Container-Runtime:** Docker oder Podman + +### Lokale Entwicklung ohne Container +```bash +# 1. Repository klonen +git clone https://git.dsit.me/sandrodach/MindSift.git +cd MindSift + +# 2. Frontend Abhängigkeiten installieren & Dev-Server starten +cd frontend +npm install +npm run dev + +# 3. Backend starten (in neuem Terminal) +cd .. +go run ./cmd/server +``` + +### Container Build & Testlauf (wie in der CI/CD) +```bash +# Backend-Tests ausführen +podman run --rm -v $(pwd):/app:z -w /app golang:1.23 go test -v ./internal/... + +# Frontend Typecheck & Build testen +podman run --rm -v $(pwd):/app:z -w /app/frontend node:20 sh -c "npx tsc --noEmit && npm run build" + +# Komplettes Multi-Stage Image bauen +podman build -t mindsift:dev . +``` diff --git a/Doku/api/rest.md b/Doku/api/rest.md new file mode 100644 index 0000000..14d0845 --- /dev/null +++ b/Doku/api/rest.md @@ -0,0 +1,185 @@ +# 🔌 REST-API Referenz + +Die MindSift REST-API ermöglicht den vollständigen Zugriff auf Gedanken, Aufgaben, Workspace-Notizen, Listen, Benutzerkonten und Smarthome-Automationen. + +--- + +## 🔐 1. Authentifizierung + +MindSift unterstützt zwei Authentifizierungsmethoden: + +1. **JSON Web Token (JWT Bearer):** + Wird beim Login oder Setup erzeugt und im HTTP-Header mitgesendet: + `Authorization: Bearer ` +2. **Persönliche API-Schlüssel (Smarthome / Automatisierung):** + Werden unter *Einstellungen ➔ Private Webhooks* erzeugt (Präfix `mindsift_sec_...`): + - Per HTTP-Header: `X-API-Key: mindsift_sec_xxxxxxxxxxxx` + - Per Authorization-Header: `Authorization: Bearer mindsift_sec_xxxxxxxxxxxx` + - Per Query-Parameter: `?token=mindsift_sec_xxxxxxxxxxxx` + +--- + +## 🚦 2. Status- & Fehler-Codes + +- `200 OK`: Erfolgreiche Abfrage oder Aktualisierung. +- `201 Created`: Ressource erfolgreich angelegt. +- `400 Bad Request`: Ungültiges JSON oder fehlende Pflichtfelder. +- `401 Unauthorized`: Token ungültig, abgelaufen oder fehlend. +- `403 Forbidden`: Unzureichende Berechtigungen (z.B. nur Server-Admin oder nur Lesezugriff). +- `404 Not Found`: Ressource existiert nicht oder gehört einem anderen Benutzer. +- `409 Conflict`: Ressource gesperrt (z.B. Dokument wird bereits von anderem Benutzer bearbeitet). + +Fehlerrückgaben erfolgen im einheitlichen Format: +```json +{ + "error": "Fehlermeldung im Klartext" +} +``` + +--- + +## 📋 3. Endpunkte nach Modulen + +### 3.1 Authentifizierung & System-Status + +| Methode | Pfad | Auth | Beschreibung | +| :--- | :--- | :--- | :--- | +| `GET` | `/api/v1/health` | Öffentlich | Liveness-Check (`{"status":"ok"}`) | +| `GET` | `/api/v1/auth/status` | Öffentlich | Prüft ob Ersteinrichtung nötig ist (`needsSetup: true/false`) | +| `POST` | `/api/v1/auth/setup` | Öffentlich | Führt First-Boot Setup durch (legt ersten Admin an) | +| `POST` | `/api/v1/auth/register` | Öffentlich | Registrierung über Einladungscode (`inviteCode`) | +| `POST` | `/api/v1/auth/login` | Öffentlich | Login via E-Mail oder Benutzername + Passwort | +| `GET` | `/api/v1/auth/me` | JWT | Gibt das aktuelle Benutzerprofil zurück | +| `PATCH` | `/api/v1/auth/settings` | JWT | Aktualisiert Profil, Passwort, Theme oder Alarm-Kanäle | +| `POST` | `/api/v1/auth/alarms/test` | JWT | Sendet Test-Alarm an E-Mail, Discord oder Custom Webhook | + +#### Beispiel: `POST /api/v1/auth/login` +```json +// Request +{ + "login": "sandro", + "password": "geheimesPasswort123" +} + +// Response (200 OK) +{ + "token": "eyJhbGciOiJIUzI1NiIs...", + "user": { + "id": "usr_abc123", + "email": "sandro@example.com", + "username": "sandro", + "displayName": "Sandro", + "role": "admin", + "alarmChannels": ["discord", "webhook"] + } +} +``` + +--- + +### 3.2 Triage & Instant Capture + +| Methode | Pfad | Auth | Beschreibung | +| :--- | :--- | :--- | :--- | +| `POST` | `/api/v1/inbox` | JWT / API-Key | Erfasst einen neuen Quick Dump oder leitet direkt weiter | +| `GET` | `/api/v1/triage/deck` | JWT | Liefert den Stapel aller noch nicht triagierten Gedanken (`status: 'inbox'`) | +| `PATCH` | `/api/v1/triage/{id}` | JWT | Führt Triage-Aktion aus (`keep`, `discard`, `restore`) | + +#### Beispiel: `POST /api/v1/inbox` +Ermöglicht flexible Direkt-Zuordnungen: +```json +// Request (Quick Dump in Triage-Deck) +{ + "content": "Kellerfeuchtigkeit über 68% gestiegen #smarthome #alarm", + "priority": "urgent" +} + +// Request (Direkt als Aufgabe anlegen) +{ + "content": "Server Backup prüfen #devops", + "priority": "high", + "target": "task", + "dueDate": "2026-10-30" +} +``` +*Hinweis:* Wenn `/api/v1/inbox` über einen API-Key aufgerufen wird und kein `source` definiert ist, wird die Quelle automatisch als `"webhook"` markiert! + +--- + +### 3.3 Aufgaben & Kanban-System + +| Methode | Pfad | Auth | Beschreibung | +| :--- | :--- | :--- | :--- | +| `GET` | `/api/v1/tasks` | JWT | Liefert alle Aufgaben des Benutzers (inkl. geteilter Aufgaben) | +| `POST` | `/api/v1/tasks/reorder` | JWT | Speichert Kanban-Status und Reihenfolge (`sort_order`) | +| `GET` | `/api/v1/task-lists` | JWT | Liefert alle Aufgabenlisten (eigene & geteilte) | +| `POST` | `/api/v1/task-lists` | JWT | Erstellt eine neue Aufgabenliste (mit Farbe & Spalten) | +| `PUT` | `/api/v1/task-lists/{id}` | JWT | Ändert Titel, Farbe, Icon oder Kanban-Spalten | +| `DELETE` | `/api/v1/task-lists/{id}` | JWT | Löscht Aufgabenliste | +| `POST` | `/api/v1/task-lists/{id}/shares` | JWT | Teilt Liste mit Team oder Benutzer | + +--- + +### 3.4 Workspace & Dokumente + +| Methode | Pfad | Auth | Beschreibung | +| :--- | :--- | :--- | :--- | +| `GET` | `/api/v1/documents` | JWT | Liefert Liste aller Dokumente des Benutzers (Metadaten) | +| `POST` | `/api/v1/documents` | JWT | Erstellt neues Notiz-Dokument (mit Blocks oder Markdown) | +| `GET` | `/api/v1/documents/{id}` | JWT | Lädt komplettes Dokument inklusive aller Blöcke | +| `PUT` | `/api/v1/documents/{id}` | JWT | Aktualisiert Dokumenttitel, Icon oder Ordnerpfad | +| `PATCH` | `/api/v1/documents/{id}/blocks` | JWT | Granulares Patching von Dokument-Blöcken (Database-First) | +| `DELETE` | `/api/v1/documents/{id}` | JWT | Löscht Dokument (Datenbank & physische Datei) | +| `POST` | `/api/v1/documents/{id}/shares` | JWT | Notiz mit anderem Benutzer teilen (`read` / `write`) | +| `GET` | `/api/v1/documents/{id}/lock` | JWT | Fragt Bearbeitungs-Sperrstatus ab | +| `POST` | `/api/v1/documents/{id}/lock` | JWT | Sperrt Dokument für Bearbeitung (30s Lease) | +| `DELETE` | `/api/v1/documents/{id}/lock` | JWT | Gibt Bearbeitungssperre wieder frei | + +--- + +### 3.5 Workspace Ordner & Geteilte Ordner + +| Methode | Pfad | Auth | Beschreibung | +| :--- | :--- | :--- | :--- | +| `GET` | `/api/v1/folders` | JWT | Liefert alle Ordnerpfade des Benutzers | +| `POST` | `/api/v1/folders` | JWT | Legt neuen (auch leeren) Ordner an | +| `POST` | `/api/v1/folders/move` | JWT | Benennt Ordner um oder verschiebt ihn hierarchisch | +| `DELETE` | `/api/v1/folders` | JWT | Löscht Ordner und verschiebt Notizen | +| `GET` | `/api/v1/workspace/folders/shared` | JWT | Liefert für den Benutzer freigegebene geteilte Ordner | +| `POST` | `/api/v1/workspace/folders/shared` | JWT | Erstellt neuen geteilten Ordner | +| `PUT` | `/api/v1/workspace/folders/shared/{id}/mount` | JWT | Ändert den lokalen Einhängepfad beim Empfänger | +| `POST` | `/api/v1/workspace/folders/shared/{id}/shares` | JWT | Gibt Ordner für Teams oder Benutzer frei | + +--- + +### 3.6 Geteilte Listen (Listen & Einkauf) + +| Methode | Pfad | Auth | Beschreibung | +| :--- | :--- | :--- | :--- | +| `GET` | `/api/v1/lists` | JWT | Liefert alle geteilten Listen | +| `POST` | `/api/v1/lists` | JWT | Erstellt neue geteilte Liste | +| `GET` | `/api/v1/lists/{id}` | JWT | Lädt Liste inklusive aller Einträge | +| `POST` | `/api/v1/lists/{id}/items` | JWT | Fügt Eintrag hinzu | +| `PATCH` | `/api/v1/lists/{id}/items/{itemId}` | JWT | Hakt Eintrag ab (`isCompleted: true/false`) | +| `POST` | `/api/v1/lists/{id}/items/reorder` | JWT | Speichert sortierte Einträge | +| `POST` | `/api/v1/lists/{id}/clear-completed`| JWT | Löscht alle abgehakten Einträge | +| `POST` | `/api/v1/lists/{id}/shares` | JWT | Teilt Liste mit Team oder Benutzer | + +--- + +### 3.7 Smarthome Ingestion & Externe Webhooks + +| Methode | Pfad | Auth | Beschreibung | +| :--- | :--- | :--- | :--- | +| `POST` | `/api/v1/webhooks/ingest` | Token / Key | Empfängt externe Webhook-Payloads von Home Assistant / n8n | + +#### Beispiel: `POST /api/v1/webhooks/ingest?token=mindsift_sec_...` +Unterstützt flexible Payloads (JSON oder Plain Text): +```json +{ + "title": "Waschmaschine Keller fertig", + "priority": "high", + "tags": ["smarthome", "keller"] +} +``` +*Live-Aktualisierung:* Sendet automatisch ein `TASK_CREATED` WebSocket-Event an alle Clients des Benutzers, sodass die neue Karte sofort ohne Neuladen im Triage-Deck erscheint! diff --git a/Doku/api/websocket.md b/Doku/api/websocket.md new file mode 100644 index 0000000..c1f4fb1 --- /dev/null +++ b/Doku/api/websocket.md @@ -0,0 +1,113 @@ +# 📡 WebSocket & Echtzeit-Synchronisation + +MindSift implementiert eine bidirektionale Echtzeit-Synchronisationsschicht auf Basis von WebSockets ([`internal/ws/hub.go`](../../internal/ws/hub.go)). Sie sorgt dafür, dass Änderungen an Aufgaben, Triage-Karten, Notizen und geteilten Einkaufslisten ohne manuelles Neuladen sofort auf allen geöffneten Geräten und bei allen Kollaborationspartnern sichtbar werden. + +--- + +## 🔌 1. Verbindung & Handshake + +### Verbindungs-Endpunkte +``` +wss:///api/v1/ws?token= +wss:///api/v1/ws/lists/?token= +``` + +### Authentifizierung & Upgrade +- Die Authentifizierung erfolgt über das JWT-Token im Query-Parameter `?token=...` oder den `Authorization: Bearer`-Header während des HTTP-Upgrade-Requests. +- Der Server extrahiert die `userID` und registriert die Verbindung im internen `Hub`. +- Der `Upgrader` akzeptiert alle Origins (`CheckOrigin: return true`), um volle Kompatibilität mit Reverse-Proxies (Traefik, Nginx, Caddy, Cloudflare) und Docker-Netzwerken zu gewährleisten. + +--- + +## 🏛️ 2. Hub-Architektur & Rooms + +Der WebSocket-Hub unterteilt Verbindungen in drei Abstraktionsebenen: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ WebSocket Hub │ +├─────────────────┬─────────────────────────────┬─────────────────┤ +│ allClients │ userRooms │ rooms │ +│ (Alle aktiven │ (Pro Benutzer-ID) │ (Pro Liste/ │ +│ Verbindungen) │ Cross-Device Sync für │ Space) │ +│ │ Tasks, Triage & Workspace │ Geteilte Liste │ +└────────┬────────┴──────────────┬──────────────┴────────┬────────┘ + ▼ ▼ ▼ + Presence Stats TASK_CREATED ITEM_TOGGLED + DOC_LOCKED TASK_UPDATED ITEM_CREATED + DOC_UNLOCKED DOC_UPDATED LIST_UPDATED +``` + +1. **`allClients` (Global):** Hält jede aktive Client-Verbindung. Ermöglicht Broadcasts systemweiter Ereignisse (z.B. globale Dokument-Sperrungen). +2. **`userRooms[userID]` (Benutzer-Räume):** Sendet Events an alle Geräte desselben Benutzers (z.B. Desktop und Smartphone). Erzeugt ein Webhook oder Quick Dump auf dem Smartphone einen Eintrag, taucht dieser zeitgleich auf dem Desktop auf. +3. **`rooms[listID]` (Kollaborations-Räume):** Multiplexing für geteilte Listen. Alle Mitglieder einer Liste empfangen Aktionen (wie Abhaken, Löschen, Umsortieren) in Echtzeit. + +--- + +## 📨 3. Nachrichten-Format (Envelope) + +Alle WebSocket-Nachrichten werden als JSON mit einheitlicher Hülle übertragen: + +```json +{ + "event": "ITEM_TOGGLED", + "listId": "lst_12345", + "payload": { + "itemId": "itm_987", + "isCompleted": true, + "completedBy": "usr_sandro", + "completedByName": "Sandro" + }, + "timestamp": "2026-10-26T13:30:00Z" +} +``` + +--- + +## 🔒 4. Dokument-Sperrsystem (Document Locking) + +Um Schreibkonflikte bei Notizen im Workspace zu vermeiden, verwendet MindSift ein **serverseitiges Lease-Locking mit Heartbeat**: + +```mermaid +sequenceDiagram + autonumber + actor UserA as Benutzer A + participant WS as WebSocket Hub + actor UserB as Benutzer B + + UserA->>WS: LOCK_DOC (docId: "doc_123") + WS-->>UserA: Lock bestätigt (30s TTL) + WS->>UserB: DOC_LOCKED (docId: "doc_123", lockedBy: "Benutzer A") + Note over UserB: Editor gesperrt (Schreibschutz aktiv) + + loop Alle 15 Sekunden + UserA->>WS: HEARTBEAT_DOC (docId: "doc_123") + WS-->>UserA: TTL um 30s verlängert + end + + alt Reguläres Schließen + UserA->>WS: UNLOCK_DOC (docId: "doc_123") + WS->>UserB: DOC_UNLOCKED (docId: "doc_123") + Note over UserB: Editor wieder freigegeben + else Verbindungsabbruch / Tab geschlossen + Note over WS: Heartbeat bleibt aus (TTL läuft ab) + WS->>UserB: DOC_UNLOCKED (Automatisch nach Ablauf) + end +``` + +### Locking-Events +- **`LOCK_DOC` (Client ➔ Server):** Fordert eine 30-Sekunden-Sperre für ein Dokument an. +- **`HEARTBEAT_DOC` (Client ➔ Server):** Verlängert die Sperre alle 15 Sekunden, solange der Benutzer das Dokument geöffnet hat. +- **`UNLOCK_DOC` (Client ➔ Server):** Gibt die Sperre beim Verlassen des Dokuments frei. +- **`DOC_LOCKED` (Server ➔ Clients):** Informiert andere Benutzer, dass das Dokument schreibgeschützt ist. +- **`DOC_UNLOCKED` (Server ➔ Clients):** Hebt den Schreibschutz auf. + +--- + +## 🔄 5. Fallback-Strategie & Reconnect + +In strikten Firmennetzwerken oder bei Proxy-Problemen (z.B. fehlende `Upgrade`-Header bei HTTP/1.1) kann eine WebSocket-Verbindung fehlschlagen: + +1. **Exponentielles Backoff:** Das Frontend ([`frontend/src/lib/api.ts`](../../frontend/src/lib/api.ts)) versucht automatisch die Wiederverbindung nach 1s, 2s, 4s, 8s, bis maximal 30s. +2. **HTTP-Polling Fallback:** Geteilte Listen und Notizen aktivieren im Hintergrund ein periodisches Polling (5s Intervall), falls der WebSocket-Status nicht `CONNECTED` ist. +3. **Status-Indikator:** Ein dezenter grüner bzw. gelber Indikator oben rechts im Header zeigt dem Benutzer jederzeit den aktuellen Live-Verbindungsstatus an. diff --git a/Doku/architecture.md b/Doku/architecture.md new file mode 100644 index 0000000..aa1606c --- /dev/null +++ b/Doku/architecture.md @@ -0,0 +1,142 @@ +# 🏛️ Systemarchitektur & Technologie-Stack + +Dieses Dokument beschreibt die interne Architektur von MindSift, die Interaktion zwischen den Systemkomponenten, das Concurrency- und Thread-Safety-Modell sowie die Deployment-Strategie. + +--- + +## 🧩 1. Schichten-Architektur + +MindSift ist als monolithische, hochperformante Single-Binary-Lösung konzipiert. Das Go-Backend liefert sowohl die REST- und WebSocket-Schnittstellen aus als auch die vorkompilierte React Single Page Application (SPA) als eingebettetes statisches Dateisystem (`embed.FS`). + +``` +┌────────────────────────────────────────────────────────┐ +│ Frontend (React 18) │ +│ Vite • TypeScript • Tailwind CSS • Lucide • Motion │ +└───────────────────────────┬────────────────────────────┘ + │ HTTPS REST / WSS WebSocket +┌───────────────────────────▼────────────────────────────┐ +│ Go Chi Routing Layer │ +│ CORS • RealIP • Recoverer • Auth / Role Guard │ +├───────────────────────────┬────────────────────────────┤ +│ Business Engine │ Gorilla WebSocket │ +│ Triage • Tasks • Alarms │ Rooms • Hub • Doc-Locking │ +├───────────────────────────┴────────────────────────────┤ +│ Storage Abstraction │ +│ sqlite.go (modernc.org/sqlite pure Go, WAL-Mode) │ +└───────────────────────────┬────────────────────────────┘ + │ + ┌─────────────┴─────────────┐ + ▼ ▼ + /data/mindsift.db /data/workspace/ + (SQLite Datenbank) (Physische .md Dateien) +``` + +--- + +## 🛠️ 2. Technologie-Stack + +### Backend (Go 1.23) +- **HTTP Routing:** [`github.com/go-chi/chi/v5`](https://github.com/go-chi/chi) – Minimalistischer, idiomatisch schneller Router mit Middleware-Ketten. +- **WebSocket Hub:** [`github.com/gorilla/websocket`](https://github.com/gorilla/websocket) – Verbindungs-Upgrade, Keep-Alive/Ping-Pong und Room-Multiplexing. +- **Datenbank-Treiber:** [`modernc.org/sqlite`](https://gitlab.com/cznic/sqlite) – CGO-freier, rein in Go übersetzter SQLite-Treiber für einfache plattformübergreifende Kompilierung und Containerisierung ohne GCC-Toolchain. +- **Authentifizierung:** [`github.com/golang-jwt/jwt/v5`](https://github.com/golang-jwt/jwt) (HMAC-SHA256 Token) und [`golang.org/x/crypto/bcrypt`](https://pkg.go.dev/golang.org/x/crypto/bcrypt) für Passwort-Hashing. + +### Frontend (React 18 SPA) +- **Build-Tool:** [Vite 5](https://vitejs.dev/) für schnelles Hot-Module-Replacement (HMR) und optimierte Production-Bundles. +- **Sprache:** TypeScript 5 mit strikter Typisierung. +- **Design & Layout:** [Tailwind CSS 3](https://tailwindcss.com/) in Verbindung mit dem MindSift Single-File CSS Theming System. +- **Icons & Animationen:** [Lucide React](https://lucide.dev/) und [Framer Motion](https://www.framer.com/motion/) für Gesten, Karten-Swipes und Übergänge. + +--- + +## 📁 3. Verzeichnisstruktur des Projekts + +``` +MindSift/ +├── cmd/ +│ └── server/ +│ └── main.go # Einstiegspunkt: Initialisierung, Config, Store, Start +├── internal/ +│ ├── api/ +│ │ ├── server.go # HTTP-Routen, Handler-Methoden, Middleware +│ │ └── server_test.go # Integrationstests für alle REST- und Ingest-Routen +│ ├── auth/ +│ │ ├── auth.go # JWT Validierung, Token-Erstellung, Context-Helpers +│ │ └── apikey.go # API-Key Erzeugung (mindsift_sec_...) & SHA-256 Hashing +│ ├── config/ +│ │ └── config.go # Environment-Variablen (PORT, DATA_DIR, JWT_SECRET, SMTP) +│ ├── domain/ +│ │ └── models.go # Go-Structs für Thought, Document, List, User, Team etc. +│ ├── storage/ +│ │ ├── sqlite.go # DB-Schema, CRUD-Operationen, Queries, Migrationen +│ │ └── storage_test.go # Unit-Tests für Speicheroperationen +│ └── ws/ +│ └── hub.go # WebSocket Client, Room-Management, Locking-Mechanismus +├── frontend/ +│ ├── src/ +│ │ ├── components/ # Wiederverwendbare UI-Elemente (Modals, Avatar, Menüs) +│ │ ├── context/ # MobileActionContext (Mobile Bottom Bar State) +│ │ ├── features/ +│ │ │ ├── auth/ # SetupWizard & Login +│ │ │ ├── capture/ # InstantCaptureBar (Schnelleingabe) +│ │ │ ├── help/ # Integrierte Benutzerdokumentation +│ │ │ ├── lists/ # Geteilte Listen & Einkauf +│ │ │ ├── settings/ # Benutzer-, Admin- & Theme-Einstellungen +│ │ │ ├── teams/ # Gruppen & Mitglieder-Management +│ │ │ ├── triage/ # Triage-Deck, Aufgabenliste, Kanban & TaskModal +│ │ │ └── workspace/ # Outliner-Editor, Datei-Explorer, Wiki & Rollup +│ │ ├── lib/ # api.ts (HTTP/WS Client), types.ts, theme.ts +│ │ ├── themes/ # Eigenständige Theme CSS-Dateien (dark, light, etc.) +│ │ ├── App.tsx # Root-Komponente, Navigation, Top/Bottom Bars +│ │ └── main.tsx # React DOM Bootstrap +│ ├── package.json +│ └── vite.config.ts +├── Doku/ # Dieses technische Wiki +├── Dockerfile # Multi-Stage Build Definition +└── compose.yaml # Docker Compose Vorlage für Produktivbetrieb +``` + +--- + +## ⚡ 4. Thread-Safety & Concurrency-Modell + +Da SQLite Datei-basiert arbeitet und Go standardmäßig hochgradig nebenläufig (Multi-Threaded via Goroutines) operiert, sind folgende Schutzmaßnahmen implementiert: + +### 1. SQLite Pragmas & WAL-Modus +Im Storage-Layer ([`internal/storage/sqlite.go`](../internal/storage/sqlite.go)) wird die Datenbankverbindung beim Start zwingend mit folgenden DSN-Pragmas konfiguriert: +```go +dsn := filepath.Join(dataDir, "mindsift.db") + + "?_pragma=busy_timeout(10000)&" + + "_pragma=journal_mode(WAL)&" + + "_pragma=synchronous(NORMAL)&" + + "_pragma=foreign_keys(1)" +``` +- **`journal_mode(WAL)`:** Write-Ahead Logging erlaubt parallele Leser (`SELECT`), während ein Schreiber (`INSERT`/`UPDATE`) aktiv ist. Leser blockieren niemals Schreiber und Schreiber blockieren niemals Leser. +- **`busy_timeout(10000)`:** Sollte ein Schreibvorgang kurzzeitig blockiert sein, wartet SQLite bis zu 10.000 ms (10 Sekunden), bevor ein `SQLITE_BUSY` Fehler geworfen wird. +- **`foreign_keys(1)`:** Erzwingt referenzielle Integrität und automatische Kaskadierungs-Löschungen (`ON DELETE CASCADE`). + +### 2. Connection Pool Begrenzung +SQLite unterstützt nur einen einzigen Schreib-Thread zur gleichen Zeit: +```go +db.SetMaxOpenConns(8) +db.SetMaxIdleConns(4) +db.SetConnMaxLifetime(time.Hour) +``` + +### 3. Cursor-Deadlock-Vermeidung +> **Kritische Backend-Regel:** Niemals verschachtelte Abfragen bei offenem `*sql.Rows`-Cursor ausführen! +In Go hält ein offener Cursor eine Verbindung aus dem Pool besetzt. Führt der Code innerhalb von `for rows.Next()` eine weitere Abfrage auf `s.db` aus, kann der Connection-Pool bei mehreren parallelen Benutzern innerhalb von Millisekunden einfrieren. +*Pattern:* Alle Zeilen werden sofort vollständig in ein Go-Slice gelesen, `rows.Close()` wird ausgeführt, und erst danach werden verknüpfte Entitäten geladen. + +--- + +## 🌐 5. Deployment & Container-Architektur + +MindSift wird als schlanker, autarker Docker-Container ausgeliefert: +1. **Stage 1 (Frontend Build):** `node:20-alpine` kompiliert TypeScript und generiert statische Assets nach `frontend/dist/`. +2. **Stage 2 (Backend Build):** `golang:1.23-alpine` bettet `frontend/dist/` via `//go:embed` ein, führt Unit-Tests aus und kompiliert ein statisches Binary (`mindsift-server`). +3. **Stage 3 (Production Runtime):** `alpine:3.20` mit `ca-certificates` und `tzdata`. Das finale Image ist ca. 45 MB groß und benötigt keine externen Datenbankserver oder Abhängigkeiten. + +Alle persistenten Daten befinden sich in einem einzigen Verzeichnis `/data`: +- `/data/mindsift.db` (SQLite Hauptdatenbank) +- `/data/workspace/` (Physische Markdown-Dateien der Workspace-Notizen) diff --git a/Doku/database.md b/Doku/database.md new file mode 100644 index 0000000..fb49e55 --- /dev/null +++ b/Doku/database.md @@ -0,0 +1,348 @@ +# 🗄️ Datenbank-Design & SQLite Schema + +MindSift verwendet als persistente Datenspeicherung eine lokale SQLite-Datenbank (`/data/mindsift.db`). Dieses Dokument beschreibt das relationale Datenmodell, Tabellenstrukturen, Indizes, Fremdschlüsselbeziehungen und das Migrationskonzept. + +--- + +## 📊 1. Entity-Relationship-Diagramm (ERD) + +```mermaid +erDiagram + users ||--o{ thoughts : "erstellt" + users ||--o{ documents : "besitzt" + users ||--o{ lists : "besitzt" + users ||--o{ task_lists : "besitzt" + users ||--o{ api_keys : "besitzt" + users ||--o{ team_members : "ist Mitglied in" + + teams ||--o{ team_members : "besteht aus" + teams ||--o{ task_list_shares : "erhält Zugriff" + teams ||--o{ list_shares : "erhält Zugriff" + teams ||--o{ doc_team_shares : "erhält Zugriff" + teams ||--o{ workspace_folder_shares : "erhält Zugriff" + + task_lists ||--o{ thoughts : "gruppiert" + task_lists ||--o{ task_list_shares : "wird geteilt mit" + + lists ||--o{ list_items : "enthält" + lists ||--o{ list_shares : "wird geteilt mit" + + documents ||--o{ document_blocks : "besteht aus" + documents ||--o{ doc_shares : "geteilt mit Benutzern" + documents ||--o{ doc_team_shares : "geteilt mit Teams" + documents ||--o{ doc_user_folders : "eigene Platzierung" + documents ||--o{ doc_pins : "Favorit von" + + workspace_shared_folders ||--o{ workspace_folder_shares : "wird geteilt mit" + workspace_shared_folders ||--o{ workspace_folder_mounts : "eingehängt bei" + workspace_shared_folders ||--o{ documents : "enthält" +``` + +--- + +## 📑 2. Detaillierte Tabellen-Spezifikation + +### 2.1 Benutzerverwaltung, System & Sicherheit + +#### `users` +Verwaltet Konten, Rollen, Multi-Channel Alarmziele und Benutzerpräferenzen. +```sql +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, -- Präfix "usr_..." + email TEXT UNIQUE NOT NULL, -- E-Mail-Adresse + username TEXT NOT NULL DEFAULT '', -- Eindeutiger Login-Name + password_hash TEXT NOT NULL, -- bcrypt Hash + display_name TEXT NOT NULL, -- Anzeigename + avatar_url TEXT NOT NULL DEFAULT '', -- Profilbild URL oder leer + role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member' + is_active INTEGER NOT NULL DEFAULT 1, -- 1 = aktiv, 0 = gesperrt + alarm_channels TEXT NOT NULL DEFAULT 'discord', -- Kommagetrennt: 'email', 'discord', 'webhook' + alarm_email TEXT NOT NULL DEFAULT '', -- Ziel-E-Mail für Alarme + discord_webhook_url TEXT NOT NULL DEFAULT '', -- Discord Webhook URL + custom_webhook_url TEXT NOT NULL DEFAULT '', -- HTTP POST JSON Webhook URL + notify_before_minutes INTEGER NOT NULL DEFAULT 30, -- Vorwarnzeit in Minuten + created_at TEXT NOT NULL, -- ISO 8601 String + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +``` + +#### `system_settings` +Globale Instanz-Konfiguration (Key-Value-Tabelle). +```sql +CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, -- 'sharing_mode', 'allow_registration', 'instance_name', 'smtp_...' + value TEXT NOT NULL +); +``` + +#### `user_invites` +Einladungscodes für kontrollierte Benutzer-Registrierungen. +```sql +CREATE TABLE IF NOT EXISTS user_invites ( + id TEXT PRIMARY KEY, -- Präfix "inv_..." + code TEXT UNIQUE NOT NULL, -- Format "MS-XXXXXXXX" + email TEXT DEFAULT '', -- Optional: an bestimmte E-Mail gebunden + role TEXT NOT NULL DEFAULT 'member', -- Rolle des neuen Kontos ('member', 'admin') + created_by TEXT NOT NULL, -- Ersteller User-ID + used_by TEXT DEFAULT '', -- Eingelöste User-ID + expires_at TEXT NOT NULL, -- Gültig bis (ISO 8601) + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_user_invites_code ON user_invites(code); +``` + +#### `api_keys` +Persönliche API-Schlüssel für externe Skripte, Smarthome und Ingestion. +```sql +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, -- Präfix "key_..." + user_id TEXT NOT NULL, + name TEXT NOT NULL, -- Bezeichnung (z.B. "Home Assistant") + key_prefix TEXT NOT NULL, -- Erste 12 Zeichen im Klartext für Anzeige + key_hash TEXT UNIQUE NOT NULL, -- SHA-256 Hash des kompletten Schlüssels + scopes TEXT NOT NULL DEFAULT 'ingest,read', + last_used_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); +``` + +--- + +### 2.2 Gruppen & Organisations-Teams + +#### `organizations` & `teams` & `team_members` +```sql +CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, -- Präfix "org_..." + name TEXT NOT NULL, + owner_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS teams ( + id TEXT PRIMARY KEY, -- Präfix "tm_..." + org_id TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS team_members ( + team_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member' + joined_at TEXT NOT NULL, + PRIMARY KEY (team_id, user_id), + FOREIGN KEY(team_id) REFERENCES teams(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); +``` + +--- + +### 2.3 Gedanken, Triage & Aufgaben + +In MindSift nutzen Quick Dumps (Triage) und Aufgaben dieselbe Basistabelle **`thoughts`**. Ein Quick Dump ist ein Gedanke mit `status = 'inbox'`, der durch Triage in `status = 'kept'` oder `status = 'converted'` übergeht. + +#### `thoughts` +```sql +CREATE TABLE IF NOT EXISTS thoughts ( + id TEXT PRIMARY KEY, -- Präfix "th_..." + user_id TEXT NOT NULL, + content TEXT NOT NULL, -- Textinhalt inkl. Hashtags + tags TEXT NOT NULL DEFAULT '[]', -- JSON Array: ["smarthome", "keller"] + priority TEXT NOT NULL DEFAULT 'normal', -- 'low' | 'normal' | 'high' | 'urgent' + source TEXT NOT NULL DEFAULT 'manual', -- 'manual' | 'voice' | 'webhook' + status TEXT NOT NULL DEFAULT 'inbox', -- 'inbox' | 'kept' | 'discarded' | 'converted' + is_completed INTEGER NOT NULL DEFAULT 0, -- 0 = offen, 1 = erledigt + converted_type TEXT DEFAULT '', -- 'task' | 'document' | 'list_item' + converted_ref_id TEXT DEFAULT '', -- Verknüpfte ID (z.B. doc_id oder list_item_id) + due_date TEXT DEFAULT '', -- Fälligkeitsdatum "YYYY-MM-DD" + task_list_id TEXT NOT NULL DEFAULT '', -- Zugeordnete Aufgabenliste ("" = Standard) + kanban_status TEXT NOT NULL DEFAULT 'todo',-- 'todo' | 'in_progress' | 'done' | column.id + notes TEXT NOT NULL DEFAULT '', -- Zusätzliche Notizen / Beschreibung + subtasks TEXT NOT NULL DEFAULT '[]', -- JSON Array von SubTask: [{id, title, isCompleted}] + alarm_sent INTEGER NOT NULL DEFAULT 0, -- 1 = Alarm wurde bereits versendet + sort_order INTEGER NOT NULL DEFAULT 0, -- Manuelle Reorder-Sortierung + created_at TEXT NOT NULL, + triaged_at TEXT DEFAULT '', + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC); +``` + +#### `task_lists` & `task_list_shares` +```sql +CREATE TABLE IF NOT EXISTS task_lists ( + id TEXT PRIMARY KEY, -- Präfix "tl_..." + user_id TEXT NOT NULL, + title TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '📋', + color TEXT NOT NULL DEFAULT '#3b82f6', + team_id TEXT NOT NULL DEFAULT '', -- Optional: an Team gebunden + columns TEXT NOT NULL DEFAULT '[]', -- JSON Array von Kanban-Spalten + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS task_list_shares ( + id TEXT PRIMARY KEY, + task_list_id TEXT NOT NULL, + target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), + target_id TEXT NOT NULL, -- Team-ID oder User-ID + created_at TEXT NOT NULL, + created_by TEXT NOT NULL, + UNIQUE(task_list_id, target_type, target_id), + FOREIGN KEY(task_list_id) REFERENCES task_lists(id) ON DELETE CASCADE +); +``` + +--- + +### 2.4 Workspace: Dokumente, Blöcke & Geteilte Ordner + +#### `documents` & `document_blocks` (Database-First Outliner) +Dokumente werden als Kopfdatensatz in `documents` gespeichert, während der Inhalt als feingranulare Blöcke in `document_blocks` verwaltet wird. Zusätzlich spiegelt MindSift die Daten synchron als physische `.md`-Datei auf Festplatte. + +```sql +CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, -- Präfix "doc_..." + user_id TEXT NOT NULL, -- Eigentümer + title TEXT NOT NULL, -- Dokumenttitel + icon TEXT NOT NULL DEFAULT '📄', -- Emoji-Icon + content TEXT NOT NULL DEFAULT '', -- Markdown-Volltext (Spiegelung) + blocks_json TEXT NOT NULL DEFAULT '[]', -- Legacy JSON-Cache + tags TEXT NOT NULL DEFAULT '[]', -- JSON Array von Tags + folder_path TEXT NOT NULL DEFAULT '', -- Relativer Ordnerpfad des Eigentümers + shared_folder_id TEXT NOT NULL DEFAULT '', -- ID aus workspace_shared_folders (wenn in geteiltem Ordner) + sub_folder_path TEXT NOT NULL DEFAULT '', -- Relativer Unterordnerpfad innerhalb des geteilten Ordners + is_pinned INTEGER NOT NULL DEFAULT 0, -- Globaler Pin + is_archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_documents_shared_folder ON documents(shared_folder_id); + +CREATE TABLE IF NOT EXISTS document_blocks ( + id TEXT PRIMARY KEY, -- Präfix "blk_..." + doc_id TEXT NOT NULL, + sort_order REAL NOT NULL DEFAULT 0, -- Fließkommazahl für konfliktfreies Einfügen + type TEXT NOT NULL DEFAULT 'bullet', -- 'bullet', 'todo', 'h1', 'h2', 'h3', 'code', 'quote', 'callout', 'divider' + content TEXT NOT NULL DEFAULT '', -- Textinhalt des Blocks + level INTEGER NOT NULL DEFAULT 0, -- Hierarchieebene (0 = Root, 1 = Kind...) + checked INTEGER NOT NULL DEFAULT 0, -- Für Checklisten (0 = offen, 1 = abgehakt) + collapsed INTEGER NOT NULL DEFAULT 0, -- Für eingeklappte Unterbäume + lang TEXT NOT NULL DEFAULT '', -- Programmiersprache für Codeblöcke + updated_at TEXT NOT NULL, + updated_by TEXT NOT NULL DEFAULT '', + FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_document_blocks_doc_order ON document_blocks(doc_id, sort_order); +``` + +#### Entkoppelte Ordner-Freigabe (`workspace_shared_folders` & `workspace_folder_mounts`) +Ermöglicht das Teilen kompletter Ordnerstrukturen. Verschiebt ein Empfänger den geteilten Ordner in seiner Hierarchie, wird **nur sein Mount-Pfad** aktualisiert – die Quellstruktur beim Eigentümer und anderen Empfängern bleibt unberührt! + +```sql +CREATE TABLE IF NOT EXISTS workspace_shared_folders ( + id TEXT PRIMARY KEY, -- Präfix "wsf_..." + owner_id TEXT NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workspace_folder_shares ( + id TEXT PRIMARY KEY, + folder_id TEXT NOT NULL, + target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), + target_id TEXT NOT NULL, + permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(folder_id, target_type, target_id) +); + +CREATE TABLE IF NOT EXISTS workspace_folder_mounts ( + user_id TEXT NOT NULL, + folder_id TEXT NOT NULL, + mount_path TEXT NOT NULL, -- Eigener lokaler Pfad des Benutzers (z.B. "Arbeit/Projekte/Team-Docs") + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(user_id, folder_id) +); + +CREATE TABLE IF NOT EXISTS doc_user_folders ( + user_id TEXT NOT NULL, + doc_id TEXT NOT NULL, + folder_path TEXT NOT NULL DEFAULT '', -- Individuelle Ordnerplatzierung für geteilte Einzelnotizen + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(user_id, doc_id) +); +``` + +--- + +### 2.5 Geteilte Listen & Listen-Einträge + +#### `lists`, `list_items` & `list_shares` +```sql +CREATE TABLE IF NOT EXISTS lists ( + id TEXT PRIMARY KEY, -- Präfix "lst_..." + user_id TEXT NOT NULL, + title TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '🛒', + list_type TEXT NOT NULL DEFAULT 'shopping',-- 'shopping', 'packing', 'inventory', 'todo' + updated_at TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_lists_user ON lists(user_id, updated_at DESC); + +CREATE TABLE IF NOT EXISTS list_shares ( + id TEXT PRIMARY KEY, + list_id TEXT NOT NULL, + target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), + target_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(list_id, target_type, target_id), + FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS list_items ( + id TEXT PRIMARY KEY, -- Präfix "itm_..." + list_id TEXT NOT NULL, + content TEXT NOT NULL, -- Bezeichnung des Eintrags + category TEXT NOT NULL DEFAULT '', -- Regal/Kategorie (z.B. "Kühlregal") + quantity TEXT NOT NULL DEFAULT '', -- Menge (z.B. "2x", "500g") + is_completed INTEGER NOT NULL DEFAULT 0, -- 1 = abgehakt + completed_by TEXT NOT NULL DEFAULT '', -- User-ID des Abhakers + completed_by_name TEXT NOT NULL DEFAULT '',-- Name des Abhakers für Live-Badge + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + completed_at TEXT NOT NULL DEFAULT '', + FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_list_items_sync ON list_items(list_id, is_completed, sort_order ASC); +``` + +--- + +## 🔄 3. Migrationsstrategie + +MindSift setzt auf **idempotente automatische In-Process-Migrationen** beim Serverstart ([`internal/storage/sqlite.go`](../internal/storage/sqlite.go)): +1. **Schema-Bootstrap:** Ausführung des Basis-Schemas via `CREATE TABLE IF NOT EXISTS`. +2. **Column & Index Migration Array:** Ein vordefiniertes Array idempotenter DDL-Befehle (z.B. `ALTER TABLE thoughts ADD COLUMN ...`, `CREATE TABLE IF NOT EXISTS doc_user_folders ...`). +3. **Fehlerbehandlung:** Wenn Spalten bereits existieren (z.B. `"duplicate column name"`), wird der Fehler ignoriert und der Start fortgesetzt. +4. **Vorteil:** Keine manuellen Migrations-Skripte nötig; Container-Updates können jederzeit ohne Downtime oder Vorbereitung über bestehende Datenbanken ausgerollt werden. diff --git a/Doku/flaws.md b/Doku/flaws.md new file mode 100644 index 0000000..c0739a7 --- /dev/null +++ b/Doku/flaws.md @@ -0,0 +1,136 @@ +# Bekannte Schwachstellen, Risiken & Technische Schulden (Flaws) + +Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse aller bekannten Limitierungen, Architektur-Kompromisse, Skalierungsrisiken und UX-Schwachstellen in **MindSift**. Sie dient als verbindlicher Backlog für zukünftige Refactorings und Architektur-Meilensteine. + +--- + +## 📌 Übersicht & Risikomatrix + +| ID | Bereich | Problem / Schwachstelle | Schweregrad | Risiko im Produktivbetrieb | +|---|---|---|---|---| +| **FLAW-01** | Workspace Sync | Kein echtes CRDT/OT – Lock-Heartbeat-Modell mit Last-Write-Wins | **Kritisch** | Zeilenverlust bei gleichzeitigen Bearbeitungen oder Netzschwankungen | +| **FLAW-02** | Storage | Dual-Storage Drift (SQLite `document_blocks` vs. Dateisystem `.md`) | **Hoch** | Externe Änderungen an `.md` werden nicht automatisch in SQLite gespiegelt | +| **FLAW-03** | Netzwerk / WS | Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback | **Mittel** | Verzögerte Synchronisierung (1–3s Polling) in Firmennetzwerken | +| **FLAW-04** | Datenbank / Suche | Volltextsuche via `LIKE '%query%'` statt SQLite FTS5 | **Mittel** | Performance-Einbrüche bei wachsenden Notizbeständen (>10.000 Blöcke) | +| **FLAW-05** | Frontend DOM | Fehlende Virtualisierung im Outliner-Baum | **Mittel** | Frame-Drops und Lags bei Dokumenten mit >500 Blöcken auf Mobilgeräten | +| **FLAW-06** | SQLite Concurrency | Single-Writer-Bottleneck bei hohem parallelem Schreibaufkommen | **Mittel** | `SQLITE_BUSY` Fehler bei vielen gleichzeitigen Team-Nutzern | +| **FLAW-07** | Benachrichtigungen | Fehlende Dead-Letter-Queue für Webhook- & E-Mail-Alarme | **Niedrig** | Verlorene Benachrichtigungen bei temporärem Ausfall von Drittsystemen | +| **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne rotierende Refresh-Tokens & Blacklisting | **Niedrig** | Keine sofortige Sitzungsinvalidierung bei Rechteentzug | +| **FLAW-09** | Mobile UX | Viewport-Verschiebung durch Virtual-Keyboard auf iOS/Android | **Niedrig** | Verdeckte Eingabefelder und springende Bottom-Navigationsleisten | +| **FLAW-10** | Outliner UX | Kein hierarchischer Undo/Redo-Stack für Strukturmutationen | **Mittel** | Versehentliches Löschen/Einrücken von Blöcken nicht per `Ctrl+Z` rückgängig machbar | + +--- + +## 🔍 Detaillierte Analyse & Maßnahmen + +### FLAW-01: Kein echtes CRDT/OT – Lock-Heartbeat & Last-Write-Wins +- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/hub.go`, `frontend/src/lib/components/Outliner.svelte` +- **Ursache:** MindSift nutzt zur Vermeidung von Konflikten eine kooperative Bearbeitungssperre (*Document Lease Lock*, 30 Sekunden TTL mit Heartbeat). Wenn zwei Nutzer dasselbe Dokument geöffnet haben, blockiert der aktive Schreiber das Bearbeiten für andere. Verliert ein Nutzer jedoch kurzzeitig die WebSocket-Verbindung oder tippt während des Übergangs zum Polling-Fallback, werden Änderungen per REST via Last-Write-Wins (`PUT /api/v1/documents/:id`) gespeichert. +- **Folgen:** Wie in früheren Versionen beobachtet, können bei parallelem Schreiben Zeilen überschrieben werden oder Strukturhierarchien (Parent-IDs) verloren gehen. +- **Empfohlene Lösung:** + 1. Langfristige Migration auf ein echtes blockweises CRDT-Framework (wie [Yjs](https://yjs.dev/) oder [Automerge](https://automerge.org/)). + 2. Kurzfristig: Optimistisches Block-Locking (Sperre nur auf Block-Ebene `block_id` statt des gesamten Dokuments) und atomare Versionsprüfung (*Optimistic Concurrency Control* mit `version_seq`). + +--- + +### FLAW-02: Dual-Storage Drift (SQLite vs. Dateisystem `.md`) +- **Betroffene Komponenten:** [`database.md`](./database.md), `internal/storage/sqlite.go`, `internal/storage/markdown_sync.go` +- **Ursache:** MindSift pflegt Notizen primär in SQLite (`documents`, `document_blocks`), exportiert bzw. spiegelt sie jedoch als reine `.md`-Dateien auf das Dateisystem (`data/notes/`), um Exportierbarkeit und lokale Lesbarkeit zu gewährleisten. +- **Problem:** Es existiert kein systemweiter File-Watcher ([fsnotify](https://github.com/fsnotify/fsnotify)). Bearbeitet der Nutzer eine Notizdatei direkt auf dem Host (z. B. via Obsidian, VS Code, Git-Sync oder SSH), bekommt die SQLite-Datenbank dies nicht mit. Beim nächsten Web-Edit überschreibt SQLite die Datei auf der Festplatte. +- **Empfohlene Lösung:** + 1. Integrieren eines Background-File-Watchers mit `fsnotify` für das Datenverzeichnis. + 2. Implementieren einer Hash-Prüfung (`sha256` der Datei vs. DB-Metadaten) vor dem Speichern. Bei Abweichung: Nutzerkonflikt-Dialog anzeigen. + +--- + +### FLAW-03: Proxy-Inkompatibilität bei WebSocket-Upgrades +- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), `frontend/src/lib/stores/websocket.js` +- **Ursache:** In restriktiven Firmennetzwerken oder über bestimmte Reverse-Proxies (Corporate SSL-Termination, Firewalls) schlägt der HTTP-Upgrade-Header `Upgrade: websocket` fehl (`NS_ERROR_CONNECTION_REFUSED` oder `400 Bad Request`). +- **Folgen:** Der Client schaltet in den HTTP-Polling-Fallback um (alle 2 Sekunden). Dadurch: + - Werden Sperren (*Document Locks*) und Online-Statusanzeigen verzögert übertragen. + - Entsteht unnötige Serverlast durch kontinuierliche HTTP-Requests. +- **Empfohlene Lösung:** + 1. Sicherstellen von standardmäßigen Nginx/Traefik-Snippets in der Dokumentation (`proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";`). + 2. Optionale Implementierung von Server-Sent Events (SSE) als unidirektionale Echtzeit-Alternative für Lesevorgänge bei blockiertem WebSocket. + +--- + +### FLAW-04: Volltextsuche via `LIKE '%query%'` +- **Betroffene Komponenten:** [`database.md`](./database.md), `internal/api/search.go`, `internal/api/thoughts.go` +- **Ursache:** Suchabfragen über Gedanken, Notizblöcke und Aufgaben nutzen aktuell einfaches SQL: + ```sql + WHERE content LIKE '%' || ? || '%' + ``` +- **Folgen:** `LIKE '%...'` kann B-Tree-Indizes prinzipbedingt nicht nutzen und erzwingt einen sequentiellen Full Table Scan. Bei Datenbanken mit Zehntausenden Blöcken führt dies zu CPU-Spitzen und trägen Suchreaktionen. +- **Empfohlene Lösung:** + 1. Einrichten einer virtuellen SQLite FTS5-Tabelle (`document_blocks_fts`) mit Tokenizer (`unicode61` oder `trigram`). + 2. Automatische Synchronisierung über SQLite Trigger (`AFTER INSERT`, `AFTER UPDATE`, `AFTER DELETE`). + +--- + +### FLAW-05: Fehlende Virtualisierung im Outliner-Baum +- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/lib/components/Outliner.svelte` +- **Ursache:** Jeder Block im Notizbaum wird als eigenes DOM-Element gerendert. Enthält ein Dokument 500 bis 2000 Blöcke (z. B. lange Projektjournale oder Besprechungsprotokolle), befinden sich tausende verschachtelte `div`- und `contenteditable`-Elemente gleichzeitig im DOM. +- **Folgen:** Auf älteren Smartphones oder bei schnellem Tippen kommt es zu spürbaren Input-Latenzen und Ruckeln beim Scrollen. +- **Empfohlene Lösung:** + 1. Implementierung einer virtuellen Fensterung (Virtual Windowing) für Blöcke außerhalb des Sichtbereichs. + 2. Automatisches Einklappen (*Collapsing*) tiefer Hierarchieebenen beim initialen Laden langer Dokumente. + +--- + +### FLAW-06: SQLite Concurrency & Single-Writer-Bottleneck +- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), [`database.md`](./database.md), `internal/storage/sqlite.go` +- **Ursache:** Obwohl SQLite mit `PRAGMA journal_mode = WAL;` parallele Leser erlaubt, kann zu jedem Zeitpunkt immer nur **ein einziger Thread** schreiben. Bei intensiver gleichzeitiger Nutzung (z. B. 20 Teammitglieder tippen gleichzeitig in Listen und Notizen, während im Hintergrund Webhook-Events einlaufen) können Schreib-Transaktionen aufeinander warten. +- **Folgen:** Risiko von `database is locked (SQLITE_BUSY)`-Fehlern, wenn der Busy-Timeout überschritten wird. +- **Empfohlene Lösung:** + 1. Setzen von `PRAGMA busy_timeout = 5000;` (5 Sekunden Wartezeit vor Fehlerwurf). + 2. Kapselung aller Schreibzugriffe über einen dedizierten Go-Channel / Single-Worker-Pool oder Migration auf PostgreSQL bei größeren Installationen. + +--- + +### FLAW-07: Fehlende Dead-Letter-Queue für Benachrichtigungen +- **Betroffene Komponenten:** [`modules/tasks.md`](./modules/tasks.md), `internal/scheduler/alarm_ticker.go` +- **Ursache:** Der Alarm-Ticker überprüft minütlich fällige Aufgaben und sendet Webhooks bzw. E-Mails. Schlägt der HTTP-Post an den externen Webhook fehl (z. B. Zielserver vorübergehend offline), wird der Fehler lediglich ins Log geschrieben. +- **Folgen:** Der Alarm wird nicht erneut versucht und die Benachrichtigung geht dauerhaft verloren. +- **Empfohlene Lösung:** + 1. Einführung einer Tabelle `notification_outbox` mit Spalten `retry_count`, `next_retry_at`, `status`. + 2. Exponentieller Backoff (1m, 5m, 15m, 1h) und Status-Markierung (*failed*, *delivered*). + +--- + +### FLAW-08: Statische JWT-Tokens ohne Blacklisting +- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), `internal/api/auth.go` +- **Ursache:** Nach dem Login wird ein JWT ausgestellt, das clientseitig im `localStorage` gespeichert wird. Der Server prüft lediglich die kryptografische Signatur des Tokens, führt aber keine serverseitige Sitzungsliste. +- **Folgen:** Wird einem Benutzer die Berechtigung entzogen oder ändert er sein Passwort, bleibt ein bereits ausgestellter Token bis zu seinem Ablaufdatum (z. B. 7 Tage) gültig. +- **Empfohlene Lösung:** + 1. Einführung einer `revoked_tokens`-Tabelle oder einer `token_version`-Spalte in der `users`-Tabelle. + 2. Implementierung von kurzlebigen Access-Tokens (15 Min.) in Verbindung mit rotierenden Refresh-Tokens in `httpOnly`-Cookies. + +--- + +### FLAW-09: Mobile Viewport-Verschiebung durch Virtual-Keyboard +- **Betroffene Komponenten:** `frontend/src/routes/+layout.svelte`, `frontend/src/lib/components/BottomNav.svelte` +- **Ursache:** Öffnet sich auf iOS Safari oder Android Chrome die Bildschirmtastatur, ändert der Browser die Höhe des sichtbaren Viewports dynamisch. Feste CSS-Attribute wie `height: 100vh` oder `fixed bottom-0` führen dazu, dass Eingabefelder unter die Tastatur rutschen oder die Navigationsleiste den Bildschirm verdeckt. +- **Folgen:** In der mobilen Triage- und Aufgabenansicht muss der Nutzer manuell scrollen, um die Speichern-Buttons zu erreichen. +- **Empfohlene Lösung:** + 1. Nutzung der modernen `window.visualViewport`-API in Svelte. + 2. Dynamisches Setzen einer CSS-Variable `--vvh` (`visualViewport.height`) zur präzisen Anpassung von Modals und Bottom-Sheets. + +--- + +### FLAW-10: Kein hierarchischer Undo/Redo-Stack im Outliner +- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/lib/components/Outliner.svelte` +- **Ursache:** Standardmäßiges Browser-`Ctrl+Z` funktioniert nur innerhalb eines einzelnen aktiven Textfelds (`contenteditable`). Strukturelle Aktionen – wie das Verschieben von Blöcken via Drag & Drop, Einrücken (`Tab`), Ausrücken (`Shift+Tab`) oder das Löschen ganzer Unterbäume – sind im Standard-Browser-Undo-Stack nicht enthalten. +- **Folgen:** Verschiebt ein Nutzer versehentlich einen Block samt 10 Unterpunkten, kann dies nicht mit `Ctrl+Z` rückgängig gemacht werden. +- **Empfohlene Lösung:** + 1. Implementierung eines expliziten In-Memory Command-Stacks im Frontend (Command-Pattern: `execute()` / `undo()`). + 2. Aufzeichnung aller Block-Mutationen (Insert, Indent, Move, Delete) mit Revert-Payloads. + +--- + +## 🧭 Verwandte Dokumente +- 🏗️ [Gesamtarchitektur](architecture.md) +- 💾 [Datenbank-Design & Tabellenschemata](database.md) +- 🔌 [WebSocket & Live-Protokoll](api/websocket.md) +- 🧠 [Workspace-Modul (Outliner)](modules/workspace.md) +- 🛡️ [Rechte & Freigabesystem](sharing-and-permissions.md) diff --git a/Doku/modules/lists.md b/Doku/modules/lists.md new file mode 100644 index 0000000..26c57dd --- /dev/null +++ b/Doku/modules/lists.md @@ -0,0 +1,62 @@ +# 🛒 Kernmodul 4: Geteilte Listen & Kollaboration + +Das Modul für geteilte Listen ([`SharedListsView.tsx`](../../frontend/src/features/lists/SharedListsView.tsx)) ist auf **Echtzeit-Kollaboration im Alltag** ausgelegt (z.B. gemeinsamer Wocheneinkauf im Supermarkt, Packlisten für Reisen, Inventarlisten oder schnelle Checklisten). + +--- + +## 🛍️ 1. Listen-Typen & Kategorien + +Jede Liste besitzt einen Typ, ein Icon und eine Farb-Codierung: + +| Listen-Typ | Icon | Einsatzzweck | Typische Kategorien | +| :--- | :--- | :--- | :--- | +| `shopping` | 🛒 | Supermarkt- & Lebensmitteleinkauf | Obst & Gemüse, Kühlregal, Vorrat, Drogerie | +| `packing` | 🎒 | Urlaubs- & Reise-Packlisten | Kleidung, Elektronik, Dokumente, Kosmetik | +| `inventory` | 📦 | Vorratskammer, Keller, Homelab-Hardware | Regal 1, Gefrierschrank, Server-Rack | +| `todo` | 📝 | Einfache Gruppen-Checklisten | Heute, Später, Ideen | + +### Automatische Kategorisierung & Mengenangaben +- Einträge können mit einer Mengenangabe (z.B. `3x`, `500g`, `1 Kasten`) versehen werden. +- Die intelligente Eingabe erkennt Einheiten automatisch oder trennt Mengen über Eingabefelder. +- Einträge werden übersichtlich nach Kategorien gruppiert. Jede Kategorie kann zur besseren Übersicht eingeklappt werden. + +--- + +## ⚡ 2. Echtzeit-Abhaken (Multi-User Live Sync) + +Wenn mehrere Familienmitglieder oder Teammitglieder gleichzeitig im Supermarkt unterwegs sind, synchronisiert MindSift jeden Schritt in Echtzeit über WebSockets: + +```mermaid +sequenceDiagram + autonumber + actor Alice as Alice (im Markt) + participant WS as WebSocket Hub + actor Bob as Bob (zu Hause) + + Alice->>WS: PATCH /items/123 (isCompleted: true) + WS->>Bob: Event: ITEM_TOGGLED (itemId: "123", completedBy: "Alice") + Note over Bob: Eintrag wird sofort durchgestrichen
Grünes Badge "von Alice" erscheint +``` + +- **Transparenz:** Beim Abhaken speichert MindSift `completed_by_name`. Ein dezentes grünes Pillen-Badge zeigt an, wer den Artikel eingepackt hat. +- **Erledigte Einträge:** Abgehakte Artikel wandern nach unten in einen einklappbaren Bereich „Erledigt". +- **Liste leeren:** Ein Klick auf „Erledigte entfernen" löscht alle abgehakten Einträge (`LIST_CLEARED`) für alle Teilnehmer. + +--- + +## 🖐️ 3. Drag & Drop Umsortierung + +Innerhalb einer Liste können Einträge frei per Drag & Drop sortiert werden: +1. **Innerhalb einer Kategorie:** Ändert die Reihenfolge (`sort_order`). +2. **Kategorie-Übergreifend:** Zieht der Benutzer einen Eintrag in eine andere Kategorie (z.B. von „Kühlregal" nach „Obst & Gemüse"), aktualisiert das Frontend optimistisch die Kategorie und sendet das Update ans Backend. + +--- + +## 👥 4. Freigaben & Berechtigungen + +Listen können über den einheitlichen Freigabe-Dialog ([`UnifiedShareModal.tsx`](../../frontend/src/components/UnifiedShareModal.tsx)) geteilt werden: +- **Freigabe für Teams:** Alle aktuellen und zukünftigen Mitglieder der gewählten Gruppe erhalten automatisch Zugriff. +- **Freigabe für Einzelbenutzer:** Gezielter Zugriff für einzelne Personen. +- **Rollen:** + - `editor`: Darf Einträge hinzufügen, bearbeiten, abhaken und löschen. + - `viewer`: Nur Lesezugriff (Einträge ansehen, aber nicht verändern). diff --git a/Doku/modules/tasks.md b/Doku/modules/tasks.md new file mode 100644 index 0000000..90ec80f --- /dev/null +++ b/Doku/modules/tasks.md @@ -0,0 +1,86 @@ +# 📋 Kernmodul 2: Aufgaben & Kanban-System + +Das Aufgaben-Modul ([`TasksView.tsx`](../../frontend/src/features/triage/TasksView.tsx)) bietet eine flexible Aufgabenverwaltung mit frei konfigurierbaren Listen, Kanban-Boards, Subtask-Checklisten und einem automatisierten Multi-Channel Alarm-System. + +--- + +## 🗂️ 1. Datenmodell & Listen-Hierarchie + +Aufgaben basieren technisch auf der Tabelle **`thoughts`** in SQLite. Ein Eintrag gilt als Aufgabe, wenn `status = 'kept'` oder `(status = 'converted' AND converted_type = 'task')` zutrifft. + +``` +Benutzer (users) + └── Aufgabenlisten (task_lists) + ├── Eigene Listen (z.B. "Privat", "Homelab") + └── Mit Teams geteilte Listen (task_list_shares) + └── Aufgaben (thoughts) + ├── Kanban-Status (todo, in_progress, done) + ├── Priorität (low, normal, high, urgent) + ├── Fälligkeitsdatum (dueDate) + ├── Notizen (notes) + └── Subtasks (JSON Array von Teilaufgaben) +``` + +### Listen-Sharing & Avatar-Stack +Aufgabenlisten können für Gruppen/Teams freigegeben werden. Ein Avatar-Stack im Listen-Header und in der Tab-Leiste visualisiert alle Mitglieder, die Zugriff auf die jeweilige Liste haben. + +--- + +## 🖥️ 2. Ansichten: Liste vs. Kanban-Board + +Der Benutzer kann jederzeit nahtlos zwischen zwei Ansichten umschalten: + +### 1. Kompakte Listenansicht +- Optimiert für schnelles Abarbeiten und Tastatursteuerung. +- Zeigt Checkbox, Titel, Prioritäts-Badge, Herkunfts-Pille (`Webhook`, `Diktat`), Fälligkeitsdatum und Subtask-Zähler (`2/5`). +- 1-Klick-Prioritäts-Wechsel: Klick auf ein Badge rotiert durch die Prioritäten. + +### 2. Kanban-Board (Drag & Drop) +- Standardmäßig 3 Spalten: **Zu erledigen** (`todo`), **In Arbeit** (`in_progress`) und **Erledigt** (`done`). +- **Freie Spalten-Konfiguration:** Über das Spalten-Menü können Spalten umbenannt, farblich markiert oder neue Spalten hinzugefügt werden. +- **Drag & Drop:** Aufgaben können per HTML5 Drag & Drop zwischen Spalten verschoben werden. Das Backend speichert die relative Position (`sort_order`) und den neuen `kanban_status` automatisch. + +--- + +## 🎯 3. Prioritäten & Subtasks + +### 4-Stufen-Prioritätensystem +Aufgaben unterstützen vier einheitliche Prioritätsstufen: +- **Niedrig (`low`):** Dezent grau hinterlegt. +- **Normal (`normal`):** Akzentfarbe. +- **Wichtig (`high`):** Gelb hervorgehoben mit Warndreieck (`⚠️ Wichtig`). +- **Dringend (`urgent`):** Rot pulsierend mit Flammen-Icon (`🔥 Dringend`). + +### Subtasks (Teilaufgaben) +Jede Aufgabe kann eine hierarchische Checkliste von Subtasks enthalten: +- Gespeichert als JSON in der Spalte `subtasks`. +- Fortschrittsanzeige im Kartenkopf (z.B. `3/5 erledigt`). +- Einzelnes Abhaken synchronisiert direkt in die Datenbank. + +--- + +## ⏰ 4. Multi-Channel Alarm-Scheduler + +MindSift enthält einen serverseitigen Hintergrund-Ticker in Go ([`internal/api/server.go`](../../internal/api/server.go)), der fällige Aufgaben proaktiv an externe Kanäle meldet. + +```mermaid +flowchart TD + Ticker["Go Background Ticker (alle 30s)"] --> Query["SELECT thoughts WHERE due_date <= now + Vorwarnzeit AND alarm_sent = 0"] + Query --> CheckChannels{"Welche Kanäle sind beim User aktiv?"} + + CheckChannels -->|email| SendSMTP["E-Mail via SMTP versenden"] + CheckChannels -->|discord| SendDiscord["Discord Webhook POST"] + CheckChannels -->|webhook| SendCustom["Custom HTTP POST Webhook"] + + SendSMTP --> MarkSent["UPDATE thoughts SET alarm_sent = 1"] + SendDiscord --> MarkSent + SendCustom --> MarkSent +``` + +### Alarm-Konfiguration +1. **Vorwarnzeit:** Einstellbar in Minuten (z.B. 15, 30, 60 Minuten vor Fälligkeit). +2. **Kanal-Multi-Select:** Benutzer können beliebig viele Kanäle parallel aktivieren: + - **E-Mail:** Versand über den in den Admin-Einstellungen hinterlegten SMTP-Server. + - **Discord:** Benachrichtigung mit formatierter Embed-Karte in einen Discord-Kanal. + - **Eigener Webhook:** Beliebige HTTP POST JSON Endpunkte (z.B. Gotify, Ntfy, Home Assistant). +3. **Schutz vor Doppel-Alarmen:** Sobald ein Alarm abgesetzt wurde, wird `alarm_sent = 1` gesetzt. Wird das Datum der Aufgabe später geändert, setzt der Server `alarm_sent = 0` automatisch zurück. diff --git a/Doku/modules/triage.md b/Doku/modules/triage.md new file mode 100644 index 0000000..8e6a6e5 --- /dev/null +++ b/Doku/modules/triage.md @@ -0,0 +1,88 @@ +# 📥 Kernmodul 1: Triage & Quick-Dump Engine + +Das Triage-Modul von MindSift basiert auf dem *Getting Things Done (GTD)*-Prinzip. Es trennt die **Erfassung von Gedanken** strikt von der **Entscheidung, was damit geschehen soll**. + +--- + +## 💡 1. Das 2-Phasen-Konzept + +1. **Phase 1: Schnelles Erfassen (Instant Dump)** + Ein Gedanke, ein Anruf, eine Idee oder ein Server-Alarm wird ohne Reibung erfasst. Der Benutzer muss in diesem Moment weder ein Projekt auswählen, noch Fälligkeitsdaten eingeben oder Ordner durchsuchen. +2. **Phase 2: Fokussierte Triage (Inbox Zero)** + Zu einem späteren Zeitpunkt (z.B. morgens oder beim Feierabend) sichtet der Benutzer den Stapel im Triage-Deck Karte für Karte und trifft für jeden Eintrag eine eindeutige Entscheidung. + +--- + +## 🔄 2. Triage State Machine + +```mermaid +stateDiagram-v2 + [*] --> Capture: Gedanke erfasst (Desktop / Mobile / Webhook) + + Capture --> Inbox: Standard (status: 'inbox') + Capture --> Task: Direkt als Aufgabe (target: 'task') + Capture --> Document: Direkt als Notiz (target: 'document') + Capture --> ListItem: Direkt auf Liste (target: 'list_item') + + state Inbox { + [*] --> ActiveCard: Oben auf Stapel + ActiveCard --> Skipped: Leertaste / Später (Ans Ende) + Skipped --> ActiveCard + } + + ActiveCard --> Discarded: Swipe links / H (Wegwerfen) + ActiveCard --> Task: Swipe rechts / L (Als Aufgabe) + ActiveCard --> TaskDated: T (Mit Fälligkeitsdatum) + ActiveCard --> Document: D (In Notiz umwandeln) + ActiveCard --> ListItem: Auf Liste schieben + + Discarded --> Trash: status: 'discarded' + Task --> TasksModule: status: 'kept', converted: 'task' + TaskDated --> TasksModule: status: 'kept', converted: 'task', dueDate + Document --> WorkspaceModule: status: 'converted', converted: 'document' + ListItem --> ListsModule: status: 'converted', converted: 'list_item' +``` + +--- + +## 🖐️ 3. Steuerung & Gesten (Mobile vs. Desktop) + +### Desktop-Bedienung (Tastatur-First) +- `C` oder `/`: Fokussiert sofort die Eingabeleiste oben im Header. +- `Enter`: Speichert den Gedanken sofort im Deck. +- `←` oder `H`: Aktive Karte wegwerfen (`discard`). +- `→` oder `L`: Aktive Karte sofort zur Aufgabe machen (`task`). +- `T`: Datumsauswahl für Termin-Aufgabe öffnen. +- `D`: Aktive Karte in eine vollwertige Workspace-Notiz umwandeln. +- `Leertaste`: Karte überspringen und ans Ende des Stapels schieben („Später"). + +### Mobile Bedienung (Touch & Swipe) +- **Mittlerer Plus-Button (`+`):** Öffnet das design-angeglichene Bottom-Sheet mit Spracheingabe, 4 Prioritäts-Buttons (`Niedrig`, `Normal`, `Wichtig`, `Dringend`) und Fälligkeitsdatum. +- **Wischgeste nach links (Swipe Left):** Roter Indikator erscheint, Karte wird verworfen. +- **Wischgeste nach rechts (Swipe Right):** Grüner Indikator erscheint, Karte wird sofort zur Aufgabe. +- **Long-Press (Langes Drücken auf Karte):** Öffnet das Schnell-Aktions-Menü (Auf Liste, Mit Termin, Als Notiz, Später). + +--- + +## 🎙️ 4. Spracheingabe (Speech-to-Text) + +In der Desktop-Schnelleingabeleiste ([`InstantCaptureBar.tsx`](../../frontend/src/features/capture/InstantCaptureBar.tsx)) sowie im mobilen Triage-Modal ([`App.tsx`](../../frontend/src/App.tsx)) ist ein nativer Browser-Sprachassistent integriert: +- Verwendet die standardisierte `SpeechRecognition` bzw. `webkitSpeechRecognition` API. +- Erkennt Sprache auf Deutsch (`lang: 'de-DE'`). +- Live-Transkription: Gesprochener Text wird in Echtzeit in das Textfeld eingefügt. +- Haptisches Feedback: Vibrieren beim Starten und Stoppen der Aufnahme. +- Markiert erfasste Gedanken automatisch mit dem Herkunfts-Attribut `source: 'voice'` (Diktat-Badge). + +--- + +## ⚡ 5. Webhook-Ingestion & Smarthome-Pille + +MindSift kann externe Alarme (Home Assistant, Zigbee2MQTT, n8n, Proxmox, Monitoring) automatisch ins Triage-Deck einspeisen: + +### Herkunftserkennung +1. Wird ein Gedanke über `/api/v1/inbox` mit `X-API-Key` oder über `/api/v1/webhooks/ingest` eingeliefert, setzt das Backend automatisch `source = "webhook"`. +2. Im Frontend visualisiert die Triage-Karte eine dezente Webhook-Pille (` Webhook`). +3. Diese Kennzeichnung bleibt auch erhalten, wenn die Karte später zu einer Aufgabe umgewandelt wird. + +### Live-WebSocket Update +Nach erfolgreichem Ingest sendet der Server sofort ein `TASK_CREATED` Event über den WebSocket-Hub. Das geöffnete Triage-Deck fügt die neue Alarm-Karte sofort oben in den Stapel ein – ohne dass der Benutzer die Seite neu laden muss. diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md new file mode 100644 index 0000000..e433f0a --- /dev/null +++ b/Doku/modules/workspace.md @@ -0,0 +1,82 @@ +# 📝 Kernmodul 3: Workspace Outliner-Engine, Wiki-Links & Disk-Sync + +Der MindSift Workspace ([`WorkspaceView.tsx`](../../frontend/src/features/workspace/WorkspaceView.tsx)) kombiniert ein **hierarchisches Bullet-Point Notizsystem (inspiriert von Logseq)** mit einem **persönlichen Markdown-Wiki** und einem **globalen To-Do Rollup Dashboard**. + +--- + +## 🌳 1. Die Logseq-Outliner-Engine + +Im Gegensatz zu klassischen Fließtext-Editoren basiert der MindSift Workspace auf **Blöcken** (`document_blocks` in SQLite). Jeder Block ist ein gedanklicher Knotenpunkt mit einer flexiblen Einrückungsebene (`level: 0, 1, 2, ...`). + +``` +• Projekt MindSift v2.0 (level 0) + • Architektur-Entscheidung (level 1) + - [x] Database-First Blocks implementieren (level 2) + - [ ] Performance Benchmark mit 10.000 Blöcken (level 2) + • Notizen aus dem Dev-Meeting (level 1) + > Wichtig: Keine verschachtelten SQLite Queries ausführen! (level 2) +``` + +### Tastatur-Navigation & Shortcuts +- `Enter`: Erstellt einen neuen Block auf derselben Hierarchieebene. +- `Tab`: Rückt den aktuellen Block ein (wird Kindknoten des vorherigen Blocks). +- `Shift + Tab`: Rückt den Block um eine Ebene aus (verringert die Tiefe). +- `Shift + Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks (z.B. für mehrzeilige Notizen oder Code). +- `Backspace` (auf leerem Block): Rückt den Block aus oder löscht ihn, wenn er auf Level 0 ist. +- `/` (Slash-Commands): Öffnet das Umwandlungsmenü (`/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/quote`, `/divider`). + +### Zoom-In Hoisting (Fokus-Modus) +Klickt der Benutzer auf den Bullet-Dot (`•`) eines Blocks, blendet der Editor alle übergeordneten und benachbarten Blöcke aus und zoomt **ausschließlich in diesen Ast** hinein. +- Eine dezente Breadcrumb-Leiste oben zeigt den Pfad an (z.B. `Workspace / Projekte / MindSift / Architektur`). +- Ein Klick auf ein beliebiges Breadcrumb-Element zoomt sofort auf die jeweilige Ebene zurück. + +### Drag & Drop von Blöcken +Der Bullet-Dot dient gleichzeitig als Drag-Anfasser. Wird ein Block verschoben, erkennt die Engine automatisch alle untergeordneten Kind-Blöcke (`isDescendantBlock`) und verschiebt den **kompletten Unterbaum** an die neue Zielposition. + +--- + +## 🔗 2. Wiki-Links (`[[Link]]`) & Hashtags + +1. **Interne Wiki-Links:** + - Text in doppelten eckigen Klammern (z.B. `[[Reiseplanung 2026]]`) wird automatisch als klickbarer Link gerendert. + - Ein Klick darauf durchsucht die Notizen des Benutzers und öffnet das Ziel-Dokument direkt im Editor. +2. **Hashtags (`#tag`):** + - Hashtags im Fließtext werden automatisch indiziert (`doc_block_tags`-Tabelle). + - In der linken Leiste listet der Tag-Explorer alle verwendeten Schlagworte auf. Klick auf ein Tag filtert alle Dokumente und Blöcke, die dieses Tag enthalten. + +--- + +## 💾 3. Dual-Storage: Database-First & Disk-Mirroring + +MindSift löst das Dilemma zwischen schneller relationaler Abfrage und portabler Dateispeicherung durch ein **synchrones Dual-Storage-Modell**: + +``` +Client (Block-Editor) + │ PATCH /api/v1/documents/:id/blocks + ▼ + Backend Engine + ┌──────────────────────────────────────────────┐ + │ 1. Transaktion in SQLite (document_blocks) │ + │ -> Sort-Order, Content, Level, Checked │ + │ 2. Volltext-Generierung (Markdown-String) │ + │ 3. Schreiben auf Disk (/data/workspace/...) │ + └──────────────────────────────────────────────┘ +``` + +1. **Präzision & Geschwindigkeit (SQLite):** + - Jeder Block besitzt eine eindeutige ID (`blk_...`) und eine Gleitkomma-Reihenfolge (`sort_order REAL`). + - Schnelle Status-Updates (z.B. Checkbox abhaken) schreiben nur genau eine Zeile in die Datenbank. +2. **Datensouveränität & Portabilität (Disk-Mirror):** + - Zeitgleich spiegelt der Server das Dokument als Standard-`.md`-Datei unter `/data/workspace///.md`. + - Einrückungen werden nach dem 2-Leerzeichen-Standard (` - [ ] `) formatiert. + - Administratoren können das Verzeichnis jederzeit mit externen Werkzeugen (Obsidian, Logseq, Git, rsync) sichern. + +--- + +## 📊 4. Globaler To-Do Rollup Dashboard + +Über den Tab **„Globale To-Dos"** bietet der Workspace eine aggregierte Übersicht aller im gesamten Wiki verteilten Aufgaben: +- Scannt in Echtzeit alle unvollendeten `- [ ] ` Checkboxen über alle Ordner und Notizen hinweg. +- Übersichtliche Gruppierung nach **Ordner ➔ Dokument ➔ Aufgabe**. +- **Ordner-Filter:** Klick auf einen Ordner isoliert nur dessen Aufgaben für fokussiertes Arbeiten. +- **2-Wege-Live-Sync:** Wird eine Checkbox im Rollup abgehakt, wird der Block in SQLite sofort auf `checked = 1` aktualisiert und die physische `.md`-Datei synchronisiert. diff --git a/Doku/sharing-and-permissions.md b/Doku/sharing-and-permissions.md new file mode 100644 index 0000000..cb2eb90 --- /dev/null +++ b/Doku/sharing-and-permissions.md @@ -0,0 +1,93 @@ +# 👥 Synchrones Teilen & Berechtigungskonzept + +MindSift bietet ein mehrstufiges Kollaborations- und Freigabesystem. Es gewährleistet strikte Datensouveränität und Privatsphäre, ermöglicht aber gleichzeitig reibungslose Zusammenarbeit in Teams, Familien oder Unternehmen. + +--- + +## 🛡️ 1. Globale Rollen & System-Isolation + +### Globale Rollen +- **Server-Admin (`role = 'admin'`):** Vollzugriff auf System-Einstellungen, E-Mail-Konfiguration (SMTP), Benutzerverwaltung, Einladungscodes und System-Statistiken. +- **Mitglied (`role = 'member'`):** Regulärer Benutzer; arbeitet in seinen eigenen Notizen, Aufgaben und freigegebenen Teams. + +### System-Isolations-Modi (`sharing_mode`) +Der Server-Admin legt global fest, wie Benutzer miteinander interagieren dürfen: + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ System-Isolations-Modi │ +├──────────────────────┬─────────────────────────┬───────────────────────┤ +│ strict_private │ teams_only │ open │ +│ (Strikt Isoliert) │ (Teams & Gruppen) │ (Offene Instanz) │ +│ │ [EMPFOHLENER STANDARD]│ │ +├──────────────────────┼─────────────────────────┼───────────────────────┤ +│ Kein Adressbuch │ Adressbuch zeigt nur │ Alle Benutzer sehen │ +│ Keine Freigaben │ gemeinsame Teammit- │ alle anderen Konten │ +│ Autarke Einzel-Instanz│ glieder │ im System │ +└──────────────────────┴─────────────────────────┴───────────────────────┘ +``` + +1. **`strict_private`:** Vollständige Abschottung. Benutzer wissen nicht voneinander. Keine Freigaben möglich. +2. **`teams_only` (Standard):** Kollaboration ist strikt an Gruppen gebunden. Ein Benutzer kann Objekte nur mit Personen teilen, die mindestens in einer gemeinsamen Gruppe/Team mit ihm sind. +3. **`open`:** Geeignet für Familien oder kleine Firmen. Jeder Benutzer kann für jeden anderen Benutzer Objekte freigeben. + +--- + +## 🏢 2. Gruppen & Teams (`teams` & `team_members`) + +Gruppen strukturieren Benutzer (z.B. „Familie", „Entwicklung", „Marketing"): +- **Gruppen-Admin:** Darf Mitglieder einladen, Rollen ändern oder die Gruppe löschen. +- **Gruppen-Mitglied:** Hat Zugriff auf alle mit der Gruppe geteilten Ressourcen (Ordner, Aufgabenlisten, Einkaufslisten, Notizen). + +Wird eine Ressource für eine Gruppe freigegeben, erhalten **alle aktuellen und zukünftigen Mitglieder** der Gruppe automatisch Zugriff, ohne dass Einzelberechtigungen gepflegt werden müssen. + +--- + +## 📁 3. Entkoppelte Ordner-Freigabe (Decoupled Folder Mounts) + +### Das traditionelle Problem +In herkömmlichen Cloud-Systemen (wie Google Drive oder Nextcloud) führt das Verschieben eines geteilten Ordners oft dazu, dass er auch bei allen anderen Teilnehmern den Platz wechselt. Verschiebt ein Kollege den Ordner in sein Unterverzeichnis „Archiv", verschwindet er beim Ersteller aus dem Sichtfeld. + +### Die MindSift-Lösung: Unabhängige Einhängepunkte (Mounts) +MindSift entkoppelt die physische Ordner-Identität von der persönlichen Ordner-Hierarchie des Benutzers: + +``` +Eigentümer (Alice): + Ordner: "Kundenprojekte" + └── Geteilter Ordner: "Projekt Phoenix" (wsf_123) + ├── spec.md + └── roadmap.md + +Empfänger (Bob): + Ordner: "Meine Aufgaben" + └── "Wichtiges" + └── Einhängepunkt: "Projekt Phoenix" (wsf_123 eingehängt via mount_path) + ├── spec.md + └── roadmap.md +``` + +#### Wie es technisch funktioniert: +1. Der geteilte Ordner existiert als Entität in `workspace_shared_folders`. +2. Beim Eigentümer liegt der Ordner unter seinem Pfad. +3. Jeder Empfänger erhält in `workspace_folder_mounts` seinen **eigenen lokalen `mount_path`**. +4. **Unabhängiges Verschieben:** Schiebt Bob den Ordner an eine andere Stelle in seinem Dateibaum, wird **ausschließlich sein persönlicher `mount_path`** aktualisiert. Alice und andere Teammitglieder bemerken davon nichts. +5. **Inhaltliche Synchronisation:** Verschiebt, erstellt oder bearbeitet jemand eine Notiz *innerhalb* des geteilten Ordners, wird der relative Unterpfad (`sub_folder_path`) synchronisiert, sodass alle dieselbe interne Dokumentstruktur sehen. + +--- + +## 📄 4. Entkoppelte Notizen-Freigabe (`doc_user_folders`) + +Dasselbe Prinzip gilt für einzeln geteilte Dokumente: +- Erhält Benutzer Bob eine einzelne Notiz von Alice freigegeben, kann Bob diese Notiz in jeden beliebigen seiner eigenen Ordner einsortieren. +- Seine persönliche Zuordnung wird in der Tabelle `doc_user_folders` (`user_id`, `doc_id`, `folder_path`) gespeichert. +- Die Original-Notiz von Alice verbleibt an ihrem ursprünglichen Platz. + +--- + +## 📋 5. Geteilte Aufgaben- & Einkaufslisten + +- **Aufgabenlisten (`task_list_shares`):** Freigabe für Teams oder Einzelbenutzer mit Lese- oder Schreibrechten. +- **Geteilte Listen (`list_shares`):** + - Rolle `editor`: Einträge hinzufügen, ändern, abhaken und löschen. + - Rolle `viewer`: Nur Lesezugriff (Echtzeit-Ansicht). +- **Einheitlicher Freigabe-Dialog:** [`UnifiedShareModal.tsx`](../../frontend/src/components/UnifiedShareModal.tsx) bündelt alle Freigabeoptionen (Teams, Benutzer, Berechtigungsstufen) in einer konsistenten Benutzeroberfläche. diff --git a/Doku/theming.md b/Doku/theming.md new file mode 100644 index 0000000..9eb3bdd --- /dev/null +++ b/Doku/theming.md @@ -0,0 +1,109 @@ +# 🎨 Single-File CSS Theming Engine & Design-System + +MindSift implementiert ein radikal einfaches, modulares Theming-System. Anstatt Farb- und Geometriewerte in Dutzenden Tailwind-Klassen über hunderte Komponenten zu verstreuen, wird das komplette Erscheinungsbild der Plattform über **eine einzige CSS-Datei pro Theme** gesteuert. + +--- + +## 💡 1. Philosophie: 1 Datei = 1 komplettes Theme + +Jedes Theme liegt im Verzeichnis [`frontend/src/themes/`](../../frontend/src/themes/) und definiert alle visuellen Eigenschaften über standardisierte CSS-Variablen. + +### Trennung von Struktur und Design +- **React-Komponenten** definieren ausschließlich semantische Klassen (wie `.panel-surface`, `.input-field`, `.btn-primary`, `text-main`, `bg-canvas`). +- **Die aktive Theme-CSS-Datei** bestimmt, welche konkreten Farben, Kontraste, Rahmenbreiten und Ecken-Rundungen diesen Klassen zugewiesen werden. + +--- + +## 🧱 2. Die 6 standardisierten Variablen-Blöcke + +Jede Theme-Datei gliedert sich in sechs exakt definierte Bereiche: + +```css +:root[data-theme="dein-theme"] { + /* ========================================================================== + 1. AKZENTFARBE (Fokus, Primäraktionen, Highlights) + ========================================================================== */ + --accent: #2563eb; /* Primäre Schaltflächen & aktive Tabs */ + --accent-hover: #3b82f6; /* Hover-Zustand */ + --accent-dim: rgba(37, 99, 235, 0.15);/* Transparenter Hintergrund für Badges */ + --accent-contrast: #ffffff; /* Textfarbe auf Akzentflächen */ + + /* ========================================================================== + 2. OBERFLÄCHEN & HINTERGRÜNDE + ========================================================================== */ + --bg-canvas: #0e1013; /* Grundhintergrund der gesamten Seite */ + --bg-panel: #16181d; /* Panels, Karten, Modals & Seitenleisten */ + --bg-panel-elevated: #1f2229; /* Hervorgehobene Elemente & Hover-Panels */ + --bg-input: #121418; /* Hintergrund von Textfeldern */ + --bg-code: #0b0c0f; /* Codeblöcke & Tastatur-Badges */ + --bg-overlay: rgba(0, 0, 0, 0.75); /* Modal-Hintergrund Backdrop */ + + /* ========================================================================== + 3. TEXT & KONTRASTE + ========================================================================== */ + --text-main: #f4f4f5; /* Haupttext, Überschriften, Labels */ + --text-muted: #a1a1aa; /* Sekundärtext, Beschreibungen */ + --text-faint: #71717a; /* Dezentester Text, Tastaturhinweise */ + --text-on-accent: #ffffff; /* Lesbarer Text auf --accent */ + + /* ========================================================================== + 4. RAHMEN, LINIEN & SCHATTEN + ========================================================================== */ + --border-color: rgba(255, 255, 255, 0.08); /* Standard-Trennlinien */ + --border-strong: rgba(255, 255, 255, 0.18);/* Fokussierte Rahmen, Tabs */ + --shadow-panel: 0 4px 20px -2px rgba(0, 0, 0, 0.45); /* Box-Shadows */ + + /* ========================================================================== + 5. STATUS- & SIGNALFARBEN + ========================================================================== */ + --status-ok: #22c55e; /* Erledigt, Online, Aktiv (Grün) */ + --status-err: #ef4444; /* Fehler, Löschen, Dringend (Rot) */ + --status-warn: #eab308; /* Wichtig, Warnung, Gesperrt (Gelb) */ + --status-info: #3b82f6; /* Hinweise, Information (Blau) */ + + /* ========================================================================== + 6. RUNDUNGEN / GEOMETRIE + ========================================================================== */ + --radius-card: 0px; /* Eckenrundung von Panels & Karten */ + --radius-btn: 2px; /* Eckenrundung von Schaltflächen */ + --radius-input: 2px; /* Eckenrundung von Eingabefeldern */ + --radius-checkbox: 2px; /* Checkboxen */ + --radius-pill: 9999px; /* Pillen & Status-Badges */ +} +``` + +--- + +## 🎭 3. Verfügbare Standard-Themes + +| Theme-ID | Datei | Stil & Charakter | Standard-Geometrie | +| :--- | :--- | :--- | :--- | +| `dark` | [`theme-dark.css`](../../frontend/src/themes/theme-dark.css) | Standard Dark Slate / Monochrom | Streng eckig (`0px` Cards, `2px` Buttons) | +| `light` | [`theme-light.css`](../../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) | +| `high-contrast` | [`theme-high-contrast.css`](../../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) | +| `soft` | [`theme-soft.css`](../../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) | +| *Template* | [`theme-template.css`](../../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar | + +--- + +## 🔌 4. Tailwind-Bridge & Semantische Klassen + +In [`frontend/src/index.css`](../../frontend/src/index.css) und `tailwind.config.js` sind die semantischen Utility-Klassen definiert, die sich automatisch an das gewählte Theme anpassen: + +- `.panel-surface`: Wendet `--bg-panel`, `--border-color` und `--radius-card` an. +- `.input-field`: Wendet `--bg-input`, `--border-color`, `--radius-input` und `--text-main` an. +- `.btn-primary`: Akzent-Schaltfläche mit `--accent`, `--accent-hover`, `--text-on-accent` und `--radius-btn`. +- `.btn-action`: Sekundär-Aktion mit dezentem Rahmen und Hover-Zustand. +- `.checkbox-square`: Kontrollkästchen mit `--radius-checkbox`. +- `.capture-pill`: Spezial-Pille für die Desktop-Schnelleingabeleiste mit `--radius-pill`. + +--- + +## 🛠️ 5. Ein eigenes Theme erstellen + +Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") hinzuzufügen: +1. Kopiere `frontend/src/themes/theme-template.css` nach `frontend/src/themes/theme-meintheme.css`. +2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`. +3. Passe Farben und Rundungen nach Belieben an. +4. Importiere die CSS-Datei in [`frontend/src/lib/theme.ts`](../../frontend/src/lib/theme.ts) und trage das Theme im `AVAILABLE_THEMES`-Array ein. +5. Das neue Theme steht sofort in den Kontoeinstellungen und im Profil-Dropdown zur Verfügung! -- 2.54.0