Dev #8

Merged
sandrodach merged 30 commits from dev into main 2026-10-27 22:02:54 +01:00
29 changed files with 3175 additions and 3048 deletions

No files matched your search

+14
View File
@@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS users (
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
token_version INTEGER NOT NULL DEFAULT 1, -- Erhöht bei Passwortwechsel/Rechteentzug zur Token-Invalidierung
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
@@ -111,6 +112,19 @@ CREATE TABLE IF NOT EXISTS api_keys (
);
```
#### `revoked_tokens`
Sperrliste für explizit abgemeldete JWTs (Blacklist bis zum regulären Token-Ablaufdatum).
```sql
CREATE TABLE IF NOT EXISTS revoked_tokens (
token_hash TEXT PRIMARY KEY, -- SHA-256 Hash des Tokens
user_id TEXT NOT NULL, -- Benutzer-ID
expires_at TEXT NOT NULL, -- ISO 8601 Ablaufdatum
revoked_at TEXT NOT NULL -- ISO 8601 Sperrzeitpunkt
);
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at);
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(user_id);
```
---
### 2.2 Gruppen & Organisations-Teams
+10 -8
View File
@@ -15,7 +15,7 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle
| **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-08** | Sicherheit / Auth | Statische JWT-Tokens ohne Blacklisting & Token-Versionierung | **Gelöst (v2.2)** | Behoben durch `token_version`-Spalte in `users`, `revoked_tokens`-Sperrliste und sofortige Sitzungsinvalidierung bei Passwortänderung/Logout/Rechteentzug |
---
@@ -112,13 +112,15 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle
---
### 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-08: [GELÖST / IMPLEMENTIERT] JWT-Sicherheit, Token-Blacklisting & Token-Versionierung
- **Status:** **Gelöst via `token_version`-Spalte in `users`, `revoked_tokens`-Blacklist und serverseitige Invalidierungs-Endpunkte**
- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), [`database.md`](./database.md), `internal/api/server.go`, `internal/auth/auth.go`, `internal/storage/sqlite.go`, `frontend/src/lib/api.ts`
- **Ursache (Historisch):** Nach dem Login wurde ein statisches JWT ausgestellt, das lediglich kryptografisch signiert war. Bei Passwortänderungen, Rechteänderungen oder Konto-Deaktivierung blieben bereits ausgestellte Tokens bis zum Ablaufdatum gültig.
- **Implementierte Lösung:**
1. **`token_version`-Spalte in `users`:** Jedes ausgestellte JWT führt die `tokenVersion` des Benutzers im Claim. Bei einer Passwortänderung (`UpdateUserPassword`), Rechterückstufung oder Deaktivierung wird `token_version = token_version + 1` atomar hochgezählt. Alle alten Tokens auf beliebigen Geräten werden sofort mit `401 Unauthorized` abgewiesen.
2. **Tabelle `revoked_tokens` für Einzelsitzungen:** Beim Klick auf Logout (`POST /api/v1/auth/logout`) wird das spezifische JWT via SHA-256-Hash in die Sperrliste eingetragen, bis sein reguläres Ablaufdatum erreicht ist.
3. **"Alle Sitzungen beenden" (`POST /api/v1/auth/revoke-all`):** Ermöglicht es dem Benutzer in den Einstellungen ("Profil & Sicherheit") oder einem Administrator, mit einem Klick alle aktiven Sitzungen auf sämtlichen Browsern und Mobilgeräten zu terminieren.
4. **Konsistente Prüfung bei WebSockets & SSE:** Auch bei Echtzeit-Verbindungen (`/api/v1/ws`, `/api/v1/ws/docs/:id`, `/api/v1/events`) wird der Benutzerstatus, die Sperrliste und die aktuelle `token_version` überprüft. Outdated oder gesperrte Clients werden sofort abgewiesen.
---
+16 -4
View File
@@ -5,6 +5,7 @@ import (
"embed"
"io/fs"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
@@ -58,9 +59,15 @@ func main() {
srv := api.NewServer(cfg, store, wsHub, staticFS)
serverCtx, serverCancel := context.WithCancel(context.Background())
defer serverCancel()
httpServer := &http.Server{
Addr: ":" + cfg.Port,
Handler: srv.Router(),
Addr: ":" + cfg.Port,
Handler: srv.Router(),
BaseContext: func(l net.Listener) context.Context {
return serverCtx
},
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
@@ -79,7 +86,12 @@ func main() {
<-stop
slog.Info("Shutting down MindSift server gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
serverCancel()
wsHub.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = httpServer.Shutdown(ctx)
if err := httpServer.Shutdown(ctx); err != nil {
slog.Warn("HTTP server shutdown finished with error", "error", err)
}
}
+12 -3
View File
@@ -1,5 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" rx="128" fill="#0b0f19"/>
<circle cx="256" cy="256" r="164" fill="none" stroke="#8ab4f8" stroke-width="36"/>
<path d="M184 266 L236 318 L338 194" fill="none" stroke="#c58af9" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/>
<rect width="512" height="512" rx="128" fill="#8ab4f8"/>
<g transform="translate(80, 80) scale(14.666)" fill="none" stroke="#0b0f19" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/>
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/>
<path d="M3.477 10.896a4 4 0 0 1 .585-.396"/>
<path d="M19.938 10.5a4 4 0 0 1 .585.396"/>
<path d="M6 18a4 4 0 0 1-1.967-.516"/>
<path d="M19.967 17.484A4 4 0 0 1 18 18"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 374 B

After

Width:  |  Height:  |  Size: 851 B

+80 -59
View File
@@ -127,6 +127,8 @@ export const App: React.FC = () => {
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [captureFocusCount, setCaptureFocusCount] = useState(0);
const [refreshRevision, setRefreshRevision] = useState(0);
@@ -143,6 +145,7 @@ export const App: React.FC = () => {
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
const mobileRecognitionRef = useRef<any>(null);
const mobileBaseVoiceRef = useRef('');
const toggleMobileVoice = () => {
const SpeechRecognition =
@@ -157,6 +160,7 @@ export const App: React.FC = () => {
return;
}
try {
mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim();
const recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.continuous = false;
@@ -170,10 +174,8 @@ export const App: React.FC = () => {
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;
});
const base = mobileBaseVoiceRef.current;
setMobileTriageContent(base ? `${base} ${transcript}` : transcript);
setIsVoiceCaptured(true);
};
recognition.onerror = () => setMobileVoiceActive(false);
@@ -249,13 +251,6 @@ export const App: React.FC = () => {
id: 'triage-nav',
title: 'Bereiche wechseln',
items: [
{
id: 'goto-tasks',
label: 'Aufgaben & Kanban',
icon: <CheckSquare className="w-4 h-4" />,
badge: stats.tasksOpenCount,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-workspace',
label: 'Notizen & Workspace',
@@ -263,6 +258,13 @@ export const App: React.FC = () => {
badge: stats.docsCount,
onClick: () => setActiveTab('workspace'),
},
{
id: 'goto-tasks',
label: 'Aufgaben & Kanban',
icon: <CheckSquare className="w-4 h-4" />,
badge: stats.tasksOpenCount,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-lists',
label: 'Einkaufslisten',
@@ -297,18 +299,18 @@ export const App: React.FC = () => {
variant: 'accent',
onClick: () => setActiveTab('triage'),
},
{
id: 'goto-tasks',
label: 'Zu den Aufgaben',
icon: <CheckSquare className="w-4 h-4" />,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-workspace',
label: 'Zum Workspace',
icon: <FileText className="w-4 h-4" />,
onClick: () => setActiveTab('workspace'),
},
{
id: 'goto-tasks',
label: 'Zu den Aufgaben',
icon: <CheckSquare className="w-4 h-4" />,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-lists',
label: 'Zu den Listen',
@@ -549,11 +551,17 @@ export const App: React.FC = () => {
refreshAll();
};
const handleOpenDocument = (docId: string) => {
const handleOpenDocument = (docId: string, blockId?: string) => {
setSelectedDocId(docId);
setSelectedBlockId(blockId || null);
setActiveTab('workspace');
};
const handleOpenTask = useCallback((taskId: string) => {
setSelectedTaskId(taskId);
setActiveTab('tasks');
}, []);
const handleTriggerAddButton = () => {
triggerHaptic(20);
if (window.innerWidth < 640) {
@@ -567,7 +575,11 @@ export const App: React.FC = () => {
const handleLogout = async () => {
triggerHaptic(20);
disconnectWebSocket();
clearToken();
try {
await api.logout();
} catch {
clearToken();
}
setUser(null);
setShowProfileMenu(false);
setActiveTab('triage');
@@ -632,9 +644,12 @@ export const App: React.FC = () => {
<Brain className="w-4 h-4" />
</div>
<div className="flex items-center gap-1.5">
<span className="font-bold text-base text-main tracking-tight">
{authStatus.instanceName || 'MindSift'}
</span>
<div className="flex flex-col">
<span className="font-bold text-base text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</span>
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">v0.1</span>
</div>
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
{activeTabLabelMap[activeTab]}
</span>
@@ -694,9 +709,12 @@ export const App: React.FC = () => {
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
<Brain className="w-4 h-4" />
</div>
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</span>
<div className="flex flex-col">
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</span>
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">v0.1</span>
</div>
</div>
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
@@ -862,24 +880,6 @@ export const App: React.FC = () => {
</span>
</button>
<button
onClick={() => {
triggerHaptic(10);
setActiveTab('tasks');
}}
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
>
<CheckSquare className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
<span>Aufgaben</span>
{stats.tasksOpenCount > 0 && (
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
{stats.tasksOpenCount}
</span>
)}
</span>
</button>
<button
onClick={() => {
triggerHaptic(10);
@@ -898,6 +898,24 @@ export const App: React.FC = () => {
</span>
</button>
<button
onClick={() => {
triggerHaptic(10);
setActiveTab('tasks');
}}
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
>
<CheckSquare className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
<span>Aufgaben</span>
{stats.tasksOpenCount > 0 && (
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
{stats.tasksOpenCount}
</span>
)}
</span>
</button>
<button
onClick={() => {
triggerHaptic(10);
@@ -950,6 +968,7 @@ export const App: React.FC = () => {
onRefreshStats={refreshAll}
onOpenDocument={handleOpenDocument}
refreshTrigger={refreshRevision}
initialSelectedTaskId={selectedTaskId}
/>
)}
@@ -957,7 +976,9 @@ export const App: React.FC = () => {
<WorkspaceView
currentUser={user}
initialDocId={selectedDocId}
initialBlockId={selectedBlockId}
onStatsChange={refreshAll}
onOpenTask={handleOpenTask}
/>
)}
@@ -1024,27 +1045,27 @@ export const App: React.FC = () => {
<span className="text-[10px] leading-tight">Triage</span>
</button>
{/* Col 2: Aufgaben */}
{/* Col 2: Workspace */}
<button
type="button"
onClick={() => {
triggerHaptic(10);
setActiveTab('tasks');
setActiveTab('workspace');
}}
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
activeTab === 'tasks' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
activeTab === 'workspace' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
}`}
title="Aufgaben"
title="Workspace"
>
<div className="relative">
<CheckSquare className="w-5 h-5" />
{stats.tasksOpenCount > 0 && (
<FileText className="w-5 h-5" />
{(stats.docsTodosOpenCount ?? 0) > 0 && (
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
{stats.tasksOpenCount}
{stats.docsTodosOpenCount}
</span>
)}
</div>
<span className="text-[10px] leading-tight">Aufgaben</span>
<span className="text-[10px] leading-tight">Workspace</span>
</button>
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
@@ -1066,27 +1087,27 @@ export const App: React.FC = () => {
</button>
</div>
{/* Col 4: Workspace */}
{/* Col 4: Aufgaben */}
<button
type="button"
onClick={() => {
triggerHaptic(10);
setActiveTab('workspace');
setActiveTab('tasks');
}}
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
activeTab === 'workspace' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
activeTab === 'tasks' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
}`}
title="Workspace"
title="Aufgaben"
>
<div className="relative">
<FileText className="w-5 h-5" />
{(stats.docsTodosOpenCount ?? 0) > 0 && (
<CheckSquare className="w-5 h-5" />
{stats.tasksOpenCount > 0 && (
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
{stats.docsTodosOpenCount}
{stats.tasksOpenCount}
</span>
)}
</div>
<span className="text-[10px] leading-tight">Workspace</span>
<span className="text-[10px] leading-tight">Aufgaben</span>
</button>
{/* Col 5: Listen */}
+38 -4
View File
@@ -24,6 +24,7 @@ export interface UnifiedShareModalProps {
teams: Team[];
collaborators: User[];
allowRoleSelection?: boolean; // Default true (editor vs viewer)
fixedPermission?: 'editor' | 'viewer'; // When allowRoleSelection is false, force fixedPermission (default 'viewer' for documents, 'editor' for tasks/lists)
onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise<void>;
onRemoveShare: (shareId: string) => Promise<void>;
}
@@ -40,13 +41,17 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
teams,
collaborators,
allowRoleSelection = true,
fixedPermission,
onAddShare,
onRemoveShare,
}) => {
const effectiveFixedPerm = fixedPermission || 'viewer';
const [shareType, setShareType] = useState<'team' | 'user'>('team');
const [selectedTeamId, setSelectedTeamId] = useState('');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor');
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>(
allowRoleSelection ? 'editor' : effectiveFixedPerm
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -63,7 +68,8 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
setLoading(true);
try {
await onAddShare(shareType, targetId, selectedPermission);
const permToUse = allowRoleSelection ? selectedPermission : effectiveFixedPerm;
await onAddShare(shareType, targetId, permToUse);
setSelectedTeamId('');
setSelectedUser(null);
} catch (err: any) {
@@ -196,7 +202,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
</option>
))}
</select>
{allowRoleSelection && (
{allowRoleSelection ? (
<select
value={selectedPermission}
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
@@ -206,6 +212,20 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
</select>
) : (
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
{effectiveFixedPerm === 'editor' ? (
<>
<Check className="w-3 h-3 text-accent" />
Bearbeiten
</>
) : (
<>
<Lock className="w-3 h-3 text-faint" />
Nur Lesen
</>
)}
</span>
)}
</div>
) : (
@@ -220,7 +240,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
placeholder="Benutzer suchen (Name oder @Benutzername)…"
/>
</div>
{allowRoleSelection && (
{allowRoleSelection ? (
<select
value={selectedPermission}
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
@@ -230,6 +250,20 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
</select>
) : (
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
{effectiveFixedPerm === 'editor' ? (
<>
<Check className="w-3 h-3 text-accent" />
Bearbeiten
</>
) : (
<>
<Lock className="w-3 h-3 text-faint" />
Nur Lesen
</>
)}
</span>
)}
</div>
)}
@@ -52,6 +52,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLFormElement>(null);
const recognitionRef = useRef<any>(null);
const baseVoiceTextRef = useRef('');
const hasVoiceInputRef = useRef(false);
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
const targetEl = el || inputRef.current;
@@ -131,6 +133,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}
try {
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
hasVoiceInputRef.current = true;
const recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.continuous = false;
@@ -146,10 +151,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
setContent((prev) => {
const base = prev.replace(/\s*\[…\]$/, '').trim();
return base ? `${base} ${transcript}` : transcript;
});
const base = baseVoiceTextRef.current;
setContent(base ? `${base} ${transcript}` : transcript);
};
recognition.onerror = () => {
@@ -180,7 +183,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
const capturedPriority = priority;
const capturedDueDate = dueDate;
const capturedSource = isListening ? 'voice' : 'manual';
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
hasVoiceInputRef.current = false;
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
setContent('');
+154 -39
View File
@@ -8,6 +8,7 @@ import {
Sparkles,
X,
Hash,
ShoppingCart,
} from 'lucide-react';
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
@@ -84,6 +85,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [newItemText, setNewItemText] = useState('');
const [newItemQty, setNewItemQty] = useState('');
const [isSubmittingItem, setIsSubmittingItem] = useState(false);
const [editingQtyItemId, setEditingQtyItemId] = useState<string | null>(null);
const [editingQtyValue, setEditingQtyValue] = useState('');
const [wsConnected, setWsConnected] = useState(false);
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
@@ -173,7 +176,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
return { ...l, items: deduplicateItems([newItem, ...l.items]) };
})
);
} else if (msg.event === 'ITEM_TOGGLED' && msgListId) {
} else if ((msg.event === 'ITEM_TOGGLED' || msg.event === 'ITEM_UPDATED') && msgListId) {
const updatedItem: ListItem = msg.payload;
setLists((prev) =>
prev.map((l) => {
@@ -272,6 +275,26 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
}
};
const handleSaveQuantity = async (itemId: string, quantity: string) => {
setEditingQtyItemId(null);
if (!activeList) return;
const trimmed = quantity.trim();
try {
const updated = await api.updateListItem(activeList.id, itemId, { quantity: trimmed });
setLists((prev) =>
prev.map((l) => {
if (l.id !== activeList.id) return l;
return {
...l,
items: l.items.map((i) => (i.id === itemId ? { ...i, quantity: updated.quantity } : i)),
};
})
);
} catch (err: any) {
console.error('Fehler beim Aktualisieren der Menge', err);
}
};
const handleToggleItem = async (item: ListItem) => {
if (!activeList) return;
triggerHaptic(10);
@@ -953,41 +976,61 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div>
{showNewListModal && (
<form
onSubmit={handleCreateList}
className="p-2 panel-surface flex flex-wrap items-center gap-2"
>
<div className="flex items-center gap-1.5">
{['🛒', '🏔️', '🍕', '🎉', '🧰', '🎁'].map((ic) => (
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
<div className="flex items-center justify-between border-b border-border pb-2">
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
<ShoppingCart className="w-4 h-4 text-accent" />
<span>Neue Einkaufsliste erstellen</span>
</h3>
<button
key={ic}
type="button"
onClick={() => setNewListIcon(ic)}
className={`w-9 h-9 flex items-center justify-center rounded-btn transition-colors ${
newListIcon === ic
? 'bg-accent/20 border border-accent text-accent'
: 'bg-canvas hover:bg-panel-elevated border border-border'
}`}
onClick={() => setShowNewListModal(false)}
className="text-muted hover:text-main"
>
{ic}
<X className="w-4 h-4" />
</button>
))}
</div>
<form onSubmit={handleCreateList} className="space-y-3">
<div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5">
<input
type="text"
value={newListIcon}
onChange={(e) => setNewListIcon(e.target.value)}
maxLength={2}
className="w-10 h-8 text-center input-field text-sm"
title="Icon (Emoji)"
/>
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
autoFocus
required
className="flex-1 h-8 input-field px-2.5 text-xs"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<button
type="button"
onClick={() => setShowNewListModal(false)}
className="btn-action h-7 px-3 text-xs"
>
Abbrechen
</button>
<button type="submit" className="btn-primary h-7 px-3 text-xs">
Erstellen
</button>
</div>
</form>
</div>
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="Titel der Liste (z. B. Baumarkt, Drogerie, Camping)…"
className="flex-1 h-9 px-3 input-field text-sm"
autoFocus
/>
<button
type="submit"
className="btn-primary h-9 px-4 text-xs sm:text-sm"
>
Liste erstellen
</button>
</form>
</div>
)}
{/* Active Shared List View */}
@@ -1064,8 +1107,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<input
type="text"
value={newItemQty}
onChange={(e) => setNewItemQty(e.target.value)}
placeholder="Menge (z.B. 2x)"
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
placeholder="Menge (z.B. 2)"
className="flex-1 sm:flex-none sm:w-28 h-9 sm:h-7 px-2.5 sm:px-2 input-field text-xs"
/>
<button
@@ -1175,10 +1218,45 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{displayContent}
</span>
{item.quantity && (
<span className="badge-accent">
{item.quantity}
</span>
{editingQtyItemId === item.id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
type="text"
autoFocus
value={editingQtyValue}
onChange={(e) => setEditingQtyValue(e.target.value.replace(/[^0-9.,xX]/g, ''))}
onKeyDown={async (e) => {
if (e.key === 'Enter') {
e.preventDefault();
await handleSaveQuantity(item.id, editingQtyValue);
} else if (e.key === 'Escape') {
setEditingQtyItemId(null);
}
}}
onBlur={async () => {
await handleSaveQuantity(item.id, editingQtyValue);
}}
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
placeholder="Menge"
/>
</div>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setEditingQtyItemId(item.id);
setEditingQtyValue(item.quantity || '');
}}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
item.quantity
? 'badge-accent cursor-pointer hover:brightness-125'
: 'opacity-0 group-hover:opacity-60 hover:!opacity-100 text-muted hover:text-accent border border-dashed border-border/70 hover:border-accent text-[11px]'
}`}
title="Menge bearbeiten"
>
{item.quantity || '+ Menge'}
</button>
)}
{cat && !group.label && (
@@ -1253,8 +1331,45 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{displayContent}
</span>
{item.quantity && (
<span className="text-xs text-faint">({item.quantity})</span>
{editingQtyItemId === item.id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
type="text"
autoFocus
value={editingQtyValue}
onChange={(e) => setEditingQtyValue(e.target.value.replace(/[^0-9.,xX]/g, ''))}
onKeyDown={async (e) => {
if (e.key === 'Enter') {
e.preventDefault();
await handleSaveQuantity(item.id, editingQtyValue);
} else if (e.key === 'Escape') {
setEditingQtyItemId(null);
}
}}
onBlur={async () => {
await handleSaveQuantity(item.id, editingQtyValue);
}}
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
placeholder="Menge"
/>
</div>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setEditingQtyItemId(item.id);
setEditingQtyValue(item.quantity || '');
}}
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
item.quantity
? 'text-faint hover:text-main cursor-pointer'
: 'opacity-0 group-hover:opacity-60 text-faint hover:text-main text-[11px]'
}`}
title="Menge bearbeiten"
>
{item.quantity ? `(${item.quantity})` : '+ Menge'}
</button>
)}
</div>
@@ -49,6 +49,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [newPassword, setNewPassword] = useState('');
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
const [revokingSessions, setRevokingSessions] = useState(false);
const [isProcessingAvatar, setIsProcessingAvatar] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -155,6 +156,21 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
};
const handleRevokeAllSessions = async () => {
if (!window.confirm('Möchtest du wirklich alle aktiven Sitzungen auf allen Geräten beenden? Du wirst dabei auch auf diesem Gerät abgemeldet.')) {
return;
}
setRevokingSessions(true);
try {
await api.revokeAllSessions();
await api.logout();
window.location.reload();
} catch (err: any) {
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Beenden der Sitzungen' });
setRevokingSessions(false);
}
};
// --- Handlers: Alarms ---
const handleSaveAlarms = async (e: React.FormEvent) => {
e.preventDefault();
@@ -530,6 +546,32 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</button>
</div>
</form>
{/* Sessions & Security */}
<div className="pt-5 border-t border-border space-y-3">
<div>
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
<p className="text-[11px] text-muted">
Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig.
</p>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
<div className="text-xs">
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
<p className="text-[11px] text-faint">
Invalidiert alle ausgestellten Tokens auf allen Geräten und Browsern.
</p>
</div>
<button
type="button"
onClick={handleRevokeAllSessions}
disabled={revokingSessions}
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer"
>
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
</button>
</div>
</div>
</div>
)}
@@ -18,6 +18,7 @@ import {
} from 'lucide-react';
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
import { triggerHaptic } from '../../lib/api';
import { MarkdownViewer } from '../../lib/markdown';
interface TaskDetailModalProps {
task: Thought | null;
@@ -36,6 +37,7 @@ interface TaskDetailModalProps {
onAddSubtask: (task: Thought, title: string) => Promise<void>;
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
onConvertToDoc: (task: Thought) => void;
onOpenDocument?: (docId: string, blockId?: string) => void;
onDeleteTask: (taskId: string) => void;
}
@@ -56,6 +58,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onAddSubtask,
onDeleteSubtask,
onConvertToDoc,
onOpenDocument,
onDeleteTask,
}) => {
if (!isOpen || !task) return null;
@@ -202,18 +205,35 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => {
onClose();
onConvertToDoc(task);
}}
title="Als Dokument / Notiz vertiefen"
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5"
>
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
<span className="hidden sm:inline">Als Notiz vertiefen</span>
</button>
{(task.convertedType === 'document_block' || task.docId) ? (
<button
type="button"
onClick={() => {
onClose();
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
if (docId && onOpenDocument) onOpenDocument(docId, blockId);
}}
title="Zur verlinkten Notiz springen"
className="btn-primary min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 font-semibold"
>
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span>In Notiz öffnen</span>
</button>
) : (
<button
type="button"
onClick={() => {
onClose();
onConvertToDoc(task);
}}
title="Als Dokument / Notiz vertiefen"
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5"
>
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
<span className="hidden sm:inline">Als Notiz vertiefen</span>
</button>
)}
<button
type="button"
onClick={() => {
@@ -656,10 +676,10 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
setNotesDraft(task.notes || '');
setIsEditingNotes(true);
}}
className="p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed whitespace-pre-wrap cursor-pointer hover:border-strong transition-colors"
className="p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed cursor-pointer hover:border-strong transition-colors"
title="Klicken zum Bearbeiten"
>
{task.notes}
<MarkdownViewer content={task.notes} />
</div>
) : (
<button
+53 -4
View File
@@ -46,8 +46,9 @@ const getInitials = (name?: string): string => {
interface TasksViewProps {
currentUser?: User | null;
onRefreshStats: () => void;
onOpenDocument: (docId: string) => void;
onOpenDocument: (docId: string, blockId?: string) => void;
refreshTrigger?: number;
initialSelectedTaskId?: string | null;
}
export const TasksView: React.FC<TasksViewProps> = ({
@@ -55,6 +56,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
onRefreshStats,
onOpenDocument,
refreshTrigger = 0,
initialSelectedTaskId,
}) => {
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
@@ -101,6 +103,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
// Detail modal state
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [extraTask, setExtraTask] = useState<Thought | null>(null);
useEffect(() => {
if (initialSelectedTaskId) {
setDetailTaskId(initialSelectedTaskId);
setShowDetailModal(true);
setFilter('open');
api.getThought(initialSelectedTaskId).then((th) => {
if (th) setExtraTask(th);
}).catch(() => {});
}
}, [initialSelectedTaskId]);
// Manage columns modal state
const [showManageColumnsModal, setShowManageColumnsModal] = useState(false);
@@ -243,8 +257,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
const detailTask = useMemo(() => {
if (!detailTaskId) return null;
return tasks.find((t) => t.id === detailTaskId) || null;
}, [detailTaskId, tasks]);
return tasks.find((t) => t.id === detailTaskId) || (extraTask?.id === detailTaskId ? extraTask : null);
}, [detailTaskId, tasks, extraTask]);
const safeTeams = Array.isArray(teams) ? teams : [];
const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
@@ -1135,6 +1149,23 @@ export const TasksView: React.FC<TasksViewProps> = ({
className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden"
onClick={(e) => e.stopPropagation()}
>
{(task.convertedType === 'document_block' || task.docId) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
if (docId) onOpenDocument(docId, blockId);
}}
className="h-5 px-1.5 text-[10px] rounded-btn border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer flex items-center gap-1 font-medium"
title="In Notiz an der passenden Stelle öffnen"
>
<FileText className="w-2.5 h-2.5" />
<span>In Notiz</span>
</button>
)}
{/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */}
<select
value={task.kanbanStatus || (isCompleted ? 'done' : 'todo')}
@@ -1978,6 +2009,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
teams={safeTeams}
collaborators={safeCollaborators}
allowRoleSelection={false}
fixedPermission="editor"
onAddShare={async (targetType, targetId) => {
await api.addTaskListShare(sharingTaskList.id, {
targetType,
@@ -2029,6 +2061,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
}}
activeColumns={activeColumns}
taskLists={safeTaskLists}
onOpenDocument={onOpenDocument}
onUpdateTitle={handleUpdateTaskTitle}
onUpdateStatus={handleSetKanbanStatus}
onUpdatePriority={handleSetPriority}
@@ -2163,8 +2196,24 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div>
)}
{/* Badges: Priority, Status, Tags */}
{/* Badges: In Notiz, Priority, Status, Tags */}
<div className="flex flex-wrap items-center gap-1 pt-0.5" onClick={(e) => e.stopPropagation()}>
{(task.convertedType === 'document_block' || task.docId) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
if (docId) onOpenDocument(docId, blockId);
}}
className="h-4 px-1.5 text-[9px] rounded-btn border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer flex items-center gap-1 font-medium shrink-0"
title="In Notiz an der passenden Stelle öffnen"
>
<FileText className="w-2.5 h-2.5" />
<span>In Notiz</span>
</button>
)}
{task.priority === 'urgent' && (
<button
type="button"
+469 -307
View File
@@ -7,18 +7,19 @@ import {
FileText,
Loader2,
ArrowLeft,
Hash,
Lock,
Home,
ChevronRight,
Minimize2,
Sparkles,
Plus,
Search,
} from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api';
import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus } from '../../lib/useDocumentSync';
import { OutlinerTree } from './OutlinerTree';
import { api, getToken } from '../../lib/api';
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
export interface DocumentEditorProps {
docId: string;
@@ -33,10 +34,67 @@ export interface DocumentEditorProps {
onSelectTag: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
onBackMobile?: () => void;
onSyncStatusChange?: (status: DocSyncStatus) => void;
onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
}
const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒'];
interface EmojiItem {
emoji: string;
category: string;
keywords: string;
}
const EMOJI_CATALOG: EmojiItem[] = [
// Notizen & Dokumente
{ emoji: '📝', category: 'Notizen', keywords: 'notiz schreiben memo text note' },
{ emoji: '📄', category: 'Notizen', keywords: 'dokument seite page doc' },
{ emoji: '📋', category: 'Notizen', keywords: 'clipboard klemmbrett liste' },
{ emoji: '📂', category: 'Notizen', keywords: 'ordner mappe folder datei' },
{ emoji: '📁', category: 'Notizen', keywords: 'ordner folder directory' },
{ emoji: '📌', category: 'Notizen', keywords: 'pin anheften bookmark wichtig' },
{ emoji: '📍', category: 'Notizen', keywords: 'standort ort pin location' },
{ emoji: '🏷️', category: 'Notizen', keywords: 'tag etikett label markierung' },
{ emoji: '📚', category: 'Notizen', keywords: 'buch bibliothek lesen studium' },
{ emoji: '📖', category: 'Notizen', keywords: 'buch open book wissen' },
// Ideen & Fokus
{ emoji: '🧠', category: 'Ideen', keywords: 'gehirn brain denken wissen thought mind' },
{ emoji: '💡', category: 'Ideen', keywords: 'idee inspiration lampe glühbirne idea' },
{ emoji: '🎯', category: 'Ideen', keywords: 'ziel target fokus mission okr' },
{ emoji: '🚀', category: 'Ideen', keywords: 'rakete start launch projekt rocket' },
{ emoji: '⚡', category: 'Ideen', keywords: 'blitz energie schnell quick power' },
{ emoji: '🔥', category: 'Ideen', keywords: 'feuer flame priorität hot wichtig' },
{ emoji: '⭐', category: 'Ideen', keywords: 'stern favoriten star top' },
{ emoji: '✨', category: 'Ideen', keywords: 'funkeln magie neu neuigkeit sparkles' },
{ emoji: '🧩', category: 'Ideen', keywords: 'puzzle teil lösung problem' },
{ emoji: '🔬', category: 'Ideen', keywords: 'mikroskop forschung analyse test' },
// Tech & Dev
{ emoji: '💻', category: 'Tech', keywords: 'laptop computer dev code programming' },
{ emoji: '🖥️', category: 'Tech', keywords: 'desktop monitor bildschirm workstation' },
{ emoji: '🛠️', category: 'Tech', keywords: 'werkzeug tools optionen settings einstellungen' },
{ emoji: '⚙️', category: 'Tech', keywords: 'zahnrad config settings system' },
{ emoji: '📊', category: 'Tech', keywords: 'diagramm chart daten statistik dashboard' },
{ emoji: '📈', category: 'Tech', keywords: 'wachstum trend kurve business' },
{ emoji: '🔒', category: 'Tech', keywords: 'schloss sicherheit geheim privat secure' },
{ emoji: '🔑', category: 'Tech', keywords: 'schlüssel key passwort access' },
{ emoji: '🌐', category: 'Tech', keywords: 'web internet network welt globus' },
{ emoji: '📦', category: 'Tech', keywords: 'paket box release lieferung package' },
// Alltag & Organisation
{ emoji: '💬', category: 'Alltag', keywords: 'chat nachricht kommentar call sprechen' },
{ emoji: '✉️', category: 'Alltag', keywords: 'mail email brief post nachricht' },
{ emoji: '📅', category: 'Alltag', keywords: 'kalender datum termin plan event' },
{ emoji: '⏰', category: 'Alltag', keywords: 'wecker uhr zeit deadline alarm' },
{ emoji: '🛒', category: 'Alltag', keywords: 'warenkorb einkauf shopping liste' },
{ emoji: '☕', category: 'Alltag', keywords: 'kaffee pause bistro morning' },
{ emoji: '🏠', category: 'Alltag', keywords: 'haus heim home wohnung' },
{ emoji: '🏢', category: 'Alltag', keywords: 'büro firma arbeit office work' },
{ emoji: '✈️', category: 'Alltag', keywords: 'flugzeug reise urlaub trip travel' },
{ emoji: '🎨', category: 'Alltag', keywords: 'kunst farbe design creative' },
{ emoji: '🏆', category: 'Alltag', keywords: 'pokal erfolg win trophy award' },
{ emoji: '✅', category: 'Alltag', keywords: 'haken check done erledigt' },
];
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
if (!blocks || blocks.length === 0) {
@@ -144,7 +202,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
docId,
currentUser,
initialDoc,
availableFolders,
autoFocusTitle,
initialFocusedBlockId,
onDocMetadataChange,
@@ -153,7 +210,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onSelectTag,
onWikiLinkClick,
onBackMobile,
onSyncStatusChange,
onOpenTask,
onSaveStatusChange,
}) => {
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || '');
@@ -163,13 +221,36 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const [loading, setLoading] = useState<boolean>(!initialDoc);
const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [emojiSearch, setEmojiSearch] = useState<string>('');
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
const titleInputRef = useRef<HTMLInputElement | null>(null);
const titleDebounceRef = useRef<number | null>(null);
const filteredEmojis = useMemo(() => {
const q = emojiSearch.trim().toLowerCase();
if (!q) return EMOJI_CATALOG;
return EMOJI_CATALOG.filter(
(item) => item.keywords.includes(q) || item.emoji.includes(q) || item.category.toLowerCase().includes(q)
);
}, [emojiSearch]);
// AutoSave state (REST persistence)
const [, setSaveStatus] = useState<DocSaveStatus>('saved');
const [, setSaveErrorMessage] = useState<string | null>(null);
const saveTimeoutRef = useRef<number | null>(null);
const isSavingRef = useRef<boolean>(false);
const lastSavedSnapshotRef = useRef<string>('');
const isInitialLoadRef = useRef<boolean>(true);
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
const docRef = useRef<Document | null>(doc);
docRef.current = doc;
const currentTitleRef = useRef<string>(title);
currentTitleRef.current = title;
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
currentBlocksRef.current = blocks;
const lastKnownUpdatedAtRef = useRef<string | null>(initialDoc?.updatedAt || null);
useEffect(() => {
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
const focusTitle = () => {
@@ -202,186 +283,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId);
}, [title, doc?.title, blocks, zoomedBlockId]);
// Crucial fix: Collaborator with write permission can edit!
// Strictly Read-Only for shared documents: only the document owner can edit!
const canEdit = useMemo(() => {
if (!doc) return true;
if (doc.isOwner) return true;
return doc.permission === 'owner' || doc.permission === 'write';
return Boolean(doc.isOwner || doc.permission === 'owner');
}, [doc]);
// Handle incoming WebSocket events for THIS document room
const handleRemoteEvent = useCallback(
(evt: DocSyncEvent) => {
if (evt.docId && evt.docId !== docId) return;
switch (evt.type) {
case 'doc_state': {
if (evt.title !== undefined) {
const isTitleActivelyEditing =
titleInputRef.current !== null &&
document.activeElement === titleInputRef.current &&
typeof document !== 'undefined' &&
document.hasFocus();
if (!isTitleActivelyEditing) {
setTitle(evt.title);
}
}
if (Array.isArray(evt.blocks) && evt.blocks.length > 0) {
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
}
break;
}
case 'title_updated': {
if (evt.title !== undefined) {
const isTitleActivelyEditing =
titleInputRef.current !== null &&
document.activeElement === titleInputRef.current &&
typeof document !== 'undefined' &&
document.hasFocus();
if (!isTitleActivelyEditing) {
setTitle(evt.title);
}
if (docRef.current) {
const updated = { ...docRef.current, title: evt.title };
setDoc(updated);
onDocMetadataChange(updated);
}
}
break;
}
case 'block_created': {
if (!evt.block) return;
const incoming = evt.block;
setBlocks((prev) => {
if (prev.some((b) => b.id === incoming.id)) {
return prev.map((b) => (b.id === incoming.id ? incoming : b));
}
const next = [...prev];
if (evt.afterBlockId) {
const idx = next.findIndex((b) => b.id === evt.afterBlockId);
if (idx >= 0) {
next.splice(idx + 1, 0, incoming);
return ensureNonEmptyBlocks(docId, next);
}
}
next.push(incoming);
return ensureNonEmptyBlocks(docId, next);
});
break;
}
case 'block_updated': {
const targetId = evt.blockId || evt.block?.id;
if (!targetId) return;
setBlocks((prev) =>
prev.map((b) => {
if (b.id !== targetId) return b;
if (evt.block) {
return { ...b, ...evt.block };
}
return {
...b,
content: evt.content !== undefined ? evt.content : b.content,
level: evt.level !== undefined ? evt.level : b.level,
checked: evt.checked !== undefined ? evt.checked : b.checked,
collapsed: evt.collapsed !== undefined ? evt.collapsed : b.collapsed,
};
})
);
break;
}
case 'block_deleted': {
if (!evt.blockId) return;
setBlocks((prev) =>
ensureNonEmptyBlocks(
docId,
prev.filter((b) => b.id !== evt.blockId)
)
);
break;
}
case 'block_moved': {
if (!evt.blockId || evt.newIndex === undefined) return;
const targetIndex = evt.newIndex;
setBlocks((prev) => {
const fromIdx = prev.findIndex((b) => b.id === evt.blockId);
if (fromIdx === -1) return prev;
const copy = [...prev];
const [moving] = copy.splice(fromIdx, 1);
const updatedMoving: DocumentBlock = {
...moving,
level: evt.level !== undefined ? evt.level : moving.level,
};
const boundedIdx = Math.max(0, Math.min(targetIndex, copy.length));
copy.splice(boundedIdx, 0, updatedMoving);
return ensureNonEmptyBlocks(docId, copy);
});
break;
}
case 'blocks_replaced': {
if (Array.isArray(evt.blocks)) {
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
}
break;
}
}
},
[docId, onDocMetadataChange]
);
const {
status: syncStatus,
remoteCursors,
sendCursorMoved,
sendBlockCreated,
sendBlockUpdated,
sendBlockDeleted,
sendBlockMoved,
sendTitleUpdated,
} = useDocumentSync(docId, handleRemoteEvent, canEdit, currentUser);
useEffect(() => {
onSyncStatusChange?.(syncStatus);
}, [syncStatus, onSyncStatusChange]);
// Fetch isolated document state on mount
useEffect(() => {
let isCancelled = false;
if (!initialDoc) {
setLoading(true);
}
setError(null);
api
.getDocument(docId)
.then((fetched) => {
if (isCancelled) return;
setDoc(fetched);
setTitle(fetched.title || 'Unbenannte Notiz');
setBlocks(ensureNonEmptyBlocks(docId, fetched.blocks));
setLoading(false);
onDocMetadataChange(fetched);
})
.catch((err: any) => {
if (isCancelled) return;
setError(err?.message || 'Dokument konnte nicht geladen werden');
setLoading(false);
});
return () => {
isCancelled = true;
if (titleDebounceRef.current !== null) {
window.clearTimeout(titleDebounceRef.current);
titleDebounceRef.current = null;
}
};
}, [docId]);
// Extract all #tags present across the current blocks
const extractedTags = useMemo(() => {
const tagSet = new Set<string>();
@@ -396,6 +303,174 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
return Array.from(tagSet);
}, [blocks]);
const currentTagsRef = useRef<string[]>(extractedTags);
currentTagsRef.current = extractedTags;
// AutoSave snapshot
const currentSnapshot = useMemo(() => {
return JSON.stringify({
title,
blocks,
tags: extractedTags,
});
}, [title, blocks, extractedTags]);
const currentSnapshotRef = useRef<string>(currentSnapshot);
currentSnapshotRef.current = currentSnapshot;
const executeAutoSave = useCallback(
async (immediate: boolean = false) => {
if (!docId || !canEdit) return;
if (isSavingRef.current && !immediate) return;
const snapshot = currentSnapshotRef.current;
if (snapshot === lastSavedSnapshotRef.current) {
setSaveStatus('saved');
onSaveStatusChange?.('saved');
return;
}
isSavingRef.current = true;
setSaveStatus('saving');
onSaveStatusChange?.('saving');
try {
const updated = await api.updateDocument(docId, {
title: currentTitleRef.current,
blocks: currentBlocksRef.current,
tags: currentTagsRef.current,
});
isSavingRef.current = false;
lastSavedSnapshotRef.current = snapshot;
lastKnownUpdatedAtRef.current = updated.updatedAt || null;
setSaveStatus('saved');
setSaveErrorMessage(null);
onSaveStatusChange?.('saved');
if (docRef.current) {
docRef.current = updated;
onDocMetadataChange(updated);
}
} catch (err: any) {
isSavingRef.current = false;
const msg = err?.message || 'Fehler beim Speichern';
setSaveStatus('error');
setSaveErrorMessage(msg);
onSaveStatusChange?.('error', msg);
}
},
[docId, canEdit, onSaveStatusChange, onDocMetadataChange]
);
useEffect(() => {
if (isInitialLoadRef.current) return;
if (!canEdit) return;
if (currentSnapshot === lastSavedSnapshotRef.current) return;
setSaveStatus('unsaved');
onSaveStatusChange?.('unsaved');
if (saveTimeoutRef.current !== null) {
window.clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = window.setTimeout(() => {
saveTimeoutRef.current = null;
executeAutoSave();
}, 800);
return () => {
if (saveTimeoutRef.current !== null) {
window.clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
};
}, [currentSnapshot, canEdit, executeAutoSave, onSaveStatusChange]);
// Flush on page unload / pagehide via keepalive fetch
useEffect(() => {
const handleUnload = () => {
if (saveTimeoutRef.current !== null) {
window.clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
const snapshot = currentSnapshotRef.current;
if (snapshot !== lastSavedSnapshotRef.current && canEdit) {
const token = getToken();
if (token) {
fetch(`/api/v1/documents/${docId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: currentTitleRef.current,
blocks: currentBlocksRef.current,
tags: currentTagsRef.current,
}),
keepalive: true,
}).catch(() => {});
}
}
};
window.addEventListener('beforeunload', handleUnload);
window.addEventListener('pagehide', handleUnload);
return () => {
window.removeEventListener('beforeunload', handleUnload);
window.removeEventListener('pagehide', handleUnload);
handleUnload();
};
}, [docId, canEdit]);
// Fetch document on mount
useEffect(() => {
let isCancelled = false;
if (!initialDoc) {
setLoading(true);
}
setError(null);
api
.getDocument(docId)
.then((fetched) => {
if (isCancelled) return;
const normalized = ensureNonEmptyBlocks(docId, fetched.blocks);
setDoc(fetched);
setTitle(fetched.title || 'Unbenannte Notiz');
setBlocks(normalized);
setLoading(false);
onDocMetadataChange(fetched);
const initialTags: string[] = [];
for (const b of normalized) {
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
if (matches) {
for (const m of matches) {
const tag = m.slice(1).toLowerCase();
if (!initialTags.includes(tag)) initialTags.push(tag);
}
}
}
lastSavedSnapshotRef.current = JSON.stringify({
title: fetched.title || 'Unbenannte Notiz',
blocks: normalized,
tags: initialTags,
});
isInitialLoadRef.current = false;
setSaveStatus('saved');
onSaveStatusChange?.('saved');
})
.catch((err: any) => {
if (isCancelled) return;
setError(err?.message || 'Dokument konnte nicht geladen werden');
setLoading(false);
});
return () => {
isCancelled = true;
};
}, [docId]);
// Keep parent sidebar metadata in sync when blocks/tags change
useEffect(() => {
if (!docRef.current) return;
@@ -410,118 +485,154 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onDocMetadataChange(updatedDoc);
}, [blocks, extractedTags, title]);
// Active remote users in this room (excluding self)
const activeUsers = useMemo(() => {
const list: RemoteCursor[] = [];
const seen = new Set<string>();
for (const c of Object.values(remoteCursors)) {
const key = c.userId || c.clientId;
if (!seen.has(key)) {
seen.add(key);
list.push(c);
}
}
return list;
}, [remoteCursors]);
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (!canEdit) return;
const nextTitle = e.target.value;
setTitle(nextTitle);
if (titleDebounceRef.current !== null) {
window.clearTimeout(titleDebounceRef.current);
}
titleDebounceRef.current = window.setTimeout(() => {
titleDebounceRef.current = null;
sendTitleUpdated(nextTitle);
}, 250);
setTitle(e.target.value);
};
const handleTitleBlur = () => {
if (titleDebounceRef.current !== null) {
window.clearTimeout(titleDebounceRef.current);
titleDebounceRef.current = null;
sendTitleUpdated(title);
}
executeAutoSave();
};
// Flush title debouncing on window blur so background window never holds stale pending title
useEffect(() => {
const handleWindowBlur = () => {
if (titleDebounceRef.current !== null) {
window.clearTimeout(titleDebounceRef.current);
titleDebounceRef.current = null;
sendTitleUpdated(title);
}
};
window.addEventListener('blur', handleWindowBlur);
return () => window.removeEventListener('blur', handleWindowBlur);
}, [title, sendTitleUpdated]);
const handleUpdateBlock = useCallback(
(updatedBlock: DocumentBlock) => {
setBlocks((prev) =>
prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))
);
sendBlockUpdated(updatedBlock);
(updatedBlock: DocumentBlock, immediate: boolean = false) => {
if (!canEdit) return;
setBlocks((prev) => {
const next = prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b));
currentBlocksRef.current = next;
return next;
});
if (immediate) {
window.setTimeout(() => {
executeAutoSave(true);
}, 10);
}
},
[sendBlockUpdated]
[canEdit, executeAutoSave]
);
const handleUpdateBlocks = useCallback(
(updatedBlocks: DocumentBlock[]) => {
if (!canEdit || updatedBlocks.length === 0) return;
const map = new Map<string, DocumentBlock>();
for (const b of updatedBlocks) {
map.set(b.id, b);
}
setBlocks((prev) =>
prev.map((b) => map.get(b.id) || b)
);
},
[canEdit]
);
const handleCreateBlock = useCallback(
(newBlock: DocumentBlock, afterBlockId?: string) => {
if (!canEdit) return;
setBlocks((prev) => {
const next = [...prev];
if (afterBlockId) {
const idx = next.findIndex((b) => b.id === afterBlockId);
if (idx >= 0) {
next.splice(idx + 1, 0, newBlock);
const normalized = ensureNonEmptyBlocks(docId, next);
sendBlockCreated(newBlock, afterBlockId, normalized);
return normalized;
return ensureNonEmptyBlocks(docId, next);
}
}
next.push(newBlock);
const normalized = ensureNonEmptyBlocks(docId, next);
sendBlockCreated(newBlock, afterBlockId, normalized);
return normalized;
return ensureNonEmptyBlocks(docId, next);
});
},
[docId, sendBlockCreated]
[docId, canEdit]
);
const handleInsertBlocks = useCallback(
(afterBlockId: string, updatedCurrentBlock: DocumentBlock, newBlocks: DocumentBlock[]) => {
if (!canEdit) return;
setBlocks((prev) => {
const next = [...prev];
const idx = next.findIndex((b) => b.id === afterBlockId);
if (idx >= 0) {
next[idx] = updatedCurrentBlock;
next.splice(idx + 1, 0, ...newBlocks);
} else {
next.push(updatedCurrentBlock, ...newBlocks);
}
const normalized = ensureNonEmptyBlocks(docId, next);
currentBlocksRef.current = normalized;
return normalized;
});
window.setTimeout(() => {
executeAutoSave(true);
}, 50);
},
[docId, canEdit, executeAutoSave]
);
const handleDeleteBlock = useCallback(
(blockId: string) => {
if (!canEdit) return;
setBlocks((prev) => {
const filtered = prev.filter((b) => b.id !== blockId);
const normalized = ensureNonEmptyBlocks(docId, filtered);
sendBlockDeleted(blockId, normalized);
return normalized;
return ensureNonEmptyBlocks(docId, filtered);
});
},
[docId, sendBlockDeleted]
[docId, canEdit]
);
const handleMoveBlock = useCallback(
(blockId: string, newIndex: number, level?: number) => {
(blockId: string, targetIndex: number, newLevel?: number) => {
if (!canEdit) return;
setBlocks((prev) => {
const fromIdx = prev.findIndex((b) => b.id === blockId);
if (fromIdx === -1) return prev;
const parentBlock = prev[fromIdx];
const parentLevel = Math.max(0, parentBlock.level || 0);
// Calculate full subtree count (dragged block + all contiguous descendants)
let count = 1;
for (let i = fromIdx + 1; i < prev.length; i++) {
if (Math.max(0, prev[i].level || 0) > parentLevel) {
count++;
} else {
break;
}
}
// Prevent dropping inside its own subtree
if (targetIndex >= fromIdx && targetIndex < fromIdx + count) {
return prev;
}
const copy = [...prev];
const [moving] = copy.splice(fromIdx, 1);
const updatedMoving: DocumentBlock = {
...moving,
level: level !== undefined ? level : moving.level,
};
const boundedIdx = Math.max(0, Math.min(newIndex, copy.length));
copy.splice(boundedIdx, 0, updatedMoving);
const normalized = ensureNonEmptyBlocks(docId, copy);
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
return normalized;
const subtree = copy.splice(fromIdx, count);
// Adjust insertion index because removing elements from before shifts target index
let insertIndex = targetIndex;
if (fromIdx < targetIndex) {
insertIndex = targetIndex - count;
}
insertIndex = Math.max(0, Math.min(insertIndex, copy.length));
// Calculate level adjustments if moving to a constrained position
const prevBlockInPosition = insertIndex > 0 ? copy[insertIndex - 1] : null;
const maxAllowed = prevBlockInPosition
? Math.max(0, prevBlockInPosition.level || 0) + 1
: 0;
const targetLevel = newLevel !== undefined ? newLevel : parentLevel;
const clampedLevel = Math.min(targetLevel, maxAllowed);
const levelDelta = clampedLevel - parentLevel;
const updatedSubtree = subtree.map((b) => ({
...b,
level: Math.max(0, (b.level || 0) + levelDelta),
}));
copy.splice(insertIndex, 0, ...updatedSubtree);
return ensureNonEmptyBlocks(docId, copy);
});
},
[docId, sendBlockMoved]
[docId, canEdit]
);
const handleTogglePin = async () => {
@@ -680,37 +791,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
)}
</div>
{/* Right Status & Action Buttons */}
{/* Right Action Buttons */}
<div className="flex items-center gap-1.5">
{/* Active Presence Avatars (Immer rund: rounded-full mit Überlappung) */}
{activeUsers.length > 0 && (
<div className="flex items-center -space-x-1.5 mr-1" title="Aktive Bearbeiter im Dokument">
{activeUsers.map((u) => {
const initials = (u.userName || 'A')
.split(' ')
.map((n) => n[0])
.slice(0, 2)
.join('')
.toUpperCase();
return (
<div key={u.clientId} className="relative group/avatar cursor-default">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white shadow-sm ring-2 ring-canvas"
style={{ backgroundColor: u.userColor }}
>
{initials}
</div>
{/* Tooltip */}
<div className="pointer-events-none absolute bottom-full mb-1 left-1/2 -translate-x-1/2 opacity-0 group-hover/avatar:opacity-100 transition-opacity whitespace-nowrap bg-canvas text-main text-[10px] px-1.5 py-0.5 rounded-btn border border-border shadow-md z-30">
{u.userName}
</div>
</div>
);
})}
</div>
)}
{/* Sharing Section (1:1 styling from SharedListsView) */}
{/* Sharing Section */}
{doc && (() => {
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
const sharesCount = doc.sharedWith?.length || 0;
@@ -790,14 +873,21 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
</div>
</div>
{/* Read-only notification banner */}
{!canEdit && (
<div className="flex items-center gap-2 px-4 sm:px-6 py-2 bg-status-warn/10 border-b border-status-warn/25 text-status-warn text-xs">
<Lock className="w-3.5 h-3.5 shrink-0" />
<span>Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen).</span>
</div>
)}
{/* Main Content Area */}
<div className="max-w-4xl w-full mx-auto px-4 sm:px-8 py-5 flex-1">
<div className="w-full px-4 sm:px-8 py-5 flex-1">
{zoomedBlock ? (
/* =========================================================================
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
========================================================================= */
<div className="space-y-3">
{/* Prominent Zoomed Ast-Heading */}
<div className="flex items-start justify-between gap-3 pb-4 mb-2 border-b border-border">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
@@ -846,7 +936,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
</button>
</div>
{/* Subtree Children or Empty State */}
{childBlocks.length === 0 ? (
<div className="py-8 text-center text-xs text-faint border border-dashed border-border rounded-card my-2">
<p className="mb-2">Dieser Ast hat noch keine Unterpunkte.</p>
@@ -878,12 +967,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={setZoomedBlockId}
remoteCursors={remoteCursors}
sendCursorMoved={sendCursorMoved}
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
onInsertBlocks={handleInsertBlocks}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onOpenTask={onOpenTask}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
@@ -907,47 +997,119 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{doc?.icon || '📝'}
</button>
{showEmojiPicker && canEdit && (
<div className="absolute left-0 mt-1 p-2 panel-surface shadow-panel border border-border rounded-card grid grid-cols-6 gap-1 z-30 w-52">
{EMOJI_PALETTE.map((emoji) => (
<div
className="absolute left-0 mt-1 p-2 panel-surface shadow-lg border border-border rounded-card z-30 w-72 space-y-2"
onClick={(e) => e.stopPropagation()}
>
<div className="relative">
<Search className="w-3.5 h-3.5 text-faint absolute left-2 top-1/2 -translate-y-1/2" />
<input
type="text"
autoFocus
value={emojiSearch}
onChange={(e) => setEmojiSearch(e.target.value)}
placeholder="Icon suchen oder Emoji eingeben…"
className="input-field w-full pl-7 pr-2 py-1 text-xs"
/>
</div>
{doc?.icon && (
<button
key={emoji}
type="button"
onClick={() => handleSelectIcon(emoji)}
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center cursor-pointer"
onClick={() => {
handleSelectIcon('');
setShowEmojiPicker(false);
setEmojiSearch('');
}}
className="w-full text-left px-2 py-1 text-status-warn/90 hover:bg-status-warn/10 rounded text-xs font-medium flex items-center gap-1.5 cursor-pointer"
>
{emoji}
<Trash2 className="w-3 h-3" />
<span>Icon entfernen</span>
</button>
))}
)}
{emojiSearch.trim() && (
<button
type="button"
onClick={() => {
handleSelectIcon(emojiSearch.trim());
setShowEmojiPicker(false);
setEmojiSearch('');
}}
className="w-full text-left px-2 py-1 bg-accent/10 hover:bg-accent/20 text-accent rounded text-xs font-medium flex items-center justify-between cursor-pointer"
>
<span>Eingabe als Icon nutzen</span>
<span className="text-base">{emojiSearch.trim()}</span>
</button>
)}
<div className="max-h-48 overflow-y-auto grid grid-cols-6 gap-1 pr-1">
{filteredEmojis.map((item) => (
<button
key={item.emoji}
type="button"
onClick={() => {
handleSelectIcon(item.emoji);
setShowEmojiPicker(false);
setEmojiSearch('');
}}
className="p-1.5 rounded-btn hover:bg-panel-elevated text-lg flex items-center justify-center cursor-pointer transition-colors"
title={item.keywords}
>
{item.emoji}
</button>
))}
{filteredEmojis.length === 0 && (
<div className="col-span-6 text-center text-xs text-faint py-3">
Kein passendes Standard-Icon
</div>
)}
</div>
</div>
)}
</div>
<input
ref={titleInputRef}
type="text"
<textarea
ref={(el) => {
titleInputRef.current = el;
if (el) {
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}
}}
rows={1}
value={title}
readOnly={!canEdit}
autoFocus={autoFocusTitle}
onChange={handleTitleChange}
onChange={(e) => {
handleTitleChange(e);
e.target.style.height = 'auto';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
outlinerTreeRef.current?.focusFirstBlock();
}
}}
onBlur={handleTitleBlur}
placeholder="Titel der Notiz…"
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight"
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
/>
</div>
{/* Logseq Outliner Tree */}
<OutlinerTree
ref={outlinerTreeRef}
docId={docId}
blocks={blocks}
canEdit={canEdit}
levelOffset={0}
onZoomBlock={setZoomedBlockId}
remoteCursors={remoteCursors}
sendCursorMoved={sendCursorMoved}
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
onInsertBlocks={handleInsertBlocks}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onOpenTask={onOpenTask}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
+274 -146
View File
@@ -3,8 +3,10 @@ import {
ChevronRight,
ChevronDown,
Check,
CheckSquare,
X,
GripVertical,
Trash2,
MoreHorizontal,
Code,
Heading1,
Heading2,
@@ -19,8 +21,9 @@ import {
FileText,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { RemoteCursor } from '../../lib/useDocumentSync';
import { LinkModal } from './LinkModal';
import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api';
export interface SlashCommand {
type: BlockType | 'link';
@@ -41,22 +44,23 @@ const SLASH_COMMANDS: SlashCommand[] = [
];
export interface OutlinerBlockProps {
docId: string;
block: DocumentBlock;
index: number;
isLast: boolean;
maxAllowedLevel: number;
hasChildren: boolean;
isFocused: boolean;
canEdit: boolean;
activeRemoteCursors?: RemoteCursor[];
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
onFocusBlock: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
onDeleteBlock: (blockId: string) => void;
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
onFocusBlock?: (blockId: string) => void;
onOpenTask?: (taskId: string) => void;
onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
onDragStartBlock?: (index: number) => void;
@@ -65,24 +69,27 @@ export interface OutlinerBlockProps {
isDragTarget?: boolean;
isTargeted?: boolean;
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
}
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
docId,
block,
index,
isLast,
maxAllowedLevel,
hasChildren,
isFocused,
canEdit,
activeRemoteCursors = [],
sendCursorMoved,
levelOffset = 0,
onZoomBlock,
onFocusBlock,
onUpdateBlock,
onCreateBlockBelow,
onDeleteBlock,
onIndentBlock,
onNavigateFocus,
onFocusBlock,
onOpenTask,
onTagClick,
onWikiLinkClick,
onDragStartBlock,
@@ -91,11 +98,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
isDragTarget,
isTargeted,
registerTextareaRef,
onPasteMultiLines,
}) => {
const [localContent, setLocalContent] = useState<string>(block.content || '');
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false);
const activeFocus = isFocused || isLocallyFocused;
const [slashSearch, setSlashSearch] = useState<string | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const debounceTimerRef = useRef<number | null>(null);
@@ -107,18 +118,17 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
// Callback refs to avoid stale closures in setTimeout, debounce flush, and event listeners
const onUpdateBlockRef = useRef(onUpdateBlock);
onUpdateBlockRef.current = onUpdateBlock;
const onFocusBlockRef = useRef(onFocusBlock);
onFocusBlockRef.current = onFocusBlock;
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
onCreateBlockBelowRef.current = onCreateBlockBelow;
const onPasteMultiLinesRef = useRef(onPasteMultiLines);
onPasteMultiLinesRef.current = onPasteMultiLines;
const onDeleteBlockRef = useRef(onDeleteBlock);
onDeleteBlockRef.current = onDeleteBlock;
const onIndentBlockRef = useRef(onIndentBlock);
onIndentBlockRef.current = onIndentBlock;
const onNavigateFocusRef = useRef(onNavigateFocus);
onNavigateFocusRef.current = onNavigateFocus;
const sendCursorMovedRef = useRef(sendCursorMoved);
sendCursorMovedRef.current = sendCursorMoved;
const isPastingRef = useRef<boolean>(false);
// Track if this input currently has actual focus in an active browser window
const isFocusedRef = useRef<boolean>(false);
@@ -166,7 +176,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
flushDebounce(localContentRef.current);
}
isFocusedRef.current = false;
sendCursorMovedRef.current?.(null);
};
const handleWindowFocus = () => {
@@ -203,7 +212,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
useLayoutEffect(() => {
adjustHeight();
}, [localContent, block.type, block.level, adjustHeight]);
}, [localContent, block.type, block.level, activeFocus, adjustHeight]);
useEffect(() => {
window.addEventListener('resize', adjustHeight);
@@ -279,7 +288,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}
setLocalContent(val);
sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd);
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
@@ -302,14 +310,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}, 150);
};
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
const handleFocus = () => {
isFocusedRef.current = true;
onFocusBlockRef.current(block.id);
sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd);
setIsLocallyFocused(true);
onFocusBlock?.(block.id);
setTimeout(adjustHeight, 0);
};
const handleBlur = () => {
isFocusedRef.current = false;
setIsLocallyFocused(false);
if (isPastingRef.current) {
isPastingRef.current = false;
return;
}
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
@@ -317,23 +331,30 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} else if (localContentRef.current !== latestBlockRef.current.content) {
flushDebounce(localContentRef.current);
}
sendCursorMovedRef.current?.(null);
};
const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
};
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (!canEdit || block.type === 'code') return;
const pastedText = e.clipboardData.getData('text');
if (!pastedText || !pastedText.includes('\n')) return;
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
};
e.preventDefault();
const rawLines = pastedText.split(/\r?\n/);
if (rawLines.length <= 1) return;
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const target = e.currentTarget;
sendCursorMovedRef.current?.(block.id, target.selectionStart, target.selectionEnd);
};
const start = e.currentTarget.selectionStart ?? localContent.length;
const end = e.currentTarget.selectionEnd ?? start;
const textBefore = localContent.slice(0, start);
const textAfter = localContent.slice(end);
const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null;
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
isPastingRef.current = true;
onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!canEdit) return;
@@ -374,13 +395,49 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return;
}
// 2. Enter (without Shift) -> Create new block on same level
// 2. Enter (without Shift) -> If block is completely empty:
// - If level > minLevel, outdent (Shift+Tab behaviour)
// - If already at minLevel and is the last block, do NOT create another empty block below
// - Otherwise split paragraph at cursor and create new block below
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
e.preventDefault();
e.stopPropagation();
setSlashSearch(null);
flushDebounce(localContent);
onCreateBlockBelowRef.current(block.id, localContent);
const minLevel = levelOffset || 0;
if (localContent === '') {
if (Math.max(0, block.level || 0) > minLevel) {
flushDebounce(localContent);
onIndentBlockRef.current(block.id, -1);
return;
}
if (isLast) {
// Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block
return;
}
}
const start = e.currentTarget.selectionStart ?? localContent.length;
const end = e.currentTarget.selectionEnd ?? start;
let beforeText = localContent.slice(0, start);
let afterText = localContent.slice(end);
// Clean linebreaks at the split boundary if present
if (beforeText.endsWith('\r\n')) {
beforeText = beforeText.slice(0, -2);
} else if (beforeText.endsWith('\n')) {
beforeText = beforeText.slice(0, -1);
}
if (afterText.startsWith('\r\n')) {
afterText = afterText.slice(2);
} else if (afterText.startsWith('\n')) {
afterText = afterText.slice(1);
}
setLocalContent(beforeText);
localContentRef.current = beforeText;
flushDebounce(beforeText);
onCreateBlockBelowRef.current(block.id, afterText);
return;
}
@@ -422,6 +479,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const level = Math.max(0, block.level || 0);
const visualLevel = Math.max(0, level - (levelOffset || 0));
const isEmpty = (!localContent || localContent.trim() === '') && !hasChildren;
// Parse rich tokens (WikiLinks [[Page]], Markdown Links [Title](url), raw URLs, and #Tags)
const tokens = useMemo(() => {
@@ -441,39 +499,29 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return (
<div
className={`group relative flex items-start gap-1 py-1 rounded-card transition-all duration-300 ${
className={`group relative flex items-start gap-1 py-1 px-1.5 rounded-card transition-all duration-300 ${
isDragTarget
? 'border-t-2 border-accent bg-accent/10'
: isTargeted
? 'bg-accent/15 ring-2 ring-accent'
: isFocused
: activeFocus
? 'bg-panel-elevated'
: 'hover:bg-panel'
}`}
style={{
paddingLeft: `${visualLevel * 22 + 4}px`,
...(primaryRemoteCursor
? { borderLeft: `2px solid ${primaryRemoteCursor.userColor}` }
: {}),
}}
onDragOver={(e) => onDragOverBlock?.(e, index)}
onDrop={() => onDropBlock?.(index)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button, a, input, select')) return;
if (!activeFocus && canEdit && textareaRef.current) {
textareaRef.current.focus();
const len = textareaRef.current.value.length;
textareaRef.current.setSelectionRange(len, len);
}
}}
>
{/* Remote Cursors Floating Name Badges on Block */}
{activeRemoteCursors.length > 0 && (
<div className="absolute -top-2.5 right-2 z-20 flex items-center gap-1 select-none pointer-events-none">
{activeRemoteCursors.map((c) => (
<span
key={c.clientId}
className="text-[9px] font-bold px-1.5 py-0.5 rounded-btn text-white shadow-sm flex items-center gap-0.5"
style={{ backgroundColor: c.userColor }}
>
{c.userName}
</span>
))}
</div>
)}
{/* Indentation Guide Line */}
{visualLevel > 0 &&
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
@@ -484,20 +532,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
/>
))}
{/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */}
{/* Bullet Controls: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
{canEdit && (
<button
type="button"
draggable
onDragStart={() => onDragStartBlock?.(index)}
title="Block ziehen oder umwandeln"
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-main cursor-grab active:cursor-grabbing transition-opacity"
>
<GripVertical className="w-3.5 h-3.5" />
</button>
)}
{hasChildren ? (
<button
type="button"
@@ -517,7 +553,38 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<span className="w-3.5" />
)}
{block.type === 'todo' ? (
{/* Bullet handle for dragging and zoom focus */}
<button
type="button"
draggable={canEdit}
onDragStart={() => canEdit && onDragStartBlock?.(index)}
onClick={() => onZoomBlock?.(block.id)}
className={`w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover group/bullet transition-transform ${
canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
}`}
title={
canEdit
? 'Ziehen zum Verschieben • Klick: Ast hineinzoomen (Fokus-Modus)'
: 'Klick: In diesen Ast hineinzoomen (Fokus-Modus)'
}
>
<span
className={`w-1.5 h-1.5 rounded-full transition-all duration-150 ${
block.collapsed && hasChildren
? 'bg-accent ring-2 ring-accent/30'
: activeFocus
? 'bg-accent scale-125'
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
} ${
isEmpty
? 'opacity-0 group-hover:opacity-100 hover:opacity-100'
: 'opacity-100'
}`}
/>
</button>
{/* If To-Do block: checkbox alongside bullet */}
{block.type === 'todo' && (
<button
type="button"
disabled={!canEdit}
@@ -533,28 +600,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
>
<Check className="w-3 h-3 stroke-[3]" />
</button>
) : (
<button
type="button"
onClick={() => onZoomBlock?.(block.id)}
className="w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover group/bullet transition-transform"
title="Klick: In diesen Ast hineinzoomen (Fokus-Modus)"
>
<span
className={`w-1.5 h-1.5 rounded-full transition-all ${
block.collapsed && hasChildren
? 'bg-accent ring-2 ring-accent/30'
: isFocused
? 'bg-accent scale-125'
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
}`}
/>
</button>
)}
</div>
{/* Main Block Content */}
<div className="flex-1 min-w-0 pr-1 relative">
<div className="flex-1 min-w-0 pr-1 relative min-h-[1.5rem]">
{block.type === 'divider' ? (
<div className="py-2">
<hr className="border-t border-border" />
@@ -581,9 +631,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onClick={handleClick}
onKeyUp={handleKeyUp}
onSelect={handleSelect}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
@@ -601,16 +648,133 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onClick={handleClick}
onKeyUp={handleKeyUp}
onSelect={handleSelect}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
/>
</div>
) : (
<div className="relative w-full">
<div className="relative w-full min-h-[1.5rem]">
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
<div
onClick={() => {
if (!activeFocus && canEdit && textareaRef.current) {
textareaRef.current.focus();
const len = textareaRef.current.value.length;
textareaRef.current.setSelectionRange(len, len);
}
}}
className={`w-full min-h-[1.5rem] leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text'
} ${
block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight'
: block.type === 'h2'
? 'text-base font-semibold text-main'
: block.type === 'h3'
? 'text-sm font-semibold text-main'
: block.type === 'todo' && block.checked
? 'text-sm text-faint line-through'
: 'text-sm text-main'
}`}
>
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
<span className={activeFocus ? 'invisible' : ''}>
{localContent ? renderInlineMarkdown(localContent) : '\u200b'}
</span>
{/* Inline Badge and 3-Dots directly behind the text */}
{block.type === 'todo' && (
<span className="inline-flex items-center align-middle ml-1.5 gap-1 shrink-0 pointer-events-auto z-10 relative select-none">
{block.taskId && (
<span
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.2 rounded font-medium shrink-0"
title="Mit erweiterter Aufgabe synchronisiert"
>
<CheckSquare className="w-2.5 h-2.5" />
<span>Erweitert</span>
</span>
)}
{canEdit && (
<span className="relative inline-flex items-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowTodoMenu((prev) => !prev);
}}
className="p-0.5 text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="Aufgaben-Optionen"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showTodoMenu && (
<div
className="absolute left-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
onClick={(e) => e.stopPropagation()}
>
{!block.taskId ? (
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
const targetDocId = docId || block.documentId || block.docId || '';
const thought = await api.createEnhancedTask(targetDocId, block.id, {
content: localContent || 'Aufgabe',
checked: Boolean(block.checked),
});
const updatedBlock: DocumentBlock = {
...latestBlockRef.current,
content: localContent,
taskId: thought.id,
type: 'todo',
};
onUpdateBlockRef.current(updatedBlock, true);
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
}
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
>
<CheckSquare className="w-3.5 h-3.5 text-accent" />
<span>In erweiterte Aufgabe umwandeln</span>
</button>
) : (
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
if (block.taskId) {
await api.unlinkEnhancedTask(block.taskId).catch(() => {});
}
const updatedBlock: DocumentBlock = {
...latestBlockRef.current,
content: localContent,
taskId: '',
type: 'todo',
};
onUpdateBlockRef.current(updatedBlock, true);
} catch (err: any) {
alert(err.message || 'Fehler beim Aufheben der Verknüpfung');
}
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-status-warn flex items-center gap-1.5 text-main font-medium cursor-pointer"
>
<X className="w-3.5 h-3.5 text-status-warn" />
<span>Verknüpfung aufheben</span>
</button>
)}
</div>
)}
</span>
)}
</span>
)}
</div>
<textarea
ref={(el) => {
textareaRef.current = el;
@@ -621,17 +785,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onClick={handleClick}
onKeyUp={handleKeyUp}
onSelect={handleSelect}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
placeholder={
index === 0 && level === 0
? 'Schreibe einen Punkt… (Enter = Neu, Tab = Einrücken, Shift+Tab = Ausrücken, / für Befehle)'
: ''
}
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${
onPaste={handlePaste}
className={`w-full min-h-[1.5rem] resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
} ${
block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight'
: block.type === 'h2'
@@ -643,45 +802,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
: 'text-sm text-main'
}`}
/>
{/* Remote Caret & Floating Name Tag Overlay */}
{primaryRemoteCursor &&
primaryRemoteCursor.selectionStart !== undefined &&
primaryRemoteCursor.selectionStart !== null && (
<div
aria-hidden="true"
className={`pointer-events-none absolute inset-0 overflow-hidden leading-relaxed whitespace-pre-wrap break-words select-none z-10 ${
block.type === 'h1'
? 'text-lg font-bold tracking-tight'
: block.type === 'h2'
? 'text-base font-semibold'
: block.type === 'h3'
? 'text-sm font-semibold'
: block.type === 'todo' && block.checked
? 'text-sm text-faint line-through'
: 'text-sm'
}`}
>
<span className="opacity-0">
{localContent.slice(
0,
Math.min(primaryRemoteCursor.selectionStart, localContent.length)
)}
</span>
<span className="relative inline-block align-baseline">
<span
className="inline-block w-[2px] h-[1.15em] -mb-0.5 animate-pulse"
style={{ backgroundColor: primaryRemoteCursor.userColor }}
/>
<span
className="absolute -top-3.5 left-0 text-[8px] font-bold px-1 py-0 rounded-btn text-white whitespace-nowrap shadow-sm"
style={{ backgroundColor: primaryRemoteCursor.userColor }}
>
{primaryRemoteCursor.userName}
</span>
</span>
</div>
)}
</div>
)}
@@ -804,15 +924,23 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<Maximize2 className="w-3.5 h-3.5" />
</button>
{/* Delete button on hover */}
{/* Delete button on hover (Trash2 icon) */}
{canEdit && (
<button
type="button"
onClick={() => onDeleteBlockRef.current(block.id)}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0"
onClick={() => {
if (hasChildren) {
if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) {
onDeleteBlockRef.current(block.id, true);
}
} else {
onDeleteBlockRef.current(block.id, false);
}
}}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer"
title="Block löschen"
>
<X className="w-3.5 h-3.5" />
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
+446 -97
View File
@@ -1,42 +1,47 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Plus } from 'lucide-react';
import { DocumentBlock } from '../../lib/types';
import { RemoteCursor } from '../../lib/useDocumentSync';
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { OutlinerBlock } from './OutlinerBlock';
export interface OutlinerTreeHandle {
focusFirstBlock: () => void;
focusBlock: (blockId: string, cursor?: 'start' | 'end') => void;
}
export interface OutlinerTreeProps {
docId: string;
blocks: DocumentBlock[];
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
remoteCursors?: Record<string, RemoteCursor>;
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
onDeleteBlock: (blockId: string) => void;
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
onOpenTask?: (taskId: string) => void;
onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
initialFocusedBlockId?: string | null;
}
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
docId,
blocks,
canEdit,
levelOffset = 0,
onZoomBlock,
remoteCursors,
sendCursorMoved,
onUpdateBlock,
onUpdateBlocks,
onCreateBlock,
onInsertBlocks,
onDeleteBlock,
onMoveBlock,
onOpenTask,
onTagClick,
onWikiLinkClick,
initialFocusedBlockId,
}) => {
}, ref) => {
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
initialFocusedBlockId || null
@@ -73,6 +78,32 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
[]
);
useImperativeHandle(
ref,
() => ({
focusFirstBlock: () => {
if (blocks.length > 0) {
focusBlockInput(blocks[0].id, 'end');
} else {
const firstBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_0`,
documentId: docId,
type: 'text',
content: '',
level: levelOffset || 0,
position: 1000,
};
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'end' };
onCreateBlock(firstBlock);
}
},
focusBlock: (blockId: string, cursor: 'start' | 'end' = 'end') => {
focusBlockInput(blockId, cursor);
},
}),
[blocks, docId, levelOffset, onCreateBlock, focusBlockInput]
);
const registerTextareaRef = useCallback((blockId: string, el: HTMLTextAreaElement | null) => {
if (el) {
textareaMapRef.current.set(blockId, el);
@@ -164,22 +195,21 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
}
return result;
}, [blocks]);
}, [blocks, levelOffset]);
const handleCreateBlockBelow = useCallback(
(afterBlockId: string, _currentContent: string) => {
(afterBlockId: string, newContent: string = '') => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === afterBlockId);
const currentBlock = idx >= 0 ? blocks[idx] : null;
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
const inheritedType =
currentBlock && currentBlock.type === 'todo' ? 'todo' : 'text';
const inheritedType: BlockType = 'text';
const newBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
documentId: docId,
type: inheritedType,
content: '',
content: newContent,
level,
checked: false,
collapsed: false,
@@ -193,8 +223,252 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
[blocks, canEdit, docId, onCreateBlock]
);
const handlePasteMultiLines = useCallback(
(blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => {
if (!canEdit || rawLines.length === 0) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
const currentBlock = blocks[idx];
const baseLevel = Math.max(0, currentBlock.level || 0);
// Detect indentation style (2-space vs 4-space tabs)
let detectedSpaceStep = 2;
const leadingSpacesList: number[] = [];
for (const line of rawLines) {
if (!line.trim()) continue;
let sp = 0;
while (sp < line.length && line[sp] === ' ') sp++;
if (sp > 0) leadingSpacesList.push(sp);
}
if (leadingSpacesList.length > 0 && leadingSpacesList.every((s) => s % 4 === 0)) {
detectedSpaceStep = 4;
}
// Parse lines into structured blocks
interface ParsedPasteItem {
type: BlockType;
checked?: boolean;
content: string;
indent: number;
}
const parsed: ParsedPasteItem[] = [];
let currentItem: ParsedPasteItem | null = null;
let inCodeBlock = false;
let afterHeading = false;
const pushCurrentItem = () => {
if (currentItem) {
currentItem.content = currentItem.content.trimEnd();
parsed.push(currentItem);
currentItem = null;
}
};
for (let i = 0; i < rawLines.length; i++) {
const rawLine = rawLines[i];
// 1. Inside a fenced code block
if (inCodeBlock) {
if (currentItem) {
currentItem.content += '\n' + rawLine;
}
if (rawLine.trim().startsWith('```')) {
inCodeBlock = false;
}
continue;
}
// 2. Starts a code fence
if (rawLine.trim().startsWith('```')) {
inCodeBlock = true;
if (afterHeading) {
afterHeading = false;
pushCurrentItem();
currentItem = {
type: 'text',
content: rawLine,
indent: 0,
};
} else if (currentItem) {
currentItem.content += (currentItem.content.endsWith('\n') ? '' : '\n') + rawLine;
} else {
currentItem = {
type: 'text',
content: rawLine,
indent: 0,
};
}
continue;
}
// Measure leading indent
let tabs = 0;
let spaces = 0;
let charIdx = 0;
while (charIdx < rawLine.length) {
if (rawLine[charIdx] === '\t') {
tabs++;
charIdx++;
} else if (rawLine[charIdx] === ' ') {
spaces++;
charIdx++;
} else {
break;
}
}
const strippedLine = rawLine.slice(charIdx);
const lineIndent = tabs + Math.floor(spaces / detectedSpaceStep);
// Blank lines
if (strippedLine.trim() === '') {
if (currentItem) {
currentItem.content += '\n';
}
continue;
}
// Headings: #, ##, ###
const h3Match = strippedLine.match(/^###\s+(.*)$/);
const h2Match = strippedLine.match(/^##\s+(.*)$/);
const h1Match = strippedLine.match(/^#\s+(.*)$/);
if (h1Match || h2Match || h3Match) {
pushCurrentItem();
afterHeading = true;
const type: BlockType = h1Match ? 'h1' : h2Match ? 'h2' : 'h3';
const content = (h1Match || h2Match || h3Match)![1];
parsed.push({ type, content, indent: lineIndent });
continue;
}
// Dividers: ---, ***, ___
if (/^(?:---|---|\*\*\*|___)\s*$/.test(strippedLine.trim())) {
pushCurrentItem();
afterHeading = false;
parsed.push({ type: 'divider', content: '---', indent: lineIndent });
continue;
}
// Todo items: - [ ], - [x], * [ ], * [x]
const todoMatch = strippedLine.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/);
if (todoMatch) {
pushCurrentItem();
afterHeading = false;
parsed.push({
type: 'todo',
checked: todoMatch[1].toLowerCase() === 'x',
content: todoMatch[2],
indent: lineIndent,
});
continue;
}
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
const numMatch = strippedLine.match(/^\d+[\.\)]\s+(.*)$/);
if (bulletMatch || numMatch) {
pushCurrentItem();
afterHeading = false;
parsed.push({
type: 'text',
content: (bulletMatch ? bulletMatch[1] : numMatch![1]),
indent: lineIndent,
});
continue;
}
// Regular text line
if (afterHeading) {
// This starts the single bullet point after the heading
afterHeading = false;
pushCurrentItem();
currentItem = {
type: 'text',
content: strippedLine,
indent: lineIndent,
};
} else if (currentItem) {
if (currentItem.content.endsWith('\n')) {
currentItem.content += strippedLine;
} else {
currentItem.content += '\n' + strippedLine;
}
} else {
currentItem = {
type: 'text',
content: strippedLine,
indent: lineIndent,
};
}
}
pushCurrentItem();
if (parsed.length === 0) return;
const line0 = parsed[0];
const baseIndent = line0.indent;
const isSingle = parsed.length === 1;
const line0Content = textBefore + line0.content + (isSingle ? textAfter : '');
const isCurrentEmpty = (currentBlock.content || '').trim() === '';
const adoptedType = line0.type !== 'text' ? line0.type : (isCurrentEmpty ? 'text' : currentBlock.type);
const adoptedChecked = line0.type === 'todo' ? (line0.checked ?? false) : currentBlock.checked;
const updatedCurrentBlock: DocumentBlock = {
...currentBlock,
content: line0Content,
type: adoptedType,
checked: adoptedChecked,
};
const newBlocks: DocumentBlock[] = [];
const nowBase = Date.now().toString(36);
for (let j = 1; j < parsed.length; j++) {
const p = parsed[j];
const isLastLine = j === parsed.length - 1;
const lineContent = isLastLine ? (p.content + textAfter) : p.content;
const lineLevel = Math.max(0, baseLevel + (p.indent - baseIndent));
newBlocks.push({
id: `blk_${nowBase}_${j}_${Math.random().toString(36).slice(2, 6)}`,
documentId: docId,
type: p.type,
checked: p.checked || false,
content: lineContent,
level: lineLevel,
collapsed: false,
position: (currentBlock.position || 1000) + j * 10,
});
}
const lastTargetId = newBlocks.length > 0 ? newBlocks[newBlocks.length - 1].id : currentBlock.id;
const existingEl = textareaMapRef.current.get(lastTargetId);
if (existingEl) {
pendingFocusRef.current = null;
focusBlockInput(lastTargetId, 'end');
} else {
pendingFocusRef.current = { blockId: lastTargetId, cursor: 'end' };
}
setFocusedBlockId(lastTargetId);
if (onInsertBlocks) {
onInsertBlocks(currentBlock.id, updatedCurrentBlock, newBlocks);
} else {
onUpdateBlock(updatedCurrentBlock, true);
for (let k = 0; k < newBlocks.length; k++) {
const afterId = k === 0 ? currentBlock.id : newBlocks[k - 1].id;
onCreateBlock(newBlocks[k], afterId);
}
}
},
[blocks, canEdit, docId, focusBlockInput, onCreateBlock, onInsertBlocks, onUpdateBlock]
);
const handleDeleteBlock = useCallback(
(blockId: string) => {
(blockId: string, deleteSubtree = false) => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
@@ -219,9 +493,25 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
if (targetFocusBlock) {
focusBlockInput(targetFocusBlock.id, 'end');
}
onDeleteBlock(blockId);
if (deleteSubtree) {
const rootLevel = blocks[idx].level || 0;
const idsToDelete = [blockId];
for (let j = idx + 1; j < blocks.length; j++) {
if ((blocks[j].level || 0) > rootLevel) {
idsToDelete.push(blocks[j].id);
} else {
break;
}
}
for (const id of idsToDelete) {
onDeleteBlock(id);
}
} else {
onDeleteBlock(blockId);
}
},
[blocks, canEdit, focusBlockInput, onDeleteBlock, onUpdateBlock]
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock]
);
const handleIndentBlock = useCallback(
@@ -233,9 +523,9 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
const current = blocks[idx];
const currentLevel = Math.max(0, current.level || 0);
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 0;
const maxLevel = idx === 0 ? 0 : prevLevel + 1;
const minLevel = levelOffset || 0;
const maxLevel = idx === 0 ? minLevel : prevLevel + 1;
const nextLevel =
delta === 1
? Math.min(maxLevel, currentLevel + 1)
@@ -243,15 +533,38 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
if (nextLevel === currentLevel) return;
onUpdateBlock(
{
...current,
level: nextLevel,
},
true
);
const actualDelta = nextLevel - currentLevel;
// Find all contiguous children / descendants:
// contiguous blocks with level > currentLevel
let count = 1;
for (let i = idx + 1; i < blocks.length; i++) {
if (Math.max(0, blocks[i].level || 0) > currentLevel) {
count++;
} else {
break;
}
}
const updatedBlocks: DocumentBlock[] = [];
for (let i = idx; i < idx + count; i++) {
const b = blocks[i];
const newLvl = Math.max(minLevel, (b.level || 0) + actualDelta);
updatedBlocks.push({
...b,
level: newLvl,
});
}
if (onUpdateBlocks) {
onUpdateBlocks(updatedBlocks);
} else {
for (const b of updatedBlocks) {
onUpdateBlock(b, true);
}
}
},
[blocks, canEdit, onUpdateBlock]
[blocks, canEdit, levelOffset, onUpdateBlock, onUpdateBlocks]
);
const handleNavigateFocus = useCallback(
@@ -274,10 +587,29 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
setDraggedIndex(index);
}, []);
const handleDragOverBlock = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverIndex(index);
}, []);
const handleDragOverBlock = useCallback(
(e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex !== null) {
const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0);
let count = 1;
for (let i = draggedIndex + 1; i < blocks.length; i++) {
if (Math.max(0, blocks[i].level || 0) > parentLevel) {
count++;
} else {
break;
}
}
// Do not allow dropping inside its own subtree (self or descendants)
if (index >= draggedIndex && index < draggedIndex + count) {
setDragOverIndex(null);
return;
}
}
setDragOverIndex(index);
},
[blocks, draggedIndex]
);
const handleDropBlock = useCallback(
(targetIndex: number) => {
@@ -286,6 +618,20 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
setDragOverIndex(null);
return;
}
const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0);
let count = 1;
for (let i = draggedIndex + 1; i < blocks.length; i++) {
if (Math.max(0, blocks[i].level || 0) > parentLevel) {
count++;
} else {
break;
}
}
if (targetIndex >= draggedIndex && targetIndex < draggedIndex + count) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
}
const movingBlock = blocks[draggedIndex];
if (movingBlock) {
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
@@ -305,78 +651,81 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
return () => window.removeEventListener('dragend', clearDrag);
}, []);
const cursorsByBlock = useMemo(() => {
const map: Record<string, RemoteCursor[]> = {};
if (!remoteCursors) return map;
for (const c of Object.values(remoteCursors)) {
if (c.blockId) {
if (!map[c.blockId]) map[c.blockId] = [];
map[c.blockId].push(c);
}
}
return map;
}, [remoteCursors]);
return (
<div className="space-y-0.5">
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => (
<OutlinerBlock
key={block.id}
block={block}
index={index}
maxAllowedLevel={maxAllowedLevel}
hasChildren={hasChildren}
isFocused={focusedBlockId === block.id}
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={onZoomBlock}
activeRemoteCursors={cursorsByBlock[block.id] || []}
sendCursorMoved={sendCursorMoved}
onFocusBlock={setFocusedBlockId}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onDeleteBlock={handleDeleteBlock}
onIndentBlock={handleIndentBlock}
onNavigateFocus={handleNavigateFocus}
onTagClick={onTagClick}
onWikiLinkClick={onWikiLinkClick}
onDragStartBlock={handleDragStartBlock}
onDragOverBlock={handleDragOverBlock}
onDropBlock={handleDropBlock}
isDragTarget={dragOverIndex === index && draggedIndex !== index}
isTargeted={highlightedBlockId === block.id}
registerTextareaRef={registerTextareaRef}
/>
))}
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
const isLast = entryIdx === visibleEntries.length - 1;
return (
<OutlinerBlock
key={block.id}
docId={docId}
block={block}
index={index}
isLast={isLast}
maxAllowedLevel={maxAllowedLevel}
hasChildren={hasChildren}
isFocused={focusedBlockId === block.id}
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={onZoomBlock}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines}
onDeleteBlock={handleDeleteBlock}
onIndentBlock={handleIndentBlock}
onNavigateFocus={handleNavigateFocus}
onFocusBlock={setFocusedBlockId}
onOpenTask={onOpenTask}
onTagClick={onTagClick}
onWikiLinkClick={onWikiLinkClick}
onDragStartBlock={handleDragStartBlock}
onDragOverBlock={handleDragOverBlock}
onDropBlock={handleDropBlock}
isDragTarget={dragOverIndex === index && draggedIndex !== index}
isTargeted={highlightedBlockId === block.id}
registerTextareaRef={registerTextareaRef}
/>
);
})}
{canEdit && (
<div className="pt-2 pl-2">
<button
type="button"
onClick={() => {
<div
className={`min-h-[14rem] pt-2 pl-2 transition-colors cursor-text ${
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
}`}
onClick={(e) => {
if (e.target !== e.currentTarget && (e.target as HTMLElement).closest('button, a, input, textarea, select')) {
return;
}
if (blocks.length === 0) {
const firstBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_0`,
documentId: docId,
type: 'text',
content: '',
level: levelOffset || 0,
position: 1000,
};
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
onCreateBlock(firstBlock);
} else {
const lastBlock = blocks[blocks.length - 1];
if (lastBlock) {
handleCreateBlockBelow(lastBlock.id, lastBlock.content);
if (lastBlock.content && lastBlock.content.trim() !== '') {
handleCreateBlockBelow(lastBlock.id, '');
} else {
const firstBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_0`,
documentId: docId,
type: 'text',
content: '',
level: levelOffset || 0,
position: 1000,
};
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
onCreateBlock(firstBlock);
focusBlockInput(lastBlock.id, 'end');
}
}}
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main"
>
<Plus className="w-3.5 h-3.5" />
<span>Neuer Punkt</span>
</button>
</div>
}
}}
onDragOver={(e) => {
e.preventDefault();
if (draggedIndex !== null) {
setDragOverIndex(blocks.length);
}
}}
onDrop={() => handleDropBlock(blocks.length)}
/>
)}
</div>
);
};
});
File diff suppressed because it is too large. Load diff
+93 -34
View File
@@ -5,7 +5,6 @@ import {
Document,
DocumentBlock,
DocShare,
DocLockInfo,
KanbanColumn,
ListItem,
ListShare,
@@ -37,6 +36,19 @@ export function getToken(): string | null {
return localStorage.getItem(TOKEN_STORAGE_KEY);
}
export function getCurrentUserId(): string | null {
const token = getToken();
if (!token) return null;
try {
const parts = token.split('.');
if (parts.length >= 2) {
const payload = JSON.parse(atob(parts[1]));
return payload.userId || payload.sub || payload.id || null;
}
} catch {}
return null;
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_STORAGE_KEY, token);
}
@@ -117,6 +129,18 @@ export const api = {
return request<AuthStatus>('/api/v1/auth/status');
},
async logout(): Promise<void> {
try {
await request<{ success: boolean }>('/api/v1/auth/logout', { method: 'POST' });
} finally {
clearToken();
}
},
async revokeAllSessions(): Promise<void> {
await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' });
},
async completeSetup(payload: {
email: string;
password: string;
@@ -285,6 +309,10 @@ export const api = {
return ensureArray(res);
},
async getThought(thoughtId: string): Promise<Thought> {
return request<Thought>(`/api/v1/thoughts/${encodeURIComponent(thoughtId)}`);
},
async triageAction(
thoughtId: string,
action: 'keep' | 'discard' | 'restore',
@@ -625,6 +653,23 @@ export const api = {
};
},
async createEnhancedTask(
docId: string,
blockId: string,
payload: { content: string; checked: boolean }
): Promise<Thought> {
return request<Thought>(`/api/v1/documents/${docId}/blocks/${blockId}/enhanced-task`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
async unlinkEnhancedTask(taskId: string): Promise<{ unlinked: boolean }> {
return request<{ unlinked: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/unlink`, {
method: 'POST',
});
},
// --- DOCUMENT SHARING ---
async getDocShares(docId: string): Promise<DocShare[]> {
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
@@ -772,6 +817,17 @@ export const api = {
});
},
async updateListItem(
listId: string,
itemId: string,
payload: { content?: string; category?: string; quantity?: string }
): Promise<ListItem> {
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
},
async deleteListItem(listId: string, itemId: string): Promise<void> {
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
},
@@ -1077,7 +1133,7 @@ export function onRealtimeStatus(callback: (connected: boolean, transport: Realt
};
}
let isWsBlockedByProxy = false;
let isWsBlockedByProxy = typeof window !== 'undefined' && sessionStorage.getItem('mindsift_ws_blocked') === 'true';
let sseReconnectTimer: any = null;
let sseReconnectDelay = 2000;
const globalSubscribedRooms = new Set<string>();
@@ -1095,6 +1151,25 @@ function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTrans
window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected, transport } }));
}
function switchToSSE(reason: string) {
console.warn(`[MindSift Realtime] ${reason} -> Schalte sofort und dauerhaft auf Server-Sent Events (SSE) um.`);
isWsBlockedByProxy = true;
try {
sessionStorage.setItem('mindsift_ws_blocked', 'true');
} catch {}
clearTimeout(wsConnectionWatchdog);
clearTimeout(wsReconnectTimer);
if (activeGlobalWS) {
try {
activeGlobalWS.onclose = null;
activeGlobalWS.onerror = null;
activeGlobalWS.close();
} catch {}
activeGlobalWS = null;
}
connectSSE();
}
export function connectSSE(rooms?: string[]): void {
if (rooms) {
rooms.forEach((r) => globalSubscribedRooms.add(r));
@@ -1114,7 +1189,6 @@ export function connectSSE(rooms?: string[]): void {
activeGlobalSSE = es;
es.onopen = () => {
console.info('[MindSift Realtime] Server-Sent Events (SSE) verbunden (Proxy-Fallback aktiv).');
sseReconnectDelay = 2000;
notifyRealtimeStatusChange(true, 'sse');
if (activeSSEId && globalSubscribedRooms.size > 0) {
@@ -1149,6 +1223,7 @@ export function connectSSE(rooms?: string[]): void {
};
es.onerror = (e) => {
console.warn('[MindSift Realtime] SSE Verbindungsfehler:', e);
if (activeTransport === 'sse') {
notifyRealtimeStatusChange(false, 'none');
}
@@ -1160,11 +1235,7 @@ export function connectSSE(rooms?: string[]): void {
sseReconnectDelay = Math.min(30000, Math.round(sseReconnectDelay * 1.5));
sseReconnectTimer = setTimeout(() => {
if (!manualDisconnect && getToken()) {
if (isWsBlockedByProxy) {
connectSSE();
} else {
connectWebSocket();
}
connectSSE();
}
}, delay);
}
@@ -1199,16 +1270,12 @@ export function connectWebSocket(): void {
const ws = new WebSocket(url);
activeGlobalWS = ws;
// 3.5s watchdog for corporate proxies silently stripping Upgrade header
// 2.5s watchdog for corporate proxies silently stripping Upgrade header or hanging
wsConnectionWatchdog = setTimeout(() => {
if (ws && ws.readyState !== WebSocket.OPEN) {
console.warn('[MindSift Realtime] WebSocket connection timeout (Proxy Upgrade Block). Wechsle automatisch zu SSE...');
isWsBlockedByProxy = true;
try { ws.close(); } catch {}
activeGlobalWS = null;
connectSSE();
switchToSSE('WebSocket Timeout nach 2.5s (Proxy Upgrade Header blockiert)');
}
}, 3500);
}, 2500);
ws.onopen = () => {
clearTimeout(wsConnectionWatchdog);
@@ -1247,11 +1314,9 @@ export function connectWebSocket(): void {
clearTimeout(wsConnectionWatchdog);
activeGlobalWS = null;
const isProxyIssue = evt?.code === 1006 || evt?.code === 1002 || evt?.code === 1008;
if (isProxyIssue && !manualDisconnect && getToken()) {
console.warn('[MindSift Realtime] Proxy/Firewall Inkompatibilität erkannt (Code ' + evt.code + '). Wechsle dauerhaft zu SSE...');
isWsBlockedByProxy = true;
connectSSE();
// In corporate environments, blocked WebSockets close abruptly without clean handshake
if (!evt.wasClean || evt.code === 1006 || evt.code === 1002 || evt.code === 1008 || evt.code === 1005) {
switchToSSE(`WebSocket unsauber beendet (Code ${evt.code}, wasClean=${evt.wasClean})`);
return;
}
@@ -1273,14 +1338,10 @@ export function connectWebSocket(): void {
ws.onerror = (e) => {
clearTimeout(wsConnectionWatchdog);
try {
ws.close();
} catch {}
switchToSSE('WebSocket Fehler (Handshake/Verbindung verweigert)');
};
} catch (e) {
clearTimeout(wsConnectionWatchdog);
isWsBlockedByProxy = true;
connectSSE();
switchToSSE('WebSocket Exception beim Initialisieren');
}
}
@@ -1394,10 +1455,9 @@ export function createRealtimeConnection(options: {
// Join rooms on the active connection
const joinAll = () => {
joinedRooms.forEach((r) => {
if (r.startsWith('doc:')) {
sendWebSocketMessage({ event: 'CRDT_JOIN', docId: r.slice(4) });
} else {
sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
if (activeSSEId) {
api.joinSSERoom(activeSSEId, r, 'join').catch(() => {});
}
});
};
@@ -1427,10 +1487,9 @@ export function createRealtimeConnection(options: {
if (unsubMsg) unsubMsg();
joinedRooms.forEach((r) => {
globalSubscribedRooms.delete(r);
if (r.startsWith('doc:')) {
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: r.slice(4) });
} else {
sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
if (activeSSEId) {
api.joinSSERoom(activeSSEId, r, 'leave').catch(() => {});
}
});
},
+228
View File
@@ -0,0 +1,228 @@
import React from 'react';
/**
* Parses inline markdown:
* - **bold** or __bold__
* - *italic* or _italic_
* - ~~strikethrough~~
* - `code`
* - [label](url)
*/
function renderInlineTokens(text: string): React.ReactNode[] {
if (!text) return [];
const regex = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|~~[^~]+~~|\*[^*]+\*|_[^_]+_|\[([^\]]+)\]\(([^)]+)\))/g;
const elements: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
elements.push(text.slice(lastIndex, match.index));
}
const token = match[0];
const key = `${match.index}-${token}`;
if (token.startsWith('`') && token.endsWith('`')) {
elements.push(
<code key={key} className="px-1.5 py-0.5 rounded bg-panel-elevated text-accent font-mono text-[0.9em] border border-border/50">
{token.slice(1, -1)}
</code>
);
} else if (
(token.startsWith('**') && token.endsWith('**')) ||
(token.startsWith('__') && token.endsWith('__'))
) {
elements.push(
<strong key={key} className="font-semibold text-main">
{token.slice(2, -2)}
</strong>
);
} else if (
(token.startsWith('*') && token.endsWith('*')) ||
(token.startsWith('_') && token.endsWith('_'))
) {
elements.push(
<em key={key} className="italic text-main/90">
{token.slice(1, -1)}
</em>
);
} else if (token.startsWith('~~') && token.endsWith('~~')) {
elements.push(
<del key={key} className="line-through text-muted/80">
{token.slice(2, -2)}
</del>
);
} else if (token.startsWith('[') && match[2] && match[3]) {
const label = match[2];
const url = match[3];
elements.push(
<a
key={key}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline hover:opacity-80 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
{label}
</a>
);
} else {
elements.push(token);
}
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
elements.push(text.slice(lastIndex));
}
return elements;
}
/**
* Parses inline markdown and fenced code blocks:
* - ```lang\ncode\n```
* - **bold** or __bold__
* - *italic* or _italic_
* - ~~strikethrough~~
* - `code`
* - [label](url)
*/
export function renderInlineMarkdown(text: string): React.ReactNode[] {
if (!text) return [];
if (text.includes('```')) {
const codeBlockRegex = /(```[a-zA-Z0-9_-]*\r?\n[\s\S]*?```|```[\s\S]*?```)/g;
const parts = text.split(codeBlockRegex);
const nodes: React.ReactNode[] = [];
parts.forEach((part, partIdx) => {
if (part.startsWith('```') && part.endsWith('```')) {
const lines = part.split(/\r?\n/);
const firstLine = lines[0].slice(3).trim();
const codeLines = lines.slice(1, lines.length - 1);
const code = codeLines.join('\n');
nodes.push(
<div
key={`fenced-${partIdx}`}
className="p-2.5 my-2 rounded bg-code text-accent font-mono text-xs overflow-x-auto border border-border/60 select-text"
>
{firstLine && (
<div className="text-[10px] text-faint uppercase font-sans pb-1 select-none">
{firstLine}
</div>
)}
<pre className="whitespace-pre overflow-x-auto font-mono text-status-ok leading-relaxed">
<code>{code}</code>
</pre>
</div>
);
} else if (part) {
nodes.push(...renderInlineTokens(part));
}
});
return nodes;
}
return renderInlineTokens(text);
}
export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) {
if (!content) return null;
const lines = content.split('\n');
const renderedNodes: React.ReactNode[] = [];
let inCodeBlock = false;
let codeBlockLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith('```')) {
if (inCodeBlock) {
renderedNodes.push(
<pre key={`code-${i}`} className="p-2.5 my-2 rounded bg-panel-elevated text-accent font-mono text-xs overflow-x-auto border border-border/60">
<code>{codeBlockLines.join('\n')}</code>
</pre>
);
codeBlockLines = [];
inCodeBlock = false;
} else {
inCodeBlock = true;
codeBlockLines = [];
}
continue;
}
if (inCodeBlock) {
codeBlockLines.push(line);
continue;
}
const trimmed = line.trim();
if (trimmed.startsWith('### ')) {
renderedNodes.push(
<h3 key={i} className="text-sm font-bold text-main mt-2 mb-1">
{renderInlineMarkdown(trimmed.slice(4))}
</h3>
);
} else if (trimmed.startsWith('## ')) {
renderedNodes.push(
<h2 key={i} className="text-base font-bold text-main mt-3 mb-1">
{renderInlineMarkdown(trimmed.slice(3))}
</h2>
);
} else if (trimmed.startsWith('# ')) {
renderedNodes.push(
<h1 key={i} className="text-lg font-bold text-main mt-3 mb-1.5">
{renderInlineMarkdown(trimmed.slice(2))}
</h1>
);
} else if (trimmed.startsWith('> ')) {
renderedNodes.push(
<blockquote key={i} className="border-l-2 border-accent pl-2.5 my-1 text-muted italic">
{renderInlineMarkdown(trimmed.slice(2))}
</blockquote>
);
} else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
renderedNodes.push(
<div key={i} className="flex items-start gap-1.5 my-0.5">
<span className="text-accent mt-0.5">•</span>
<span className="flex-1 text-main">{renderInlineMarkdown(trimmed.slice(2))}</span>
</div>
);
} else if (/^\d+\.\s/.test(trimmed)) {
const matchNum = trimmed.match(/^(\d+)\.\s(.*)$/);
renderedNodes.push(
<div key={i} className="flex items-start gap-1.5 my-0.5">
<span className="text-muted text-[11px] font-mono mt-0.5">{matchNum ? matchNum[1] + '.' : '•'}</span>
<span className="flex-1 text-main">{renderInlineMarkdown(matchNum ? matchNum[2] : trimmed)}</span>
</div>
);
} else if (trimmed === '') {
renderedNodes.push(<div key={i} className="h-2" />);
} else {
renderedNodes.push(
<div key={i} className="my-0.5 text-main leading-relaxed">
{renderInlineMarkdown(line)}
</div>
);
}
}
if (inCodeBlock && codeBlockLines.length > 0) {
renderedNodes.push(
<pre key="code-end" className="p-2.5 my-2 rounded bg-panel-elevated text-accent font-mono text-xs overflow-x-auto border border-border/60">
<code>{codeBlockLines.join('\n')}</code>
</pre>
);
}
return <div className={`markdown-body text-xs ${className}`}>{renderedNodes}</div>;
}
+4 -8
View File
@@ -53,8 +53,10 @@ export interface Thought {
source: 'manual' | 'voice' | 'webhook';
status: ThoughtStatus;
isCompleted: boolean;
convertedType?: 'task' | 'document' | 'list_item';
convertedType?: 'task' | 'document' | 'list_item' | 'document_block';
convertedRefId?: string;
docId?: string;
blockId?: string;
dueDate?: string;
taskListId?: string;
kanbanStatus?: string;
@@ -159,6 +161,7 @@ export interface DocumentBlock {
id: string;
docId?: string;
documentId?: string;
taskId?: string;
type: BlockType;
content: string;
checked?: boolean;
@@ -200,13 +203,6 @@ export interface DocShare {
createdAt: string;
}
export interface DocLockInfo {
docId: string;
userId: string;
userName: string;
expiresAt: string;
}
export interface Document {
id: string;
userId: string;
-553
View File
@@ -1,553 +0,0 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { DocumentBlock } from './types';
import { getToken, api } from './api';
export type DocSyncStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline';
export interface RemoteCursor {
clientId: string;
userId: string;
userName: string;
userColor: string;
blockId: string | null;
selectionStart?: number;
selectionEnd?: number;
updatedAt: number;
}
export const CURSOR_COLORS = [
'#3b82f6', // blue
'#10b981', // emerald
'#f59e0b', // amber
'#ec4899', // pink
'#8b5cf6', // purple
'#06b6d4', // cyan
'#f97316', // orange
'#14b8a6', // teal
'#6366f1', // indigo
];
export function getUserColor(idOrName: string): string {
if (!idOrName) return CURSOR_COLORS[0];
let hash = 0;
for (let i = 0; i < idOrName.length; i++) {
hash = (hash << 5) - hash + idOrName.charCodeAt(i);
hash |= 0;
}
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
}
export interface DocSyncEvent {
type:
| 'doc_state'
| 'block_created'
| 'block_updated'
| 'block_deleted'
| 'block_moved'
| 'title_updated'
| 'blocks_replaced'
| 'cursor_moved'
| 'client_left';
docId?: string;
clientId?: string;
userId?: string;
userName?: string;
userColor?: string;
selectionStart?: number;
selectionEnd?: number;
activeCursors?: RemoteCursor[];
title?: string;
block?: DocumentBlock;
blockId?: string;
afterBlockId?: string;
newIndex?: number;
content?: string;
level?: number;
checked?: boolean;
collapsed?: boolean;
blockType?: string;
blocks?: DocumentBlock[];
}
export function useDocumentSync(
docId: string | null,
onRemoteEvent: (evt: DocSyncEvent) => void,
canEdit: boolean = true,
currentUser?: { id: string; email?: string; displayName?: string } | null
) {
const [status, setStatus] = useState<DocSyncStatus>('connecting');
const [remoteCursors, setRemoteCursors] = useState<Record<string, RemoteCursor>>({});
const wsRef = useRef<WebSocket | null>(null);
const clientIdRef = useRef<string>(
`cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
);
const onRemoteEventRef = useRef(onRemoteEvent);
onRemoteEventRef.current = onRemoteEvent;
const docIdRef = useRef(docId);
docIdRef.current = docId;
const canEditRef = useRef(canEdit);
canEditRef.current = canEdit;
const currentUserRef = useRef(currentUser);
currentUserRef.current = currentUser;
const reconnectTimerRef = useRef<number | null>(null);
const fallbackTimerRef = useRef<number | null>(null);
const localUserName =
currentUser?.displayName || currentUser?.email?.split('@')[0] || 'Anonym';
const localUserColor = useMemo(
() => getUserColor(currentUser?.id || clientIdRef.current),
[currentUser?.id]
);
const localUserNameRef = useRef(localUserName);
localUserNameRef.current = localUserName;
const localUserColorRef = useRef(localUserColor);
localUserColorRef.current = localUserColor;
const pendingMessagesQueueRef = useRef<string[]>([]);
const lastCursorSendRef = useRef<number>(0);
const pendingCursorTimerRef = useRef<number | null>(null);
const pendingCursorPayloadRef = useRef<{
blockId: string | null;
selectionStart?: number;
selectionEnd?: number;
} | null>(null);
// Clean up stale remote cursors (> 30s)
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
setRemoteCursors((prev) => {
let hasStale = false;
const next: Record<string, RemoteCursor> = {};
for (const [key, cursor] of Object.entries(prev)) {
if (now - cursor.updatedAt < 30000) {
next[key] = cursor;
} else {
hasStale = true;
}
}
return hasStale ? next : prev;
});
}, 5000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (!docId) {
setStatus('offline');
setRemoteCursors({});
pendingMessagesQueueRef.current = [];
return;
}
pendingMessagesQueueRef.current = [];
let isDisposed = false;
let retryCount = 0;
const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
clientIdRef.current = currentClientId;
const connect = () => {
if (isDisposed) return;
const token = getToken();
if (!token) {
setStatus('offline');
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(
docId
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(
currentClientId
)}&userName=${encodeURIComponent(localUserName)}&userColor=${encodeURIComponent(
localUserColor
)}`;
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
let ws: WebSocket;
try {
ws = new WebSocket(wsUrl);
} catch {
setStatus('offline');
return;
}
wsRef.current = ws;
ws.onopen = () => {
if (isDisposed) {
ws.close();
return;
}
retryCount = 0;
setStatus('connected');
// Flush any queued messages buffered while connecting or reconnecting
if (pendingMessagesQueueRef.current.length > 0) {
const queue = [...pendingMessagesQueueRef.current];
pendingMessagesQueueRef.current = [];
for (const msg of queue) {
try {
ws.send(msg);
} catch (err) {
console.warn('[DocSync] Failed to flush queued message:', err);
}
}
}
};
ws.onmessage = (event) => {
if (isDisposed) return;
try {
const parsed: DocSyncEvent = JSON.parse(event.data);
if (!parsed || !parsed.type) return;
// Echo protection: ignore events originating from our own clientId (except initial doc_state)
if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) {
return;
}
if (parsed.type === 'doc_state') {
if (Array.isArray(parsed.activeCursors)) {
const initialCursors: Record<string, RemoteCursor> = {};
for (const c of parsed.activeCursors) {
if (c.clientId && c.clientId !== currentClientId) {
initialCursors[c.clientId] = {
...c,
updatedAt: c.updatedAt || Date.now(),
};
}
}
setRemoteCursors(initialCursors);
}
} else if (parsed.type === 'cursor_moved') {
if (parsed.clientId && parsed.clientId !== currentClientId) {
setRemoteCursors((prev) => ({
...prev,
[parsed.clientId!]: {
clientId: parsed.clientId!,
userId: parsed.userId || '',
userName: parsed.userName || 'Anonym',
userColor: parsed.userColor || '#3b82f6',
blockId:
parsed.blockId !== undefined
? parsed.blockId
: prev[parsed.clientId!]?.blockId ?? null,
selectionStart: parsed.selectionStart,
selectionEnd: parsed.selectionEnd,
updatedAt: Date.now(),
},
}));
}
} else if (parsed.type === 'client_left') {
if (parsed.clientId) {
setRemoteCursors((prev) => {
if (!prev[parsed.clientId!]) return prev;
const next = { ...prev };
delete next[parsed.clientId!];
return next;
});
}
}
onRemoteEventRef.current(parsed);
} catch {
// Ignore malformed frames
}
};
ws.onerror = () => {
// Handled in onclose
};
ws.onclose = () => {
if (isDisposed) return;
wsRef.current = null;
retryCount += 1;
setStatus(retryCount > 2 ? 'offline' : 'reconnecting');
const delay = Math.min(1000 * Math.pow(1.6, retryCount), 8000);
reconnectTimerRef.current = window.setTimeout(connect, delay);
};
};
connect();
return () => {
isDisposed = true;
if (reconnectTimerRef.current !== null) {
window.clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (fallbackTimerRef.current !== null) {
window.clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}
if (pendingCursorTimerRef.current !== null) {
window.clearTimeout(pendingCursorTimerRef.current);
pendingCursorTimerRef.current = null;
}
if (wsRef.current) {
try {
wsRef.current.onopen = null;
wsRef.current.onmessage = null;
wsRef.current.onerror = null;
wsRef.current.onclose = null;
wsRef.current.close();
} catch {
// ignore
}
wsRef.current = null;
}
};
}, [docId, localUserName, localUserColor]);
const executeSendCursor = useCallback(
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
const ws = wsRef.current;
const activeDocId = docIdRef.current;
if (!activeDocId || !ws || ws.readyState !== WebSocket.OPEN) return;
const payload: DocSyncEvent = {
type: 'cursor_moved',
docId: activeDocId,
clientId: clientIdRef.current,
userId: currentUserRef.current?.id,
userName: localUserNameRef.current,
userColor: localUserColorRef.current,
blockId: blockId || undefined,
selectionStart,
selectionEnd,
};
try {
ws.send(JSON.stringify(payload));
} catch {
// Ephemeral cursor, ignore send failure
}
lastCursorSendRef.current = Date.now();
},
[]
);
const sendCursorMoved = useCallback(
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
if (!docIdRef.current) return;
const now = Date.now();
const elapsed = now - lastCursorSendRef.current;
// When leaving focus / blur (blockId === null), send immediately
if (blockId === null) {
if (pendingCursorTimerRef.current !== null) {
window.clearTimeout(pendingCursorTimerRef.current);
pendingCursorTimerRef.current = null;
}
pendingCursorPayloadRef.current = null;
executeSendCursor(null);
return;
}
// Max once per 100ms
if (elapsed >= 100) {
if (pendingCursorTimerRef.current !== null) {
window.clearTimeout(pendingCursorTimerRef.current);
pendingCursorTimerRef.current = null;
}
executeSendCursor(blockId, selectionStart, selectionEnd);
} else {
pendingCursorPayloadRef.current = { blockId, selectionStart, selectionEnd };
if (pendingCursorTimerRef.current === null) {
pendingCursorTimerRef.current = window.setTimeout(() => {
pendingCursorTimerRef.current = null;
if (pendingCursorPayloadRef.current) {
executeSendCursor(
pendingCursorPayloadRef.current.blockId,
pendingCursorPayloadRef.current.selectionStart,
pendingCursorPayloadRef.current.selectionEnd
);
pendingCursorPayloadRef.current = null;
}
}, 100 - elapsed);
}
}
},
[executeSendCursor]
);
const sendRawEvent = useCallback(
(evt: DocSyncEvent, fallbackFn?: () => void) => {
const activeDocId = docIdRef.current;
const activeCanEdit = canEditRef.current;
if (!activeDocId || !activeCanEdit) return;
const ws = wsRef.current;
const payload: DocSyncEvent = {
...evt,
docId: activeDocId,
clientId: clientIdRef.current,
};
const jsonStr = JSON.stringify(payload);
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(jsonStr);
} catch (err) {
console.warn('[DocSync] Failed to send WebSocket message, buffering:', err);
pendingMessagesQueueRef.current.push(jsonStr);
}
} else {
console.warn(
`[DocSync] WebSocket not OPEN (state=${ws ? ws.readyState : 'null'}). Buffering event:`,
evt.type
);
pendingMessagesQueueRef.current.push(jsonStr);
if (fallbackFn) {
if (fallbackTimerRef.current !== null) {
window.clearTimeout(fallbackTimerRef.current);
}
fallbackTimerRef.current = window.setTimeout(() => {
fallbackFn();
}, 600);
}
}
},
[]
);
const sendBlockCreated = useCallback(
(block: DocumentBlock, afterBlockId?: string, allBlocks?: DocumentBlock[]) => {
sendRawEvent(
{
type: 'block_created',
block,
afterBlockId,
},
() => {
if (docId && allBlocks) {
api
.patchDocumentBlocks(docId, {
upserted: [block],
blockOrder: allBlocks.map((b) => b.id),
})
.catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendBlockUpdated = useCallback(
(block: DocumentBlock) => {
sendRawEvent(
{
type: 'block_updated',
blockId: block.id,
block,
},
() => {
if (docId) {
api.patchDocumentBlocks(docId, { upserted: [block] }).catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendBlockDeleted = useCallback(
(blockId: string, allBlocks?: DocumentBlock[]) => {
sendRawEvent(
{
type: 'block_deleted',
blockId,
},
() => {
if (docId) {
api
.patchDocumentBlocks(docId, {
deletedIds: [blockId],
blockOrder: allBlocks ? allBlocks.map((b) => b.id) : undefined,
})
.catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendBlockMoved = useCallback(
(blockId: string, newIndex: number, level?: number, allBlocks?: DocumentBlock[]) => {
sendRawEvent(
{
type: 'block_moved',
blockId,
newIndex,
level,
},
() => {
if (docId && allBlocks) {
api
.patchDocumentBlocks(docId, {
upserted: allBlocks,
blockOrder: allBlocks.map((b) => b.id),
})
.catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendTitleUpdated = useCallback(
(title: string) => {
sendRawEvent(
{
type: 'title_updated',
title,
},
() => {
if (docId) {
api.updateDocument(docId, { title }).catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendBlocksReplaced = useCallback(
(blocks: DocumentBlock[]) => {
sendRawEvent(
{
type: 'blocks_replaced',
blocks,
},
() => {
if (docId) {
api.updateDocument(docId, { blocks }).catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
return {
status,
clientId: clientIdRef.current,
remoteCursors,
sendCursorMoved,
sendBlockCreated,
sendBlockUpdated,
sendBlockDeleted,
sendBlockMoved,
sendTitleUpdated,
sendBlocksReplaced,
};
}
+149 -100
View File
@@ -31,9 +31,6 @@ type Server struct {
}
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
if wsHub != nil && store != nil {
wsHub.SetDocStore(store)
}
srv := &Server{
cfg: cfg,
store: store,
@@ -70,7 +67,6 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/ws", s.handleWebSocket)
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
r.Get("/api/v1/ws/docs/{id}", s.handleDocWebSocket)
r.Get("/api/v1/events", s.handleSSE)
r.Post("/api/v1/events/rooms", s.handleSSERooms)
@@ -79,6 +75,8 @@ func (s *Server) Router() http.Handler {
r.Use(s.authMiddleware)
r.Get("/api/v1/auth/me", s.handleMe)
r.Post("/api/v1/auth/logout", s.handleLogout)
r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions)
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
@@ -90,6 +88,7 @@ func (s *Server) Router() http.Handler {
// Instant Capture & Thoughts
r.Post("/api/v1/inbox", s.handleCaptureInbox)
r.Get("/api/v1/thoughts", s.handleGetThoughts)
r.Get("/api/v1/thoughts/{id}", s.handleGetThought)
r.Patch("/api/v1/thoughts/{id}", s.handleUpdateThought)
r.Delete("/api/v1/thoughts/{id}", s.handleDeleteThought)
r.Delete("/api/v1/thoughts/discarded/purge", s.handlePurgeDiscarded)
@@ -98,6 +97,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/triage/deck", s.handleGetTriageDeck)
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
r.Get("/api/v1/tasks", s.handleGetTasks)
r.Post("/api/v1/tasks/{id}/unlink", s.handleUnlinkEnhancedTask)
r.Post("/api/v1/tasks/reorder", s.handleReorderTasks)
r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks)
@@ -137,6 +137,7 @@ func (s *Server) Router() http.Handler {
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)
r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask)
// Document Sharing & Collaboration
r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares)
@@ -153,6 +154,7 @@ func (s *Server) Router() http.Handler {
r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare)
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
r.Put("/api/v1/lists/{id}/items/{itemId}", s.handleUpdateListItem)
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
@@ -304,10 +306,14 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
}
claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr)
if err == nil && claims != nil {
user, userErr := s.store.GetUserByID(claims.UserID)
if userErr == nil && user != nil && user.IsActive {
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
return
if !s.store.IsTokenRevoked(tokenStr) {
user, userErr := s.store.GetUserByID(claims.UserID)
if userErr == nil && user != nil && user.IsActive {
if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
return
}
}
}
}
}
@@ -443,7 +449,7 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
_ = s.store.SeedStarterContent(user)
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
@@ -519,7 +525,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
}
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
@@ -557,21 +563,31 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
user, err := s.store.GetUserByLogin(identifier)
if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
if err != nil {
slog.Warn("Login fehlgeschlagen: Benutzer existiert nicht", "login", identifier, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
return
}
if !auth.CheckPassword(req.Password, user.PasswordHash) {
slog.Warn("Login fehlgeschlagen: Falsches Passwort", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
return
}
if !user.IsActive {
slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
return
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil {
slog.Error("Login-Token konnte nicht erstellt werden", "login", identifier, "user_id", user.ID, "error", err)
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
}
slog.Info("Login erfolgreich", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "role", user.Role, "remote_addr", r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"user": user,
@@ -688,6 +704,17 @@ func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, thoughts)
}
func (s *Server) handleGetThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
thought, err := s.store.GetThoughtByID(userID, thoughtID)
if err != nil || thought == nil {
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
return
}
writeJSON(w, http.StatusOK, thought)
}
func (s *Server) handleGetTriageDeck(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughts, err := s.store.GetThoughts(userID, "inbox", 100)
@@ -816,13 +843,22 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted)
s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
writeJSON(w, http.StatusOK, map[string]bool{"unlinked": true})
}
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -848,11 +884,18 @@ func (s *Server) broadcastDocToAuthorizedUsers(docID, event string) {
if err != nil {
return
}
var firstDoc *domain.Document
for _, uid := range users {
if userDoc, err := s.store.GetDocumentByID(uid, docID); err == nil && userDoc != nil {
if firstDoc == nil {
firstDoc = userDoc
}
s.wsHub.BroadcastToUser(uid, event, userDoc)
}
}
if s.wsHub != nil && firstDoc != nil {
s.wsHub.BroadcastRoom("doc:"+docID, event, firstDoc, nil)
}
}
func (s *Server) handleGetDocuments(w http.ResponseWriter, r *http.Request) {
@@ -898,18 +941,6 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
if s.wsHub != nil {
if liveTitle, liveBlocks, ok := s.wsHub.GetActiveDocState(docID); ok {
if liveTitle != "" {
doc.Title = liveTitle
}
if len(liveBlocks) > 0 {
doc.Blocks = liveBlocks
doc.MarkdownCache = domain.CompileBlocksToMarkdown(liveBlocks)
doc.Content = doc.MarkdownCache
}
}
}
writeJSON(w, http.StatusOK, doc)
}
@@ -935,9 +966,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if s.wsHub != nil && (req.Title != nil || req.Blocks != nil) {
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
}
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
}
@@ -959,9 +987,6 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusBadRequest, err.Error())
return
}
if s.wsHub != nil {
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
}
patchEvent := domain.DocBlocksPatchEvent{
DocID: docID,
@@ -986,9 +1011,6 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID)
if s.wsHub != nil {
s.wsHub.CloseDocRoom(docID)
}
if err := s.store.DeleteDocument(userID, docID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -999,6 +1021,30 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleCreateEnhancedTask(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
blockID := chi.URLParam(r, "blockId")
var req struct {
Content string `json:"content"`
Checked bool `json:"checked"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
thought, err := s.store.CreateTaskFromDocBlock(userID, docID, blockID, req.Content, req.Checked)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(userID, "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, thought)
}
// --- WORKSPACE FOLDERS ---
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
@@ -1044,7 +1090,6 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
}
for _, docID := range deletedDocIDs {
s.wsHub.ReleaseDocLock(docID, userID, true)
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath})
@@ -1375,8 +1420,6 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, users)
}
// --- DOCUMENT REALTIME WEBSOCKET ROOM ---
func (s *Server) authenticateUserFromReq(r *http.Request) string {
token := r.URL.Query().Get("token")
if token == "" {
@@ -1385,53 +1428,24 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string {
}
}
if token != "" {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
return claims.UserID
if strings.HasPrefix(token, "mindsift_sec_") {
if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive {
return user.ID
}
}
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil {
if !s.store.IsTokenRevoked(token) {
if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive {
if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
return claims.UserID
}
}
}
}
}
return ""
}
// GET /api/v1/ws/docs/{id}
func (s *Server) handleDocWebSocket(w http.ResponseWriter, r *http.Request) {
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")
}
}
docID := chi.URLParam(r, "id")
if docID == "" {
writeError(w, http.StatusBadRequest, "docId erforderlich")
return
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
userID = auth.GetUserID(r.Context())
}
if userID == "" {
userID = "anonymous"
}
canWrite := false
if userID != "anonymous" {
doc, err := s.store.GetDocumentByID(userID, docID)
if err != nil {
writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument")
return
}
canWrite = doc.IsOwner || doc.Permission == "owner" || doc.Permission == "write"
}
clientID := r.URL.Query().Get("clientId")
room := s.wsHub.GetOrCreateDocRoom(docID)
room.ServeDocWS(w, r, userID, clientID, canWrite)
}
// --- REALTIME LISTS & SHARING ---
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
@@ -1608,6 +1622,30 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, item)
}
func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
var req struct {
Content *string `json:"content"`
Category *string `json:"category"`
Quantity *string `json:"quantity"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
item, err := s.store.UpdateListItem(itemID, req.Content, req.Category, req.Quantity)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item)
writeJSON(w, http.StatusOK, item)
}
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
@@ -1686,17 +1724,9 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
roomID = r.URL.Query().Get("spaceId")
}
token := r.URL.Query().Get("token")
if token == "" {
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
token = strings.TrimPrefix(authHdr, "Bearer ")
}
}
userID := "anonymous"
if token != "" {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
userID = claims.UserID
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
userID = "anonymous"
}
s.wsHub.ServeWS(w, r, roomID, userID)
}
@@ -1707,17 +1737,9 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
roomID = r.URL.Query().Get("spaceId")
}
token := r.URL.Query().Get("token")
if token == "" {
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
token = strings.TrimPrefix(authHdr, "Bearer ")
}
}
userID := "anonymous"
if token != "" {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
userID = claims.UserID
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
userID = "anonymous"
}
var initialRooms []string
@@ -1933,6 +1955,33 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
// --- USER SETTINGS & ALARMS ---
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil {
var exp time.Time
if claims.ExpiresAt != nil {
exp = claims.ExpiresAt.Time
} else {
exp = time.Now().Add(30 * 24 * time.Hour)
}
_ = s.store.RevokeToken(tokenStr, userID, exp)
}
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleRevokeAllSessions(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
if err := s.store.RevokeAllUserTokens(userID); err != nil {
writeError(w, http.StatusInternalServerError, "Sitzungen konnten nicht beendet werden")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
+250 -103
View File
@@ -45,7 +45,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
hub := ws.NewHub()
srv := NewServer(cfg, store, hub, nil)
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
@@ -219,7 +219,7 @@ func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
router := srv.Router()
// Verify stale token from non-existent user is rejected (401)
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old")
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+staleToken)
rec := httptest.NewRecorder()
@@ -548,11 +548,11 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
t.Fatalf("failed to get normal user: %v", err)
}
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName)
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion)
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName)
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion)
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
@@ -663,7 +663,7 @@ func TestWorkspaceTagsAPI(t *testing.T) {
router := srv.Router()
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
// Create document with hashtags
blocks := []domain.DocumentBlock{
@@ -841,93 +841,6 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
}
}
func TestDocRoomLiveStateAndDebouncedFlush(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "docroom_test.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
cfg := &config.Config{
JWTSecret: "test-jwt-secret-key",
AllowRegistration: true,
}
hub := ws.NewHub()
srv := NewServer(cfg, store, hub, nil)
router := srv.Router()
user, err := store.CreateUser("outliner_ws@example.com", "hash", "WS Outliner User", "member")
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role)
doc, err := store.CreateDocument(user.ID, "", "Initial Title", "📄", "", nil, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
// 1. Mutate in-memory DocRoom before 1.5s flush fires
room := hub.GetOrCreateDocRoom(doc.ID)
newContent := "Live RAM Block #ramtag"
newLevel := 1
room.HandleEvent(ws.DocSyncEvent{
Type: "title_updated",
ClientID: "cli_1",
Title: "RAM Updated Title",
}, nil)
room.HandleEvent(ws.DocSyncEvent{
Type: "block_created",
ClientID: "cli_1",
AfterBlockID: doc.Blocks[0].ID,
Block: &domain.DocumentBlock{
ID: "blk_ram_2",
Type: "text",
Content: newContent,
Level: newLevel,
},
}, nil)
// 2. Verify GET /api/v1/documents/{id} immediately returns the un-flushed in-memory state
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from GET document, got %d: %s", rec.Code, rec.Body.String())
}
var liveDoc domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &liveDoc)
if liveDoc.Title != "RAM Updated Title" {
t.Fatalf("expected live RAM title 'RAM Updated Title', got '%s'", liveDoc.Title)
}
if len(liveDoc.Blocks) != 2 || liveDoc.Blocks[1].Content != newContent {
t.Fatalf("expected 2 live blocks with RAM content, got %+v", liveDoc.Blocks)
}
// 3. Flush room to SQLite and verify persistence + tag extraction + markdown_cache
room.Flush()
persistedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
if err != nil {
t.Fatalf("GetDocumentByID failed: %v", err)
}
if persistedDoc.Title != "RAM Updated Title" {
t.Fatalf("expected persisted title 'RAM Updated Title', got '%s'", persistedDoc.Title)
}
if !strings.Contains(persistedDoc.MarkdownCache, " - Live RAM Block #ramtag") {
t.Fatalf("expected compiled markdown_cache to contain indented bullet, got:\n%s", persistedDoc.MarkdownCache)
}
tags, _ := store.GetWorkspaceTags(user.ID)
if len(tags) != 1 || tags[0].Tag != "ramtag" {
t.Fatalf("expected extracted #ramtag in block_tags, got %+v", tags)
}
}
func TestSSEGlobalEndpoint(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test_sse.db")
store, err := storage.NewStore(dbPath)
@@ -997,7 +910,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
if err != nil {
t.Fatalf("failed to create user B: %v", err)
}
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName)
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
if err != nil {
t.Fatalf("failed to generate token B: %v", err)
}
@@ -1121,7 +1034,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
}
// 7. Synchronous contents check:
// User B moves the document into a subfolder: "MeineOrdner/TeamProjekt/Archiv"
// User B (read-only recipient) cannot move documents within the shared folder:
moveDocPayload := map[string]string{
"folderPath": "MeineOrdner/TeamProjekt/Archiv",
}
@@ -1131,25 +1044,259 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User B move doc into subfolder failed: %s", rec.Body.String())
if rec.Code != http.StatusBadRequest {
t.Fatalf("Expected User B move doc in read-only shared folder to fail, got %d", rec.Code)
}
// User A must now see the document synchronized at "TeamProjekt/Archiv"!
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
// User A (the owner) moves the document into a subfolder: "TeamProjekt/Archiv"
moveDocPayload = map[string]string{
"folderPath": "TeamProjekt/Archiv",
}
b, _ = json.Marshal(moveDocPayload)
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+createdDoc.ID+"/move", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User A get doc failed: %s", rec.Body.String())
t.Fatalf("User A move doc into subfolder failed: %s", rec.Body.String())
}
var docASynced domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docASynced)
if docASynced.FolderPath != "TeamProjekt/Archiv" {
t.Fatalf("expected User A to see subfolder sync at 'TeamProjekt/Archiv', got %q", docASynced.FolderPath)
// User B must now see the document synchronized at "MeineOrdner/TeamProjekt/Archiv"!
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
req.Header.Set("Authorization", "Bearer "+tokenB)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User B get doc failed: %s", rec.Body.String())
}
var docBSynced domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docBSynced)
if docBSynced.FolderPath != "MeineOrdner/TeamProjekt/Archiv" {
t.Fatalf("expected User B to see subfolder sync at 'MeineOrdner/TeamProjekt/Archiv', got %q", docBSynced.FolderPath)
}
}
func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
// 1. Create a fresh user with known password
password := "initialPassword123"
hash, _ := auth.HashPassword(password)
user, err := store.CreateUser("revocation_test@example.com", hash, "Revocation Tester", "member")
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// 2. Perform login to get initial JWT
loginBody, _ := json.Marshal(map[string]string{
"login": user.Email,
"password": password,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login failed: %s", rec.Body.String())
}
var loginResp struct {
Token string `json:"token"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp)
token1 := loginResp.Token
if token1 == "" {
t.Fatalf("expected token in login response")
}
// 3. Verify token1 works
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token1)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("token1 should be valid, got %d", rec.Code)
}
// 4. Test explicit logout (blacklists token1)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
req.Header.Set("Authorization", "Bearer "+token1)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("logout failed: %s", rec.Body.String())
}
// Token1 must now be blacklisted and rejected (401)
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token1)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for blacklisted token1, got %d", rec.Code)
}
// Also check authenticateUserFromReq rejects token1
req = httptest.NewRequest(http.MethodGet, "/api/v1/ws?token="+token1, nil)
if uid := srv.authenticateUserFromReq(req); uid != "" {
t.Fatalf("authenticateUserFromReq should reject blacklisted token1, got uid %q", uid)
}
// 5. Login again to get token2
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
var loginResp2 struct {
Token string `json:"token"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp2)
token2 := loginResp2.Token
// Token2 works
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token2)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("token2 should be valid, got %d", rec.Code)
}
// 6. Change password via UpdateUserPassword -> increments token_version
newPassword := "newSecretPassword456"
newHash, _ := auth.HashPassword(newPassword)
if err := store.UpdateUserPassword(user.ID, newHash); err != nil {
t.Fatalf("failed to update password: %v", err)
}
// Token2 must now be REJECTED (401) because its token_version is outdated!
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token2)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for token2 after password change, got %d", rec.Code)
}
// 7. Login with new password to get token3
loginNewBody, _ := json.Marshal(map[string]string{
"login": user.Email,
"password": newPassword,
})
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginNewBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
var loginResp3 struct {
Token string `json:"token"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp3)
token3 := loginResp3.Token
// Token3 works
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token3)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("token3 should be valid, got %d", rec.Code)
}
// 8. Test revoke-all endpoint -> increments token_version and invalidates all existing sessions
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/revoke-all", nil)
req.Header.Set("Authorization", "Bearer "+token3)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("revoke-all failed: %s", rec.Body.String())
}
// Token3 must now be REJECTED (401)
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token3)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for token3 after revoke-all, got %d", rec.Code)
}
}
func TestDocumentSharingStrictlyReadOnly(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
userA, _ := store.CreateUser("user_a_ro@example.com", "hash", "User A", "member")
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion)
userB, _ := store.CreateUser("user_b_ro@example.com", "hash", "User B", "member")
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
// Create document owned by User A
blocks := []domain.DocumentBlock{
{ID: "b1", Type: "bullet", Content: "Row 1", Level: 0},
{ID: "b2", Type: "bullet", Content: "Row 2", Level: 0},
}
doc, err := store.CreateDocument(userA.ID, "", "Read Only Test Doc", "📝", "", blocks, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
// Share with User B (forced read-only)
_, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "read")
if err != nil {
t.Fatalf("CreateDocShare failed: %v", err)
}
// 1. User B can read document
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil)
req.Header.Set("Authorization", "Bearer "+tokenB)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User B should be able to read doc, got status: %d", rec.Code)
}
// 2. User B tries to update document via PUT -> must fail (not owner)
newTitle := "Hacked Title"
updateBody, _ := json.Marshal(map[string]interface{}{"title": newTitle})
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(updateBody))
req.Header.Set("Authorization", "Bearer "+tokenB)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
t.Fatalf("expected error for User B updating document, got 200 OK")
}
// 3. User B tries to patch document blocks -> must fail (not owner)
patchBody, _ := json.Marshal(domain.DocBlocksPatchRequest{
Upserted: []domain.DocumentBlock{{ID: "b2", Content: "Hacked by User B"}},
})
req = httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+doc.ID+"/blocks", bytes.NewReader(patchBody))
req.Header.Set("Authorization", "Bearer "+tokenB)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
t.Fatalf("expected error for User B patching document blocks, got 200 OK")
}
// 4. User A (owner) can successfully update document
ownerTitle := "Owner Updated Title"
ownerBody, _ := json.Marshal(map[string]interface{}{"title": ownerTitle})
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(ownerBody))
req.Header.Set("Authorization", "Bearer "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("owner should be able to update document, got %d", rec.Code)
}
}
+20 -9
View File
@@ -18,9 +18,10 @@ type contextKey string
const UserIDContextKey contextKey = "mindsift_user_id"
type Claims struct {
UserID string `json:"userId"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
UserID string `json:"userId"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
TokenVersion int `json:"tokenVersion"`
jwt.RegisteredClaims
}
@@ -34,14 +35,24 @@ func CheckPassword(password, hash string) bool {
return err == nil
}
func GenerateToken(secret, userID, email, displayName string) (string, error) {
func GenerateToken(secret, userID, email, displayName string, tokenVersion int) (string, error) {
if tokenVersion <= 0 {
tokenVersion = 1
}
b := make([]byte, 16)
_, _ = rand.Read(b)
jti := hex.EncodeToString(b)
now := time.Now()
claims := &Claims{
UserID: userID,
Email: email,
DisplayName: displayName,
UserID: userID,
Email: email,
DisplayName: displayName,
TokenVersion: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
ID: jti,
ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: "mindsift",
},
}
+5 -8
View File
@@ -101,6 +101,7 @@ type User struct {
AvatarURL string `json:"avatarUrl,omitempty"`
Role string `json:"role"` // admin | member
IsActive bool `json:"isActive"`
TokenVersion int `json:"tokenVersion"`
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
AlarmEmail string `json:"alarmEmail"`
DiscordWebhookURL string `json:"discordWebhookUrl"`
@@ -147,8 +148,10 @@ type Thought struct {
Source string `json:"source"` // manual | voice | webhook
Status ThoughtStatus `json:"status"`
IsCompleted bool `json:"isCompleted"`
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item | document_block
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
DocID string `json:"docId,omitempty"`
BlockID string `json:"blockId,omitempty"`
DueDate string `json:"dueDate,omitempty"`
TaskListID string `json:"taskListId,omitempty"`
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
@@ -269,6 +272,7 @@ type DocumentBlock struct {
Level int `json:"level"`
Position float64 `json:"position"`
Collapsed bool `json:"collapsed,omitempty"`
TaskID string `json:"taskId,omitempty"`
}
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
@@ -336,13 +340,6 @@ type DocShare struct {
CreatedAt string `json:"createdAt"`
}
type DocLockInfo struct {
DocID string `json:"docId"`
UserID string `json:"userId"`
UserName string `json:"userName"`
ExpiresAt string `json:"expiresAt"`
}
type Document struct {
ID string `json:"id"`
UserID string `json:"userId"`
+302 -38
View File
@@ -72,6 +72,7 @@ func (s *Store) migrate() error {
display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
is_active INTEGER NOT NULL DEFAULT 1,
token_version INTEGER NOT NULL DEFAULT 1,
alarm_channels TEXT NOT NULL DEFAULT 'discord',
alarm_email TEXT NOT NULL DEFAULT '',
discord_webhook_url TEXT NOT NULL DEFAULT '',
@@ -307,6 +308,16 @@ func (s *Store) migrate() error {
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS revoked_tokens (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at);
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(user_id);
`
if _, err := s.db.Exec(schema); err != nil {
return err
@@ -318,6 +329,7 @@ func (s *Store) migrate() error {
"CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);",
"ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';",
"ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;",
"ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 1;",
"ALTER TABLE users ADD COLUMN alarm_channels TEXT NOT NULL DEFAULT 'discord';",
"ALTER TABLE users ADD COLUMN alarm_email TEXT NOT NULL DEFAULT '';",
"ALTER TABLE users ADD COLUMN discord_webhook_url TEXT NOT NULL DEFAULT '';",
@@ -402,6 +414,11 @@ func (s *Store) migrate() error {
// Backfill username for existing accounts where username is empty
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
// Ensure all existing document & folder shares are strictly read-only
_, _ = s.db.Exec(`UPDATE doc_shares SET permission = 'read' WHERE permission != 'read'`)
_, _ = s.db.Exec(`UPDATE doc_team_shares SET permission = 'read' WHERE permission != 'read'`)
_, _ = s.db.Exec(`UPDATE workspace_folder_shares SET permission = 'read' WHERE permission != 'read'`)
// Default system settings (public registration disabled by default; invite or admin creation required)
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
('sharing_mode', 'teams_only'),
@@ -536,7 +553,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, avatar_url, created_at`
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, token_version, 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, ",")
@@ -558,13 +575,16 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
var channelsRaw string
err := row.Scan(
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName,
&u.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail,
&u.Role, &isActiveInt, &u.TokenVersion, &channelsRaw, &u.AlarmEmail,
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.CreatedAt,
)
if err != nil {
return nil, err
}
u.IsActive = isActiveInt != 0
if u.TokenVersion <= 0 {
u.TokenVersion = 1
}
if u.Role == "" {
u.Role = "member"
}
@@ -604,6 +624,7 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam
DisplayName: strings.TrimSpace(displayName),
Role: role,
IsActive: true,
TokenVersion: 1,
AlarmChannels: []string{},
AlarmEmail: cleanEmail,
DiscordWebhookURL: "",
@@ -613,8 +634,8 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam
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, avatar_url, created_at)
VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, '', '', 30, '', ?)`,
`INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at)
VALUES (?, ?, ?, ?, ?, ?, 1, 1, '', ?, '', '', 30, '', ?)`,
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
)
if err != nil {
@@ -811,14 +832,17 @@ func (s *Store) UpdateUserFull(
if avatarURL != nil {
u.AvatarURL = strings.TrimSpace(*avatarURL)
}
if (role != nil && *role != u.Role) || (isActive != nil && !*isActive && u.IsActive) {
u.TokenVersion++
}
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 = ?, avatar_url = ? WHERE id = ?`,
u.DisplayName, u.Email, u.Role, activeInt, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID,
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, token_version = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`,
u.DisplayName, u.Email, u.Role, activeInt, u.TokenVersion, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID,
)
if err != nil {
return nil, err
@@ -827,15 +851,45 @@ func (s *Store) UpdateUserFull(
}
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
_, err := s.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID)
_, err := s.db.Exec(`UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?`, passwordHash, userID)
return err
}
func (s *Store) DeleteUser(userID string) error {
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID)
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
return err
}
// RevokeToken adds an individual JWT to the blacklist until its expiration date
func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error {
hash := auth.HashAPIKey(tokenStr)
_, err := s.db.Exec(
`INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`,
hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339),
)
// Idempotently prune old expired tokens
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339))
return err
}
// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted
func (s *Store) IsTokenRevoked(tokenStr string) bool {
hash := auth.HashAPIKey(tokenStr)
var count int
err := s.db.QueryRow(
`SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`,
hash, time.Now().UTC().Format(time.RFC3339),
).Scan(&count)
return err == nil && count > 0
}
// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user
func (s *Store) RevokeAllUserTokens(userID string) error {
_, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID)
return err
}
// --- THOUGHTS & TRIAGE ---
func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) {
@@ -858,6 +912,30 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD
}
tags := domain.ExtractTags(content)
if strings.EqualFold(source, "voice") {
hasDiktat := false
for _, tag := range tags {
if strings.EqualFold(tag, "diktat") {
hasDiktat = true
break
}
}
if !hasDiktat {
tags = append(tags, "diktat")
}
}
if strings.EqualFold(source, "webhook") {
hasWebhook := false
for _, tag := range tags {
if strings.EqualFold(tag, "webhook") {
hasWebhook = true
break
}
}
if !hasWebhook {
tags = append(tags, "webhook")
}
}
tagsJSON, _ := json.Marshal(tags)
t := &domain.Thought{
@@ -915,6 +993,11 @@ func scanThought(rows interface {
}
t.Tags = []string{}
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
if t.ConvertedType == "document_block" && strings.Contains(t.ConvertedRefID, ":") {
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
t.DocID = parts[0]
t.BlockID = parts[1]
}
t.Subtasks = []domain.SubTask{}
if subtasksStr != "" && subtasksStr != "null" {
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
@@ -1084,23 +1167,23 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
title = title[:72] + "…"
}
blocks := make([]domain.DocumentBlock, 0)
formattedDate := domain.FormatGermanDateTime(thought.CreatedAt)
calloutText := "Ursprünglicher Gedanke aus deiner Inbox"
if formattedDate != "" {
calloutText += " vom " + formattedDate
}
// 17. Wenn ich eine Aufgabe zu einer Notiz mache, muss die erste Zeile das "To-Do" noch mal als "To-Do" enthalten, auch wenn es schon in der Überschrift steht. Wenn es vorher schon abgehakt war, dann den Zustand natürlich übernehmen.
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "callout",
Content: calloutText,
Type: "todo",
Content: thought.Content,
Checked: thought.IsCompleted,
Level: 0,
})
// 17a. Die anderen Details aus der Aufgabe sollen natürlich ebenfalls übernommen werden (Notizen, Subtasks, Fälligkeit/Priorität).
if strings.TrimSpace(thought.Notes) != "" {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "bullet",
Content: strings.TrimSpace(thought.Notes),
Level: 0,
Level: 1,
})
}
if len(thought.Subtasks) > 0 {
@@ -1110,10 +1193,25 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
Type: "todo",
Content: st.Title,
Checked: st.IsCompleted,
Level: 0,
Level: 1,
})
}
}
var metaItems []string
if thought.DueDate != "" {
metaItems = append(metaItems, "Fällig: "+thought.DueDate)
}
if thought.Priority != "" && thought.Priority != "normal" {
metaItems = append(metaItems, "Priorität: "+thought.Priority)
}
if len(metaItems) > 0 {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "bullet",
Content: strings.Join(metaItems, " • "),
Level: 1,
})
}
if len(blocks) == 1 {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
@@ -1477,7 +1575,7 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
if err != nil {
return nil, nil, err
}
if !doc.IsOwner && doc.Permission != "write" {
if !doc.IsOwner {
return nil, nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
}
@@ -2225,7 +2323,7 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
isOnlyPinChange := isPinned != nil && title == nil && icon == nil && folderPath == nil && blocks == nil && tags == nil
isOnlyFolderMove := folderPath != nil && title == nil && icon == nil && blocks == nil && tags == nil && isPinned == nil
if !doc.IsOwner && doc.Permission != "write" && !isOnlyPinChange && !isOnlyFolderMove {
if !doc.IsOwner && !isOnlyPinChange && !isOnlyFolderMove {
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
}
@@ -2266,7 +2364,7 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
if title != nil {
doc.Title = strings.TrimSpace(*title)
}
if icon != nil && *icon != "" {
if icon != nil {
doc.Icon = *icon
}
if tags != nil {
@@ -2301,6 +2399,34 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
}
if blocks != nil {
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
for _, b := range doc.Blocks {
if b.TaskID != "" {
s.SyncTaskFromBlockCheck(doc.UserID, b.TaskID, b.Checked)
}
}
// When a block is deleted from the note, unlink the corresponding task in thoughts without deleting the task
remainingBlockIDs := make(map[string]bool)
for _, b := range doc.Blocks {
remainingBlockIDs[b.ID] = true
}
rows, err := s.db.Query(`SELECT id, converted_ref_id, block_id FROM thoughts WHERE (doc_id = ? OR converted_ref_id LIKE ?) AND converted_type = 'document_block'`, docID, docID+":%")
if err == nil {
for rows.Next() {
var tid, refID, bID string
if err := rows.Scan(&tid, &refID, &bID); err == nil {
bid := bID
if bid == "" && strings.Contains(refID, ":") {
parts := strings.SplitN(refID, ":", 2)
bid = parts[1]
}
if !remainingBlockIDs[bid] {
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '', doc_id = '', block_id = '' WHERE id = ?`, tid)
}
}
}
_ = rows.Close()
}
}
return doc, nil
}
@@ -2835,8 +2961,8 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do
if err != nil {
return nil, err
}
if !shFolder.IsOwner && shFolder.Permission != "write" {
return nil, fmt.Errorf("keine Schreibberechtigung für diesen geteilten Ordner")
if !shFolder.IsOwner {
return nil, fmt.Errorf("nur der Eigentümer dieses geteilten Ordners kann Dateien dorthin verschieben")
}
// Update document to belong to targetSharedFolderID
@@ -2859,7 +2985,7 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do
// Target is NOT inside a shared folder (personal / standalone)
if doc.SharedFolderID != "" {
// Moving OUT of a shared folder into a personal folder
if !doc.IsOwner && doc.Permission != "write" {
if !doc.IsOwner {
return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben")
}
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`,
@@ -3285,8 +3411,8 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
if err != nil {
return nil, err
}
if !f.IsOwner && f.Permission != "write" {
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Ordner-Freigaben verwalten")
if !f.IsOwner {
return nil, fmt.Errorf("nur der Eigentümer kann Ordner-Freigaben verwalten")
}
cleanType := strings.TrimSpace(targetType)
@@ -3299,9 +3425,8 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
}
}
if permission != "read" && permission != "write" {
permission = "read"
}
// Geteilte Ordner können ausschließlich mit Leserberechtigung geteilt werden (wie Dokumente)
permission = "read"
var targetID, targetName, email, avatarURL string
if cleanType == "team" {
@@ -3515,8 +3640,8 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri
if err != nil {
return nil, err
}
if !doc.IsOwner && doc.Permission != "write" {
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
if !doc.IsOwner {
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
}
cleanTeamID := strings.TrimSpace(teamID)
@@ -3526,9 +3651,8 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri
return nil, fmt.Errorf("team nicht gefunden")
}
if permission != "read" && permission != "write" {
permission = "read"
}
// Document sharing is strictly read-only
permission = "read"
now := domain.NowISO()
shareID := domain.NewID("share")
@@ -3560,20 +3684,19 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
if err != nil {
return nil, err
}
if !doc.IsOwner && doc.Permission != "write" {
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
if !doc.IsOwner {
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
}
cleanTarget := strings.TrimSpace(targetUserIdentifier)
// If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare
if strings.HasPrefix(cleanTarget, "team_") {
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, "read")
}
if permission != "read" && permission != "write" {
permission = "read"
}
// Document sharing is strictly read-only
permission = "read"
// Resolve target user by ID, username, or Email
var targetUser *domain.User
@@ -4039,6 +4162,147 @@ func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName
return &it, nil
}
func (s *Store) UpdateListItem(itemID string, content *string, category *string, quantity *string) (*domain.ListItem, error) {
now := domain.NowISO()
var it domain.ListItem
var dInt int
err := s.db.QueryRow(
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
FROM list_items WHERE id = ?`,
itemID,
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
if err != nil {
return nil, err
}
it.IsCompleted = dInt != 0
if content != nil {
it.Content = *content
}
if category != nil {
it.Category = *category
}
if quantity != nil {
it.Quantity = *quantity
}
_, err = s.db.Exec(`UPDATE list_items SET content = ?, category = ?, quantity = ? WHERE id = ?`, it.Content, it.Category, it.Quantity, itemID)
if err != nil {
return nil, err
}
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
return &it, nil
}
func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, checked bool) (*domain.Thought, error) {
doc, err := s.GetDocumentByID(userID, docID)
if err != nil {
return nil, err
}
t, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", "", "todo", "")
if err != nil {
return nil, err
}
t.ConvertedType = "document_block"
t.ConvertedRefID = docID + ":" + blockID
t.DocID = docID
t.BlockID = blockID
t.IsCompleted = checked
t.Status = domain.StatusKept
doneInt := 0
if checked {
doneInt = 1
t.KanbanStatus = "done"
}
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ?, status = 'kept' WHERE id = ?`,
t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID)
var updatedBlocks []domain.DocumentBlock
found := false
for _, b := range doc.Blocks {
if b.ID == blockID {
b.TaskID = t.ID
b.Checked = checked
found = true
}
updatedBlocks = append(updatedBlocks, b)
}
if found {
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
}
return t, nil
}
func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) {
t, err := s.GetThoughtByID(userID, taskID)
if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
return
}
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
docID, blockID := parts[0], parts[1]
doc, err := s.GetDocumentByID(userID, docID)
if err != nil || doc == nil {
return
}
var updatedBlocks []domain.DocumentBlock
found := false
for _, b := range doc.Blocks {
if b.ID == blockID {
if b.Checked != isCompleted {
b.Checked = isCompleted
found = true
}
}
updatedBlocks = append(updatedBlocks, b)
}
if found {
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
}
}
func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) {
t, err := s.GetThoughtByID(userID, taskID)
if err != nil || t == nil {
return
}
docID := t.DocID
blockID := t.BlockID
if (docID == "" || blockID == "") && strings.Contains(t.ConvertedRefID, ":") {
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
docID, blockID = parts[0], parts[1]
}
if docID != "" {
if doc, err := s.GetDocumentByID(userID, docID); err == nil && doc != nil {
var updatedBlocks []domain.DocumentBlock
found := false
for _, b := range doc.Blocks {
if b.ID == blockID || b.TaskID == taskID {
b.TaskID = ""
found = true
}
updatedBlocks = append(updatedBlocks, b)
}
if found {
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
}
}
}
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '', doc_id = '', block_id = '' WHERE id = ?`, taskID)
}
func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
if taskID == "" {
return
}
doneInt := 0
kanban := "todo"
if checked {
doneInt = 1
kanban = "done"
}
_, _ = s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ? WHERE id = ? AND user_id = ?`, doneInt, kanban, taskID, userID)
}
func (s *Store) DeleteListItem(itemID string) (string, error) {
var listID string
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)
+9 -22
View File
@@ -173,16 +173,10 @@ func TestStoreLifecycle(t *testing.T) {
t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded")
}
// Upgrade to write permission
_, err = store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "write")
if err != nil {
t.Fatalf("Upgrade CreateDocShare failed: %v", err)
}
// user2 edits writeable doc -> must succeed
// user2 is a read-only collaborator -> editing must fail
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
if err != nil {
t.Fatalf("UpdateDocument failed for write collaborator: %v", err)
if err == nil {
t.Fatalf("expected UpdateDocument to fail for read-only collaborator, but succeeded")
}
// Remove share
@@ -551,8 +545,7 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
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
// User B is a read-only collaborator -> PatchDocumentBlocks must fail
_, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{
Upserted: []domain.DocumentBlock{
{ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0},
@@ -560,8 +553,8 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
},
BlockOrder: []string{"b1", "b2", "b3"},
})
if err != nil {
t.Fatalf("PatchDocumentBlocks by userB failed: %v", err)
if err == nil {
t.Fatalf("expected PatchDocumentBlocks by read-only userB to fail, but succeeded")
}
// User A's client only knew about b1, b2 when sending its patch (adds b4)
@@ -576,9 +569,9 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
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)
// Verify User A's blocks are preserved: b1, b2, b4 (3 blocks)
if len(patchedDocA.Blocks) != 3 {
t.Fatalf("expected 3 blocks after patch, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks)
}
blockContent := make(map[string]string)
for _, b := range patchedDocA.Blocks {
@@ -587,12 +580,6 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
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"])
}
-736
View File
@@ -1,736 +0,0 @@
package ws
import (
"encoding/json"
"log/slog"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/sandrodach/mindsift/internal/domain"
)
// CursorPresence represents an active user's ephemeral cursor position in a document room.
type CursorPresence struct {
ClientID string `json:"clientId"`
UserID string `json:"userId"`
UserName string `json:"userName"`
UserColor string `json:"userColor"`
BlockID string `json:"blockId,omitempty"`
SelectionStart *int `json:"selectionStart,omitempty"`
SelectionEnd *int `json:"selectionEnd,omitempty"`
UpdatedAt int64 `json:"updatedAt"`
}
// DocWSClient represents an active WebSocket connection to a specific document room.
type DocWSClient struct {
hub *Hub
room *DocRoom
conn *websocket.Conn
userID string
clientID string
canWrite bool
send chan []byte
}
// DocSyncEvent is the lightweight JSON payload exchanged over /api/v1/ws/docs/{id}.
type DocSyncEvent struct {
Type string `json:"type"`
DocID string `json:"docId,omitempty"`
ClientID string `json:"clientId,omitempty"`
UserID string `json:"userId,omitempty"`
UserName string `json:"userName,omitempty"`
UserColor string `json:"userColor,omitempty"`
SelectionStart *int `json:"selectionStart,omitempty"`
SelectionEnd *int `json:"selectionEnd,omitempty"`
ActiveCursors []CursorPresence `json:"activeCursors,omitempty"`
Title string `json:"title,omitempty"`
Block *domain.DocumentBlock `json:"block,omitempty"`
BlockID string `json:"blockId,omitempty"`
AfterBlockID string `json:"afterBlockId,omitempty"`
NewIndex *int `json:"newIndex,omitempty"`
Content *string `json:"content,omitempty"`
Level *int `json:"level,omitempty"`
Checked *bool `json:"checked,omitempty"`
Collapsed *bool `json:"collapsed,omitempty"`
BlockType *string `json:"blockType,omitempty"`
Blocks []domain.DocumentBlock `json:"blocks,omitempty"`
}
// DocRoom coordinates in-memory real-time block state and 1.5s debounced SQLite persistence for one document.
type DocRoom struct {
mu sync.Mutex
docID string
hub *Hub
title string
blocks []domain.DocumentBlock
loaded bool
wsClients map[*DocWSClient]bool
activeCursors map[string]*CursorPresence
debounceTimer *time.Timer
dirty bool
}
// GetOrCreateDocRoom returns the active in-memory room for docID or initializes one from SQLite.
func (h *Hub) GetOrCreateDocRoom(docID string) *DocRoom {
h.mu.Lock()
if h.docRooms == nil {
h.docRooms = make(map[string]*DocRoom)
}
room, exists := h.docRooms[docID]
if !exists {
room = &DocRoom{
docID: docID,
hub: h,
wsClients: make(map[*DocWSClient]bool),
activeCursors: make(map[string]*CursorPresence),
}
h.docRooms[docID] = room
}
store := h.docStore
h.mu.Unlock()
room.mu.Lock()
if !room.loaded && store != nil {
title, blocks, err := store.LoadDocumentState(docID)
if err == nil {
room.title = title
room.blocks = normalizeRoomBlocks(docID, blocks)
room.loaded = true
}
}
room.mu.Unlock()
return room
}
// GetActiveDocState returns the live in-memory title and blocks if an active DocRoom exists in RAM.
func (h *Hub) GetActiveDocState(docID string) (string, []domain.DocumentBlock, bool) {
h.mu.RLock()
room, exists := h.docRooms[docID]
h.mu.RUnlock()
if !exists || room == nil {
return "", nil, false
}
room.mu.Lock()
defer room.mu.Unlock()
if !room.loaded {
return "", nil, false
}
blocksCopy := make([]domain.DocumentBlock, len(room.blocks))
copy(blocksCopy, room.blocks)
return room.title, blocksCopy, true
}
// SyncActiveDocRoom updates an in-memory room if one is currently loaded (e.g. after REST PUT/PATCH).
func (h *Hub) SyncActiveDocRoom(docID string, title string, blocks []domain.DocumentBlock) {
h.mu.RLock()
room, exists := h.docRooms[docID]
h.mu.RUnlock()
if !exists || room == nil {
return
}
room.mu.Lock()
if title != "" {
room.title = title
}
if blocks != nil {
room.blocks = normalizeRoomBlocks(docID, blocks)
}
room.loaded = true
room.mu.Unlock()
}
// CloseDocRoom removes a document room from memory when a document is deleted.
func (h *Hub) CloseDocRoom(docID string) {
h.mu.Lock()
room, exists := h.docRooms[docID]
if exists {
delete(h.docRooms, docID)
}
h.mu.Unlock()
if exists && room != nil {
room.mu.Lock()
if room.debounceTimer != nil {
room.debounceTimer.Stop()
}
room.dirty = false
for c := range room.wsClients {
_ = c.conn.Close()
}
room.wsClients = make(map[*DocWSClient]bool)
room.mu.Unlock()
}
}
func normalizeRoomBlocks(docID string, blocks []domain.DocumentBlock) []domain.DocumentBlock {
if len(blocks) == 0 {
return []domain.DocumentBlock{
{
ID: docID + "-b0",
DocumentID: docID,
Type: "text",
Content: "",
Level: 0,
Position: 1000,
},
}
}
out := make([]domain.DocumentBlock, len(blocks))
for i, b := range blocks {
if b.ID == "" {
b.ID = domain.NewID("blk")
}
b.DocumentID = docID
if b.Type == "" {
b.Type = "text"
}
if b.Level < 0 {
b.Level = 0
}
b.Position = float64((i + 1) * 1000)
out[i] = b
}
return out
}
// GetStateSnapshot returns a copy of the room's current live in-memory title and blocks.
func (r *DocRoom) GetStateSnapshot() (string, []domain.DocumentBlock) {
r.mu.Lock()
defer r.mu.Unlock()
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
copy(blocksCopy, r.blocks)
return r.title, blocksCopy
}
// HandleEvent applies a granular block or title mutation to the in-memory room,
// broadcasts it to all OTHER clients in the room (excluding senderClientID for echo protection),
// and resets the 1.5-second inactivity debounce timer for SQLite persistence.
func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
r.mu.Lock()
evt.DocID = r.docID
if sender != nil {
if evt.ClientID == "" {
evt.ClientID = sender.clientID
}
evt.UserID = sender.userID
}
mutated := false
switch evt.Type {
case "cursor_moved":
if evt.ClientID != "" {
cp := &CursorPresence{
ClientID: evt.ClientID,
UserID: evt.UserID,
UserName: evt.UserName,
UserColor: evt.UserColor,
BlockID: evt.BlockID,
SelectionStart: evt.SelectionStart,
SelectionEnd: evt.SelectionEnd,
UpdatedAt: time.Now().UnixMilli(),
}
if r.activeCursors == nil {
r.activeCursors = make(map[string]*CursorPresence)
}
r.activeCursors[evt.ClientID] = cp
msgBytes, err := json.Marshal(evt)
if err == nil {
for client := range r.wsClients {
if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) {
continue
}
if evt.ClientID != "" && client.clientID == evt.ClientID {
continue
}
select {
case client.send <- msgBytes:
default:
}
}
}
}
r.mu.Unlock()
return
case "title_updated":
if evt.Title != "" || r.title != evt.Title {
r.title = evt.Title
mutated = true
}
case "block_created":
if evt.Block != nil {
newBlock := *evt.Block
if newBlock.ID == "" {
newBlock.ID = domain.NewID("blk")
}
newBlock.DocumentID = r.docID
if newBlock.Type == "" {
newBlock.Type = "text"
}
if newBlock.Level < 0 {
newBlock.Level = 0
}
// Avoid duplicate ID if already created
existsIdx := -1
for i, b := range r.blocks {
if b.ID == newBlock.ID {
existsIdx = i
break
}
}
if existsIdx >= 0 {
r.blocks[existsIdx] = newBlock
} else {
insertIdx := len(r.blocks)
if evt.AfterBlockID != "" {
for i, b := range r.blocks {
if b.ID == evt.AfterBlockID {
insertIdx = i + 1
break
}
}
} else if evt.NewIndex != nil && *evt.NewIndex >= 0 && *evt.NewIndex <= len(r.blocks) {
insertIdx = *evt.NewIndex
}
updated := make([]domain.DocumentBlock, 0, len(r.blocks)+1)
updated = append(updated, r.blocks[:insertIdx]...)
updated = append(updated, newBlock)
updated = append(updated, r.blocks[insertIdx:]...)
r.blocks = normalizeRoomBlocks(r.docID, updated)
}
evt.Block = &newBlock
mutated = true
}
case "block_updated":
targetID := evt.BlockID
if targetID == "" && evt.Block != nil {
targetID = evt.Block.ID
}
if targetID != "" {
found := false
for i := range r.blocks {
if r.blocks[i].ID == targetID {
found = true
if evt.Block != nil {
r.blocks[i].Content = evt.Block.Content
r.blocks[i].Level = evt.Block.Level
if evt.Block.Type != "" {
r.blocks[i].Type = evt.Block.Type
}
r.blocks[i].Checked = evt.Block.Checked
r.blocks[i].Collapsed = evt.Block.Collapsed
r.blocks[i].Lang = evt.Block.Lang
} else {
if evt.Content != nil {
r.blocks[i].Content = *evt.Content
}
if evt.Level != nil {
lvl := *evt.Level
if lvl < 0 {
lvl = 0
}
r.blocks[i].Level = lvl
}
if evt.Checked != nil {
r.blocks[i].Checked = *evt.Checked
}
if evt.Collapsed != nil {
r.blocks[i].Collapsed = *evt.Collapsed
}
if evt.BlockType != nil && *evt.BlockType != "" {
r.blocks[i].Type = *evt.BlockType
}
}
bCopy := r.blocks[i]
evt.Block = &bCopy
evt.BlockID = targetID
mutated = true
break
}
}
if !found && evt.Block != nil {
newBlock := *evt.Block
newBlock.DocumentID = r.docID
if newBlock.Type == "" {
newBlock.Type = "text"
}
if newBlock.Level < 0 {
newBlock.Level = 0
}
r.blocks = append(r.blocks, newBlock)
r.blocks = normalizeRoomBlocks(r.docID, r.blocks)
bCopy := newBlock
evt.Block = &bCopy
evt.BlockID = targetID
mutated = true
}
}
case "block_deleted":
if evt.BlockID != "" {
filtered := make([]domain.DocumentBlock, 0, len(r.blocks))
for _, b := range r.blocks {
if b.ID != evt.BlockID {
filtered = append(filtered, b)
}
}
r.blocks = normalizeRoomBlocks(r.docID, filtered)
mutated = true
}
case "block_moved":
if evt.BlockID != "" && evt.NewIndex != nil {
fromIdx := -1
for i, b := range r.blocks {
if b.ID == evt.BlockID {
fromIdx = i
break
}
}
if fromIdx >= 0 {
moving := r.blocks[fromIdx]
if evt.Level != nil {
lvl := *evt.Level
if lvl < 0 {
lvl = 0
}
moving.Level = lvl
}
without := make([]domain.DocumentBlock, 0, len(r.blocks)-1)
without = append(without, r.blocks[:fromIdx]...)
without = append(without, r.blocks[fromIdx+1:]...)
toIdx := *evt.NewIndex
if toIdx < 0 {
toIdx = 0
}
if toIdx > len(without) {
toIdx = len(without)
}
reordered := make([]domain.DocumentBlock, 0, len(r.blocks))
reordered = append(reordered, without[:toIdx]...)
reordered = append(reordered, moving)
reordered = append(reordered, without[toIdx:]...)
r.blocks = normalizeRoomBlocks(r.docID, reordered)
mutated = true
}
}
case "blocks_replaced":
if evt.Blocks != nil {
r.blocks = normalizeRoomBlocks(r.docID, evt.Blocks)
evt.Blocks = r.blocks
mutated = true
}
}
if !mutated {
r.mu.Unlock()
return
}
r.dirty = true
r.loaded = true
msgBytes, err := json.Marshal(evt)
if err == nil {
for client := range r.wsClients {
// Strict Echo Protection: never send back to the originating connection or clientId
if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) {
continue
}
if evt.ClientID != "" && client.clientID == evt.ClientID {
continue
}
select {
case client.send <- msgBytes:
default:
slog.Warn("Doc room WS client send buffer full, dropping message",
"doc_id", r.docID,
"client_id", client.clientID,
"user_id", client.userID,
)
}
}
}
r.scheduleDebouncedFlushLocked()
r.mu.Unlock()
}
func (r *DocRoom) scheduleDebouncedFlushLocked() {
if r.debounceTimer != nil {
r.debounceTimer.Stop()
}
r.debounceTimer = time.AfterFunc(1500*time.Millisecond, func() {
r.Flush()
})
}
// Flush persists the current in-memory document state (blocks, #tags in block_tags, and markdown_cache) to SQLite.
func (r *DocRoom) Flush() {
r.mu.Lock()
if !r.dirty {
r.mu.Unlock()
return
}
if r.debounceTimer != nil {
r.debounceTimer.Stop()
r.debounceTimer = nil
}
docID := r.docID
title := r.title
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
copy(blocksCopy, r.blocks)
r.dirty = false
r.mu.Unlock()
tagsCount := 0
for _, b := range blocksCopy {
tagsCount += len(domain.ExtractTags(b.Content))
}
start := time.Now()
r.hub.mu.RLock()
store := r.hub.docStore
r.hub.mu.RUnlock()
if store != nil {
if err := store.FlushDocumentState(docID, title, blocksCopy); err != nil {
slog.Error("SQLite document flush failed",
"doc_id", docID,
"error", err,
)
} else {
durationMs := float64(time.Since(start).Microseconds()) / 1000.0
slog.Debug("SQLite document flushed",
"doc_id", docID,
"blocks_count", len(blocksCopy),
"tags_count", tagsCount,
"duration_ms", durationMs,
)
}
}
}
// ServeDocWS handles WebSocket connections for GET /api/v1/ws/docs/{id}
func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string, clientID string, canWrite bool) {
conn, err := upgrader.Upgrade(w, req, nil)
if err != nil {
slog.Error("WebSocket upgrade error", "doc_id", r.docID, "error", err)
return
}
if clientID == "" {
clientID = req.URL.Query().Get("clientId")
}
if clientID == "" {
clientID = domain.NewID("cli")
}
userName := req.URL.Query().Get("userName")
userColor := req.URL.Query().Get("userColor")
client := &DocWSClient{
hub: r.hub,
room: r,
conn: conn,
userID: userID,
clientID: clientID,
canWrite: canWrite,
send: make(chan []byte, 256),
}
r.mu.Lock()
r.wsClients[client] = true
activeClients := len(r.wsClients)
if r.activeCursors == nil {
r.activeCursors = make(map[string]*CursorPresence)
}
if userName != "" {
r.activeCursors[clientID] = &CursorPresence{
ClientID: clientID,
UserID: userID,
UserName: userName,
UserColor: userColor,
UpdatedAt: time.Now().UnixMilli(),
}
}
title := r.title
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
copy(blocksCopy, r.blocks)
activeList := make([]CursorPresence, 0, len(r.activeCursors))
for _, cp := range r.activeCursors {
if cp != nil {
activeList = append(activeList, *cp)
}
}
r.mu.Unlock()
slog.Debug("WS client joined doc room",
"doc_id", r.docID,
"client_id", clientID,
"user_id", userID,
"active_clients", activeClients,
)
// Immediately send live in-memory state and current cursors to the newly connected client
initEvt := DocSyncEvent{
Type: "doc_state",
DocID: r.docID,
ClientID: clientID,
Title: title,
Blocks: blocksCopy,
ActiveCursors: activeList,
}
if initBytes, err := json.Marshal(initEvt); err == nil {
client.send <- initBytes
}
// If userName was provided, broadcast initial presence to existing clients in room
if userName != "" {
joinEvt := DocSyncEvent{
Type: "cursor_moved",
DocID: r.docID,
ClientID: clientID,
UserID: userID,
UserName: userName,
UserColor: userColor,
}
if joinBytes, err := json.Marshal(joinEvt); err == nil {
r.mu.Lock()
for c := range r.wsClients {
if c != client && c.clientID != clientID {
select {
case c.send <- joinBytes:
default:
}
}
}
r.mu.Unlock()
}
}
go client.writePump()
go client.readPump()
}
func (c *DocWSClient) readPump() {
defer func() {
c.room.mu.Lock()
delete(c.room.wsClients, c)
delete(c.room.activeCursors, c.clientID)
remaining := len(c.room.wsClients)
// Broadcast client_left to all remaining clients in room
leftEvt := DocSyncEvent{
Type: "client_left",
DocID: c.room.docID,
ClientID: c.clientID,
UserID: c.userID,
}
if leftBytes, err := json.Marshal(leftEvt); err == nil {
for client := range c.room.wsClients {
if client != c && client.clientID != c.clientID {
select {
case client.send <- leftBytes:
default:
}
}
}
}
c.room.mu.Unlock()
slog.Debug("WS client left doc room",
"doc_id", c.room.docID,
"client_id", c.clientID,
"user_id", c.userID,
"active_clients", remaining,
)
_ = c.conn.Close()
// Flush immediately when the last client leaves the document room
if remaining == 0 {
c.room.Flush()
}
}()
c.conn.SetReadLimit(4 * 1024 * 1024)
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
break
}
var evt DocSyncEvent
if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" {
targetBlockID := evt.BlockID
if targetBlockID == "" && evt.Block != nil {
targetBlockID = evt.Block.ID
}
slog.Debug("WS doc event received",
"type", evt.Type,
"doc_id", c.room.docID,
"block_id", targetBlockID,
"sender_client_id", c.clientID,
)
// Read-only clients can still move their cursor and send presence updates
if !c.canWrite && evt.Type != "cursor_moved" {
continue
}
c.room.HandleEvent(evt, c)
}
}
}
func (c *DocWSClient) writePump() {
ticker := time.NewTicker(25 * time.Second)
defer func() {
ticker.Stop()
_ = c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
-85
View File
@@ -1,85 +0,0 @@
package ws
import (
"testing"
"time"
)
func TestDocRoomCursorPresenceAndEchoProtection(t *testing.T) {
hub := NewHub()
room := hub.GetOrCreateDocRoom("doc_test_presence")
c1 := &DocWSClient{
hub: hub,
room: room,
userID: "user_1",
clientID: "cli_1",
canWrite: true,
send: make(chan []byte, 10),
}
c2 := &DocWSClient{
hub: hub,
room: room,
userID: "user_2",
clientID: "cli_2",
canWrite: false, // read-only viewer
send: make(chan []byte, 10),
}
room.mu.Lock()
room.wsClients[c1] = true
room.wsClients[c2] = true
room.mu.Unlock()
// 1. Send cursor_moved from c1
selStart := 5
selEnd := 5
room.HandleEvent(DocSyncEvent{
Type: "cursor_moved",
DocID: "doc_test_presence",
ClientID: "cli_1",
UserID: "user_1",
UserName: "Alice",
UserColor: "#3b82f6",
BlockID: "blk_123",
SelectionStart: &selStart,
SelectionEnd: &selEnd,
}, c1)
// c1 should NOT have received its own echo
select {
case <-c1.send:
t.Fatal("c1 received echo of its own cursor_moved event!")
default:
}
// c2 SHOULD receive the event
select {
case msg := <-c2.send:
if len(msg) == 0 {
t.Fatal("empty message received by c2")
}
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for cursor_moved event on c2")
}
// Room should have registered c1's presence
room.mu.Lock()
cp, exists := room.activeCursors["cli_1"]
room.mu.Unlock()
if !exists || cp == nil {
t.Fatal("expected active cursor for cli_1 in room")
}
if cp.UserName != "Alice" || cp.BlockID != "blk_123" {
t.Fatalf("unexpected cursor data: %+v", cp)
}
// Ephemeral cursor events must NOT mark the room dirty for disk flush
room.mu.Lock()
dirty := room.dirty
room.mu.Unlock()
if dirty {
t.Fatal("cursor_moved should not mark room dirty")
}
}
+96 -252
View File
@@ -28,6 +28,29 @@ type Client struct {
userID string
send chan []byte
subscribedRooms map[string]bool
sendMu sync.RWMutex
closed bool
}
func (c *Client) closeSend() {
c.sendMu.Lock()
defer c.sendMu.Unlock()
if !c.closed {
c.closed = true
close(c.send)
}
}
func (c *Client) trySend(data []byte) {
c.sendMu.RLock()
defer c.sendMu.RUnlock()
if c.closed {
return
}
select {
case c.send <- data:
default:
}
}
type SSEClient struct {
@@ -36,19 +59,29 @@ type SSEClient struct {
roomID string
send chan []byte
subscribedRooms map[string]bool
sendMu sync.RWMutex
closed bool
}
type docLockItem struct {
docID string
userID string
userName string
lockedAt time.Time
expiresAt time.Time
func (c *SSEClient) closeSend() {
c.sendMu.Lock()
defer c.sendMu.Unlock()
if !c.closed {
c.closed = true
close(c.send)
}
}
type DocStore interface {
LoadDocumentState(docID string) (string, []domain.DocumentBlock, error)
FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error
func (c *SSEClient) trySend(data []byte) {
c.sendMu.RLock()
defer c.sendMu.RUnlock()
if c.closed {
return
}
select {
case c.send <- data:
default:
}
}
type Hub struct {
@@ -59,30 +92,18 @@ type Hub struct {
sseClients map[*SSEClient]bool
sseUserRooms map[string]map[*SSEClient]bool
sseRooms map[string]map[*SSEClient]bool
locks map[string]*docLockItem
docRooms map[string]*DocRoom
docStore DocStore
closed bool
}
func NewHub() *Hub {
h := &Hub{
return &Hub{
rooms: make(map[string]map[*Client]bool),
userRooms: make(map[string]map[*Client]bool),
allClients: make(map[*Client]bool),
sseClients: make(map[*SSEClient]bool),
sseUserRooms: make(map[string]map[*SSEClient]bool),
sseRooms: make(map[string]map[*SSEClient]bool),
locks: make(map[string]*docLockItem),
docRooms: make(map[string]*DocRoom),
}
go h.cleanupLocksLoop()
return h
}
func (h *Hub) SetDocStore(store DocStore) {
h.mu.Lock()
defer h.mu.Unlock()
h.docStore = store
}
func (h *Hub) Register(c *Client) {
@@ -122,7 +143,6 @@ func (h *Hub) Unregister(c *Client) {
}
}
var docRoomsLeft []string
if c.subscribedRooms != nil {
for rID := range c.subscribedRooms {
if room, ok := h.rooms[rID]; ok {
@@ -131,52 +151,19 @@ func (h *Hub) Unregister(c *Client) {
delete(h.rooms, rID)
}
}
if strings.HasPrefix(rID, "doc:") {
docRoomsLeft = append(docRoomsLeft, strings.TrimPrefix(rID, "doc:"))
}
}
}
userRemaining := 0
if c.userID != "" && c.userID != "anonymous" {
if uRoom, ok := h.userRooms[c.userID]; ok {
delete(uRoom, c)
userRemaining += len(uRoom)
if len(uRoom) == 0 {
delete(h.userRooms, c.userID)
}
}
if sseRoom, ok := h.sseUserRooms[c.userID]; ok {
userRemaining += len(sseRoom)
}
}
close(c.send)
// If this user has no other active client connections, release their document locks
var releasedDocIDs []string
if userRemaining == 0 && c.userID != "" {
now := time.Now()
for docID, l := range h.locks {
if l.userID == c.userID || l.expiresAt.Before(now) {
releasedDocIDs = append(releasedDocIDs, docID)
delete(h.locks, docID)
}
}
}
c.closeSend()
h.mu.Unlock()
// Broadcast unlock events for released locks
for _, docID := range releasedDocIDs {
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
}
// Broadcast awareness leave for doc rooms
for _, docID := range docRoomsLeft {
h.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
"docId": docID,
"userId": c.userID,
}, nil)
}
}
func (h *Hub) RegisterSSE(c *SSEClient) {
@@ -223,7 +210,6 @@ func (h *Hub) UnregisterSSE(c *SSEClient) {
}
}
}
var docRoomsLeft []string
if c.subscribedRooms != nil {
for rID := range c.subscribedRooms {
if room, ok := h.sseRooms[rID]; ok {
@@ -232,48 +218,18 @@ func (h *Hub) UnregisterSSE(c *SSEClient) {
delete(h.sseRooms, rID)
}
}
if strings.HasPrefix(rID, "doc:") {
docRoomsLeft = append(docRoomsLeft, strings.TrimPrefix(rID, "doc:"))
}
}
}
userRemaining := 0
if c.userID != "" && c.userID != "anonymous" {
if uRoom, ok := h.sseUserRooms[c.userID]; ok {
delete(uRoom, c)
userRemaining += len(uRoom)
if len(uRoom) == 0 {
delete(h.sseUserRooms, c.userID)
}
}
if wsRoom, ok := h.userRooms[c.userID]; ok {
userRemaining += len(wsRoom)
}
}
close(c.send)
var releasedDocIDs []string
if userRemaining == 0 && c.userID != "" {
now := time.Now()
for docID, l := range h.locks {
if l.userID == c.userID || l.expiresAt.Before(now) {
releasedDocIDs = append(releasedDocIDs, docID)
delete(h.locks, docID)
}
}
}
c.closeSend()
h.mu.Unlock()
for _, docID := range releasedDocIDs {
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
}
for _, docID := range docRoomsLeft {
h.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
"docId": docID,
"userId": c.userID,
}, nil)
}
}
func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
@@ -315,120 +271,35 @@ func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool {
return false
}
// Document Locking Methods
func (h *Hub) AcquireDocLock(docID, userID, userName string, ttl time.Duration) (*domain.DocLockInfo, bool, *domain.DocLockInfo) {
// Close gracefully disconnects all WebSocket and SSE connections so HTTP shutdown completes immediately.
func (h *Hub) Close() {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
existing, exists := h.locks[docID]
if exists && existing.expiresAt.After(now) {
if existing.userID != userID {
// Locked by another user
info := &domain.DocLockInfo{
DocID: existing.docID,
UserID: existing.userID,
UserName: existing.userName,
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
}
return nil, false, info
}
// Same user extending lock
existing.expiresAt = now.Add(ttl)
if userName != "" {
existing.userName = userName
}
info := &domain.DocLockInfo{
DocID: existing.docID,
UserID: existing.userID,
UserName: existing.userName,
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
}
return info, true, nil
}
// Create fresh lock
item := &docLockItem{
docID: docID,
userID: userID,
userName: userName,
lockedAt: now,
expiresAt: now.Add(ttl),
}
h.locks[docID] = item
info := &domain.DocLockInfo{
DocID: item.docID,
UserID: item.userID,
UserName: item.userName,
ExpiresAt: item.expiresAt.Format(time.RFC3339),
}
return info, true, nil
}
func (h *Hub) HeartbeatDocLock(docID, userID string, ttl time.Duration) bool {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
item, ok := h.locks[docID]
if !ok || item.userID != userID || item.expiresAt.Before(now) {
return false
}
item.expiresAt = now.Add(ttl)
return true
}
func (h *Hub) ReleaseDocLock(docID, userID string, isAdmin bool) bool {
h.mu.Lock()
defer h.mu.Unlock()
item, ok := h.locks[docID]
if !ok {
return false
}
if item.userID == userID || isAdmin || userID == "admin" {
delete(h.locks, docID)
return true
}
return false
}
func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo {
h.mu.RLock()
defer h.mu.RUnlock()
item, ok := h.locks[docID]
if !ok || item.expiresAt.Before(time.Now()) {
return nil
}
return &domain.DocLockInfo{
DocID: item.docID,
UserID: item.userID,
UserName: item.userName,
ExpiresAt: item.expiresAt.Format(time.RFC3339),
}
}
func (h *Hub) cleanupLocksLoop() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
h.mu.Lock()
now := time.Now()
var expiredDocIDs []string
for docID, l := range h.locks {
if l.expiresAt.Before(now) {
expiredDocIDs = append(expiredDocIDs, docID)
delete(h.locks, docID)
}
}
if h.closed {
h.mu.Unlock()
return
}
h.closed = true
for _, docID := range expiredDocIDs {
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
}
allWsClients := make([]*Client, 0, len(h.allClients))
for c := range h.allClients {
allWsClients = append(allWsClients, c)
}
allSSEClients := make([]*SSEClient, 0, len(h.sseClients))
for c := range h.sseClients {
allSSEClients = append(allSSEClients, c)
}
h.mu.Unlock()
for _, c := range allWsClients {
_ = c.conn.Close()
c.closeSend()
}
for _, c := range allSSEClients {
c.closeSend()
}
}
@@ -446,19 +317,17 @@ func (h *Hub) BroadcastToAll(event string, payload interface{}) {
}
h.mu.RLock()
if h.closed {
h.mu.RUnlock()
return
}
defer h.mu.RUnlock()
for client := range h.allClients {
select {
case client.send <- data:
default:
}
client.trySend(data)
}
for sseClient := range h.sseClients {
select {
case sseClient.send <- data:
default:
}
sseClient.trySend(data)
}
}
@@ -474,22 +343,20 @@ func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
}
h.mu.RLock()
if h.closed {
h.mu.RUnlock()
return
}
defer h.mu.RUnlock()
if room, ok := h.userRooms[userID]; ok {
for client := range room {
select {
case client.send <- data:
default:
}
client.trySend(data)
}
}
if sseRoom, ok := h.sseUserRooms[userID]; ok {
for sseClient := range sseRoom {
select {
case sseClient.send <- data:
default:
}
sseClient.trySend(data)
}
}
}
@@ -519,6 +386,10 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
}
h.mu.RLock()
if h.closed {
h.mu.RUnlock()
return
}
defer h.mu.RUnlock()
targetRoom := listID
@@ -528,19 +399,12 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
if room, ok := h.rooms[targetRoom]; ok {
for client := range room {
select {
case client.send <- data:
default:
// Slow client buffer full
}
client.trySend(data)
}
}
if sseRoom, ok := h.sseRooms[targetRoom]; ok {
for sseClient := range sseRoom {
select {
case sseClient.send <- data:
default:
}
sseClient.trySend(data)
}
}
}
@@ -588,6 +452,10 @@ func (h *Hub) BroadcastRoom(roomID, event string, payload interface{}, excludeCl
}
h.mu.RLock()
if h.closed {
h.mu.RUnlock()
return
}
defer h.mu.RUnlock()
if room, ok := h.rooms[roomID]; ok {
@@ -595,18 +463,12 @@ func (h *Hub) BroadcastRoom(roomID, event string, payload interface{}, excludeCl
if excludeClient != nil && client == excludeClient {
continue
}
select {
case client.send <- data:
default:
}
client.trySend(data)
}
}
if sseRoom, ok := h.sseRooms[roomID]; ok {
for sseClient := range sseRoom {
select {
case sseClient.send <- data:
default:
}
sseClient.trySend(data)
}
}
}
@@ -619,10 +481,7 @@ func (c *Client) SendEvent(event string, payload interface{}) {
}
data, err := json.Marshal(msg)
if err == nil {
select {
case c.send <- data:
default:
}
c.trySend(data)
}
}
@@ -767,21 +626,6 @@ func (c *Client) readPump() {
c.hub.LeaveRoom(c, room)
}
}
if docID != "" {
switch incoming.Event {
case "LOCK_DOC":
if info, ok, _ := c.hub.AcquireDocLock(docID, c.userID, "", 30*time.Second); ok && info != nil {
c.hub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
}
case "HEARTBEAT_DOC":
c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second)
case "UNLOCK_DOC":
if c.hub.ReleaseDocLock(docID, c.userID, false) {
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
}
}
}
}
}
}
+58
View File
@@ -0,0 +1,58 @@
package ws
import (
"sync"
"testing"
"time"
)
func TestHubCloseAndGracefulShutdown(t *testing.T) {
hub := NewHub()
// Register multiple dummy SSE clients
var sseClients []*SSEClient
for i := 0; i < 5; i++ {
client := &SSEClient{
id: "sse-test",
userID: "user-1",
send: make(chan []byte, 10),
}
hub.RegisterSSE(client)
sseClients = append(sseClients, client)
}
// Verify broadcast works
hub.BroadcastToAll("TEST_EVENT", map[string]string{"msg": "hello"})
// Concurrently invoke Close() and Broadcast() to verify no panics or deadlocks
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
hub.BroadcastToAll("SPAM", map[string]int{"i": i})
time.Sleep(1 * time.Millisecond)
}
}()
go func() {
defer wg.Done()
time.Sleep(5 * time.Millisecond)
hub.Close()
}()
wg.Wait()
// Verify that all SSE client channels are closed
for _, c := range sseClients {
select {
case _, ok := <-c.send:
if ok {
// Drained a message, check next
_, ok = <-c.send
}
default:
}
}
}