MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
- Extract: button on the bullet (desktop) or /extract (/notiz). The bullet becomes the title (hashtags stay at the link), its children the content of a new note in the same folder with the source's shares (a foreign source owner gets write access); the branch becomes [[title]] through the operation log, so open editors follow. - Merge: drop a note onto another in the sidebar or use "Mit anderer Notiz zusammenführen…" in the note header. The own source is appended as an h2 section with its bullets below and goes to the trash; the toast offers a complete undo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1797 lines
56 KiB
TypeScript
1797 lines
56 KiB
TypeScript
import {
|
|
APIKey,
|
|
AuthStatus,
|
|
DashboardStats,
|
|
Document,
|
|
TrashedDocument,
|
|
BackupFile,
|
|
DataSection,
|
|
ApiKeyScope,
|
|
Backlink,
|
|
DocumentBlock,
|
|
DocShare,
|
|
KanbanColumn,
|
|
ListItem,
|
|
ListShare,
|
|
Organization,
|
|
PriorityLevel,
|
|
SharedList,
|
|
SharedFolder,
|
|
FolderShare,
|
|
Space,
|
|
SpaceShare,
|
|
SystemSettings,
|
|
SharingMode,
|
|
UserTheme,
|
|
ImportSummary,
|
|
Team,
|
|
TaskList,
|
|
TaskListShare,
|
|
Session,
|
|
Thought,
|
|
SubTask,
|
|
SearchResults,
|
|
TagDetails,
|
|
TagSummary,
|
|
User,
|
|
UserInvite,
|
|
VoiceAdminStatus,
|
|
MergeResult,
|
|
WSMessage,
|
|
} from './types';
|
|
import { extractHashtags } from './hashtags';
|
|
import {
|
|
OfflineError,
|
|
isOffline,
|
|
isCacheablePath,
|
|
readCached,
|
|
writeCached,
|
|
clearOfflineData,
|
|
enqueue,
|
|
flushOutbox,
|
|
} from './offline';
|
|
|
|
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
|
|
|
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);
|
|
}
|
|
|
|
export function clearToken(): void {
|
|
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
|
// Cached answers and unsent changes belong to the account that just left
|
|
void clearOfflineData();
|
|
}
|
|
|
|
export function triggerHaptic(pattern: number | number[] = 15): void {
|
|
try {
|
|
if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
|
|
navigator.vibrate(pattern);
|
|
}
|
|
} catch {
|
|
// Ignore on desktop browsers
|
|
}
|
|
}
|
|
|
|
function ensureArray<T>(val: T[] | null | undefined): T[] {
|
|
return Array.isArray(val) ? val : [];
|
|
}
|
|
|
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const token = getToken();
|
|
const method = (options.method || 'GET').toUpperCase();
|
|
const cacheable = method === 'GET' && isCacheablePath(path);
|
|
const userKey = getCurrentUserId() || 'anon';
|
|
|
|
// Known to be offline: answer from the cache right away instead of waiting for the timeout
|
|
if (cacheable && isOffline()) {
|
|
const cached = await readCached<T>(userKey, path);
|
|
if (cached !== undefined) return cached;
|
|
}
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-cache',
|
|
...(options.headers as Record<string, string>),
|
|
};
|
|
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = window.setTimeout(() => controller.abort(), 12000);
|
|
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(path, {
|
|
...options,
|
|
headers,
|
|
cache: 'no-store',
|
|
signal: controller.signal,
|
|
});
|
|
} catch {
|
|
// No HTTP answer at all (offline, server down, timeout): fall back to the last known answer
|
|
if (cacheable) {
|
|
const cached = await readCached<T>(userKey, path);
|
|
if (cached !== undefined) return cached;
|
|
}
|
|
throw new OfflineError();
|
|
} finally {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
|
|
if (
|
|
res.status === 401 &&
|
|
!path.includes('/auth/login') &&
|
|
!path.includes('/auth/register') &&
|
|
!path.includes('/auth/setup') &&
|
|
!path.includes('/auth/status')
|
|
) {
|
|
clearToken();
|
|
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
|
|
}
|
|
|
|
// Sliding session: the server hands out a fresh token once a day (or after a password change)
|
|
const renewed = res.headers.get('X-Session-Token');
|
|
if (renewed && res.ok) setToken(renewed);
|
|
|
|
if (!res.ok) {
|
|
let errMsg = `HTTP ${res.status}`;
|
|
try {
|
|
const errData = await res.json();
|
|
if (errData.error) errMsg = errData.error;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
throw new Error(errMsg);
|
|
}
|
|
|
|
const data = await res.json();
|
|
if (cacheable) writeCached(userKey, path, data);
|
|
return data;
|
|
}
|
|
|
|
// Sends a queued outbox entry with the current token (no timeout, no cache)
|
|
function sendOutboxEntry(entry: { method: string; path: string; body: Record<string, unknown> }): Promise<Response> {
|
|
const token = getToken();
|
|
return fetch(entry.path, {
|
|
method: entry.method,
|
|
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
body: JSON.stringify(entry.body),
|
|
cache: 'no-store',
|
|
});
|
|
}
|
|
|
|
// Server-wide sharing mode from /auth/status; in private mode the UI hides sharing and groups
|
|
let currentSharingMode: SharingMode = 'organizations';
|
|
export const setCurrentSharingMode = (mode: SharingMode) => {
|
|
currentSharingMode = mode;
|
|
};
|
|
export const isSharingDisabled = () => currentSharingMode === 'private';
|
|
|
|
// Authenticated binary download (archives); no JSON parsing and no request timeout
|
|
async function downloadBlob(path: string): Promise<Blob> {
|
|
const token = getToken();
|
|
const res = await fetch(path, { headers: token ? { Authorization: `Bearer ${token}` } : {}, cache: 'no-store' });
|
|
if (!res.ok) throw new Error(`Download fehlgeschlagen (HTTP ${res.status})`);
|
|
return res.blob();
|
|
}
|
|
|
|
export const api = {
|
|
// --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
|
|
async getAuthStatus(): Promise<AuthStatus> {
|
|
const st = await request<AuthStatus>('/api/v1/auth/status');
|
|
if (st.sharingMode) setCurrentSharingMode(st.sharingMode);
|
|
return st;
|
|
},
|
|
|
|
// Public, works before login
|
|
async getLegalText(type: 'imprint' | 'privacy'): Promise<string> {
|
|
const res = await request<{ content: string }>(`/api/v1/legal/${type}`);
|
|
return res.content || '';
|
|
},
|
|
|
|
async logout(): Promise<void> {
|
|
try {
|
|
await request<{ success: boolean }>('/api/v1/auth/logout', { method: 'POST' });
|
|
} finally {
|
|
clearToken();
|
|
}
|
|
},
|
|
|
|
async listSessions(): Promise<Session[]> {
|
|
return ensureArray(await request<Session[]>('/api/v1/auth/sessions'));
|
|
},
|
|
|
|
async endSession(id: string): Promise<void> {
|
|
await request(`/api/v1/auth/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async revokeAllSessions(): Promise<void> {
|
|
await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' });
|
|
},
|
|
|
|
async completeSetup(payload: {
|
|
email: string;
|
|
password: string;
|
|
displayName: string;
|
|
instanceName?: string;
|
|
sharingMode?: SharingMode;
|
|
allowRegistration?: boolean;
|
|
createSampleData?: boolean;
|
|
}): Promise<{ token: string; user: User }> {
|
|
const res = await request<{ token: string; user: User }>('/api/v1/auth/setup', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
setToken(res.token);
|
|
return res;
|
|
},
|
|
|
|
// Public: always the same answer, whether the address exists or not
|
|
async requestPasswordReset(email: string): Promise<void> {
|
|
await request('/api/v1/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) });
|
|
},
|
|
|
|
async resetPassword(token: string, password: string): Promise<void> {
|
|
await request('/api/v1/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, password }) });
|
|
},
|
|
|
|
async login(loginOrEmail: string, password: string): Promise<{ token: string; user: User }> {
|
|
const res = await request<{ token: string; user: User }>('/api/v1/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email: loginOrEmail, username: loginOrEmail, login: loginOrEmail, password }),
|
|
});
|
|
setToken(res.token);
|
|
return res;
|
|
},
|
|
|
|
async register(
|
|
email: string,
|
|
password: string,
|
|
displayName: string,
|
|
inviteCode?: string
|
|
): Promise<{ token: string; user: User }> {
|
|
const res = await request<{ token: string; user: User }>('/api/v1/auth/register', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email, password, displayName, inviteCode: inviteCode || '' }),
|
|
});
|
|
setToken(res.token);
|
|
return res;
|
|
},
|
|
|
|
async getMe(): Promise<{ user: User; stats: DashboardStats; isLastAdmin?: boolean }> {
|
|
return request<{ user: User; stats: DashboardStats; isLastAdmin?: boolean }>('/api/v1/auth/me');
|
|
},
|
|
|
|
async updateSettings(patch: {
|
|
displayName?: string;
|
|
email?: string;
|
|
password?: string;
|
|
currentPassword?: string;
|
|
avatarUrl?: string;
|
|
alarmChannels?: string[];
|
|
alarmEmail?: string;
|
|
discordWebhookUrl?: string;
|
|
customWebhookUrl?: string;
|
|
notifyBeforeMinutes?: number;
|
|
}): Promise<User> {
|
|
return request<User>('/api/v1/auth/settings', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
},
|
|
|
|
async testAlarmChannel(
|
|
channel: 'email' | 'discord' | 'webhook',
|
|
target?: string
|
|
): Promise<{ status: string }> {
|
|
return request<{ status: string }>('/api/v1/auth/alarms/test', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ channel, target: target || '' }),
|
|
});
|
|
},
|
|
|
|
async getStats(): Promise<DashboardStats> {
|
|
return request<DashboardStats>('/api/v1/stats');
|
|
},
|
|
|
|
async getUsersDirectory(): Promise<User[]> {
|
|
const res = await request<User[]>('/api/v1/users/directory');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
// --- INSTANT CAPTURE & OFFLINE SYNC ---
|
|
async captureThought(
|
|
content: string,
|
|
priority: PriorityLevel = 'normal',
|
|
source: 'manual' | 'voice' = 'manual',
|
|
dueDate = '',
|
|
options?: {
|
|
target?: 'inbox' | 'task' | 'list_item' | 'document';
|
|
targetListId?: string;
|
|
taskListId?: string;
|
|
}
|
|
): Promise<Thought> {
|
|
const body = {
|
|
content,
|
|
priority,
|
|
source,
|
|
dueDate,
|
|
target: options?.target || 'inbox',
|
|
targetListId: options?.targetListId || '',
|
|
taskListId: options?.taskListId || '',
|
|
};
|
|
try {
|
|
return await request<Thought>('/api/v1/inbox', { method: 'POST', body: JSON.stringify(body) });
|
|
} catch (err) {
|
|
if (!(err instanceof OfflineError)) throw err;
|
|
// Kept in the outbox and sent when the connection is back
|
|
const entry = enqueue('POST', '/api/v1/inbox', body);
|
|
return {
|
|
id: `th_offline_${entry.id}`,
|
|
userId: getCurrentUserId() || 'offline',
|
|
content,
|
|
tags: Array.from(new Set(extractHashtags(content))),
|
|
priority,
|
|
source,
|
|
status: 'inbox',
|
|
isCompleted: false,
|
|
dueDate,
|
|
taskListId: options?.taskListId || '',
|
|
createdAt: entry.createdAt,
|
|
};
|
|
}
|
|
},
|
|
|
|
/** Sends what was captured or checked off while offline; returns how many changes went through. */
|
|
async flushOfflineQueue(): Promise<number> {
|
|
if (!getToken()) return 0;
|
|
return flushOutbox(sendOutboxEntry);
|
|
},
|
|
|
|
// --- THOUGHTS, TRIAGE & TASKS ---
|
|
async getTriageDeck(): Promise<Thought[]> {
|
|
const res = await request<Thought[]>('/api/v1/triage/deck');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async getThoughts(status = 'all'): Promise<Thought[]> {
|
|
const res = await request<Thought[]>(`/api/v1/thoughts?status=${encodeURIComponent(status)}`);
|
|
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',
|
|
options?: {
|
|
convertTo?: 'task' | 'document' | 'list_item';
|
|
targetListId?: string;
|
|
taskListId?: string;
|
|
dueDate?: string;
|
|
}
|
|
): Promise<Thought> {
|
|
return request<Thought>(`/api/v1/triage/${thoughtId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({
|
|
action,
|
|
convertTo: options?.convertTo || '',
|
|
targetListId: options?.targetListId || '',
|
|
taskListId: options?.taskListId || '',
|
|
dueDate: options?.dueDate || '',
|
|
}),
|
|
});
|
|
},
|
|
|
|
async getTasks(taskListId?: string): Promise<Thought[]> {
|
|
const query = taskListId ? `?taskListId=${encodeURIComponent(taskListId)}` : '';
|
|
const res = await request<Thought[]>(`/api/v1/tasks${query}`);
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> {
|
|
const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder';
|
|
return request<{ success: boolean }>(url, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ items }),
|
|
});
|
|
},
|
|
|
|
async updateThought(
|
|
thoughtId: string,
|
|
patch: {
|
|
content?: string;
|
|
isCompleted?: boolean;
|
|
dueDate?: string;
|
|
priority?: string;
|
|
taskListId?: string;
|
|
kanbanStatus?: string;
|
|
notes?: string;
|
|
subtasks?: SubTask[];
|
|
tags?: string[];
|
|
assignees?: string[];
|
|
recurrence?: string;
|
|
}
|
|
): Promise<Thought> {
|
|
try {
|
|
return await request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
} catch (err) {
|
|
// Checking a task off (or on) also works offline; other edits need the server
|
|
const onlyStatus = Object.keys(patch).every((k) => k === 'isCompleted' || k === 'kanbanStatus');
|
|
if (!(err instanceof OfflineError) || !onlyStatus) throw err;
|
|
enqueue('PATCH', `/api/v1/thoughts/${thoughtId}`, patch);
|
|
return { id: thoughtId, ...patch } as Thought;
|
|
}
|
|
},
|
|
|
|
async deleteThought(thoughtId: string): Promise<void> {
|
|
await request(`/api/v1/thoughts/${thoughtId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async purgeDiscarded(): Promise<{ purged: number }> {
|
|
return request<{ purged: number }>('/api/v1/thoughts/discarded/purge', { method: 'DELETE' });
|
|
},
|
|
|
|
// --- TASK LISTS ---
|
|
async getTaskLists(): Promise<TaskList[]> {
|
|
const res = await request<TaskList[]>('/api/v1/task-lists');
|
|
return ensureArray(res).map((tl) => ({
|
|
...tl,
|
|
members: ensureArray(tl.members),
|
|
shares: ensureArray(tl.shares),
|
|
columns: ensureArray(tl.columns),
|
|
}));
|
|
},
|
|
|
|
async createTaskList(payload: {
|
|
title: string;
|
|
icon?: string;
|
|
color?: string;
|
|
teamId?: string;
|
|
spaceId?: string;
|
|
columns?: KanbanColumn[];
|
|
}): Promise<TaskList> {
|
|
return request<TaskList>('/api/v1/task-lists', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async updateTaskList(
|
|
id: string,
|
|
patch: {
|
|
title?: string;
|
|
icon?: string;
|
|
color?: string;
|
|
teamId?: string;
|
|
spaceId?: string;
|
|
columns?: KanbanColumn[];
|
|
}
|
|
): Promise<TaskList> {
|
|
return request<TaskList>(`/api/v1/task-lists/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
},
|
|
|
|
async deleteTaskList(id: string): Promise<void> {
|
|
await request(`/api/v1/task-lists/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async getTaskListShares(id: string): Promise<TaskListShare[]> {
|
|
const res = await request<TaskListShare[]>(`/api/v1/task-lists/${id}/shares`);
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async addTaskListShare(
|
|
id: string,
|
|
payload: { targetType: 'team' | 'user'; targetId: string; permission?: 'editor' | 'viewer' }
|
|
): Promise<TaskListShare> {
|
|
return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async removeTaskListShare(id: string, shareId: string): Promise<void> {
|
|
await request(`/api/v1/task-lists/${id}/shares/${shareId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
// --- WORKSPACE FOLDERS ---
|
|
async getFolders(): Promise<string[]> {
|
|
const res = await request<string[]>('/api/v1/folders');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async createFolder(path: string): Promise<{ path: string }> {
|
|
return request<{ path: string }>('/api/v1/folders', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ path }),
|
|
});
|
|
},
|
|
|
|
async deleteFolder(path: string): Promise<{ deleted: boolean; deletedDocIds: string[] }> {
|
|
return request<{ deleted: boolean; deletedDocIds: string[] }>(
|
|
`/api/v1/folders?path=${encodeURIComponent(path)}`,
|
|
{ method: 'DELETE' }
|
|
);
|
|
},
|
|
|
|
async moveFolder(sourcePath: string, targetParentPath: string): Promise<{ newPath: string; movedDocIds: string[] }> {
|
|
return request<{ newPath: string; movedDocIds: string[] }>('/api/v1/folders/move', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ sourcePath, targetParentPath }),
|
|
});
|
|
},
|
|
|
|
// --- WORKSPACE SHARED FOLDERS ---
|
|
async getSharedFolders(): Promise<SharedFolder[]> {
|
|
const res = await request<SharedFolder[]>('/api/v1/workspace/folders/shared');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async createSharedFolder(payload: { name: string; mountPath?: string }): Promise<SharedFolder> {
|
|
return request<SharedFolder>('/api/v1/workspace/folders/shared', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async getSharedFolder(id: string): Promise<SharedFolder> {
|
|
return request<SharedFolder>(`/api/v1/workspace/folders/shared/${id}`);
|
|
},
|
|
|
|
async deleteSharedFolder(id: string): Promise<void> {
|
|
await request(`/api/v1/workspace/folders/shared/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async updateFolderMount(id: string, mountPath: string): Promise<void> {
|
|
await request(`/api/v1/workspace/folders/shared/${id}/mount`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ mountPath }),
|
|
});
|
|
},
|
|
|
|
async getFolderShares(folderId: string): Promise<FolderShare[]> {
|
|
const res = await request<FolderShare[]>(`/api/v1/workspace/folders/shared/${folderId}/shares`);
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async createFolderShare(
|
|
folderId: string,
|
|
payload: { targetType?: 'team' | 'user'; targetId?: string; userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' }
|
|
): Promise<FolderShare> {
|
|
return request<FolderShare>(`/api/v1/workspace/folders/shared/${folderId}/shares`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async deleteFolderShare(folderId: string, shareId: string): Promise<void> {
|
|
await request(`/api/v1/workspace/folders/shared/${folderId}/shares/${shareId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
// --- WORKSPACE TAGS ---
|
|
async getWorkspaceTags(): Promise<TagSummary[]> {
|
|
const res = await request<TagSummary[]>('/api/v1/workspace/tags');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async getTagDetails(tag: string): Promise<TagDetails> {
|
|
const clean = encodeURIComponent(tag.replace(/^#/, ''));
|
|
const res = await request<TagDetails>(`/api/v1/workspace/tags/${clean}`);
|
|
return {
|
|
...res,
|
|
documents: ensureArray(res.documents).map((doc) => ({
|
|
...doc,
|
|
matchedBlocks: ensureArray(doc.matchedBlocks).map((mb) => ({
|
|
rootBlock: mb.rootBlock,
|
|
children: ensureArray(mb.children),
|
|
})),
|
|
})),
|
|
};
|
|
},
|
|
|
|
async updateTagPageNote(
|
|
tag: string,
|
|
content: string
|
|
): Promise<{ tag: string; content: string; updatedAt: string }> {
|
|
const clean = encodeURIComponent(tag.replace(/^#/, ''));
|
|
return request<{ tag: string; content: string; updatedAt: string }>(
|
|
`/api/v1/workspace/tags/${clean}/note`,
|
|
{
|
|
method: 'PUT',
|
|
body: JSON.stringify({ content }),
|
|
}
|
|
);
|
|
},
|
|
|
|
// --- WORKSPACE DOCUMENTS ---
|
|
async getDocuments(): Promise<Document[]> {
|
|
const res = await request<Document[]>('/api/v1/documents');
|
|
return ensureArray(res).map((d) => ({
|
|
...d,
|
|
blocks: ensureArray(d.blocks),
|
|
tags: ensureArray(d.tags),
|
|
}));
|
|
},
|
|
|
|
async getDocument(id: string): Promise<Document> {
|
|
const d = await request<Document>(`/api/v1/documents/${id}`);
|
|
return {
|
|
...d,
|
|
blocks: ensureArray(d.blocks),
|
|
tags: ensureArray(d.tags),
|
|
};
|
|
},
|
|
|
|
async createDocument(payload: {
|
|
title: string;
|
|
icon?: string;
|
|
folderPath?: string;
|
|
blocks?: DocumentBlock[];
|
|
tags?: string[];
|
|
isPinned?: boolean;
|
|
}): Promise<Document> {
|
|
const d = await request<Document>('/api/v1/documents', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
return {
|
|
...d,
|
|
blocks: ensureArray(d.blocks),
|
|
tags: ensureArray(d.tags),
|
|
};
|
|
},
|
|
|
|
async updateDocument(
|
|
id: string,
|
|
patch: {
|
|
title?: string;
|
|
icon?: string;
|
|
folderPath?: string;
|
|
isPinned?: boolean;
|
|
}
|
|
): Promise<Document> {
|
|
// Block content is saved through the note sync engine (lib/docSync), not here
|
|
const d = await request<Document>(`/api/v1/documents/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
return {
|
|
...d,
|
|
blocks: ensureArray(d.blocks),
|
|
tags: ensureArray(d.tags),
|
|
};
|
|
},
|
|
|
|
async joinSSERoom(sseId: string, room: string, action: 'join' | 'leave' = 'join'): Promise<{ success: boolean }> {
|
|
return request<{ success: boolean }>('/api/v1/events/rooms', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ sseId, room, action }),
|
|
});
|
|
},
|
|
|
|
/** Moves the note to the trash (owner) or leaves a shared note (others). */
|
|
async deleteDocument(id: string): Promise<void> {
|
|
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async getBacklinks(id: string): Promise<Backlink[]> {
|
|
return request<Backlink[]>(`/api/v1/documents/${id}/backlinks`);
|
|
},
|
|
|
|
async getTrash(): Promise<TrashedDocument[]> {
|
|
return request<TrashedDocument[]>('/api/v1/documents/trash');
|
|
},
|
|
|
|
/** Turns a bullet and its children into a note of its own; the bullet becomes a [[link]]. */
|
|
async extractBranch(docId: string, blockId: string, clientId: string): Promise<Document> {
|
|
return request<Document>(`/api/v1/documents/${docId}/extract`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ blockId, clientId }),
|
|
});
|
|
},
|
|
|
|
/** Appends the source note to the target; the source goes to the trash. */
|
|
async mergeDocuments(targetId: string, sourceId: string): Promise<MergeResult> {
|
|
return request<MergeResult>(`/api/v1/documents/${targetId}/merge`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ sourceId }),
|
|
});
|
|
},
|
|
|
|
async undoMerge(m: MergeResult): Promise<void> {
|
|
await request(`/api/v1/documents/${m.targetId}/merge/undo`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ sourceId: m.sourceId, headingId: m.headingId }),
|
|
});
|
|
},
|
|
|
|
async restoreDocument(id: string): Promise<void> {
|
|
await request(`/api/v1/documents/${id}/restore`, { method: 'POST' });
|
|
},
|
|
|
|
async purgeDocument(id: string): Promise<void> {
|
|
await request(`/api/v1/documents/${id}/permanent`, { method: 'DELETE' });
|
|
},
|
|
|
|
async emptyTrash(): Promise<{ deleted: number }> {
|
|
return request<{ deleted: number }>('/api/v1/documents/trash', { method: 'DELETE' });
|
|
},
|
|
|
|
async moveDocument(id: string, folderPath: string): Promise<Document> {
|
|
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ folderPath }),
|
|
});
|
|
return {
|
|
...d,
|
|
blocks: ensureArray(d.blocks),
|
|
tags: ensureArray(d.tags),
|
|
};
|
|
},
|
|
|
|
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`);
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async createDocShare(
|
|
docId: string,
|
|
payload: { userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' }
|
|
): Promise<DocShare> {
|
|
return request<DocShare>(`/api/v1/documents/${docId}/shares`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async deleteDocShare(docId: string, userId: string): Promise<void> {
|
|
await request(`/api/v1/documents/${docId}/shares/${userId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async getCollaborators(): Promise<User[]> {
|
|
const res = await request<User[]>('/api/v1/collaborators');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
// --- SPACES & REALTIME LISTS ---
|
|
async getSpaces(): Promise<Space[]> {
|
|
const res = await request<Space[]>('/api/v1/spaces');
|
|
return ensureArray(res).map((s) => ({
|
|
...s,
|
|
members: ensureArray(s.members),
|
|
shares: ensureArray(s.shares),
|
|
}));
|
|
},
|
|
|
|
async getSpaceShares(spaceId: string): Promise<SpaceShare[]> {
|
|
const res = await request<SpaceShare[]>(`/api/v1/spaces/${spaceId}/shares`);
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async createSpace(name: string, icon: string, description: string): Promise<Space> {
|
|
return request<Space>('/api/v1/spaces', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, icon, description }),
|
|
});
|
|
},
|
|
|
|
async joinSpace(inviteCode: string): Promise<Space> {
|
|
return request<Space>('/api/v1/spaces/join', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ inviteCode }),
|
|
});
|
|
},
|
|
|
|
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise<any> {
|
|
return request<any>(`/api/v1/spaces/${spaceId}/members`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(target),
|
|
});
|
|
},
|
|
|
|
async removeSpaceMember(spaceId: string, userId: string): Promise<void> {
|
|
await request(`/api/v1/spaces/${spaceId}/members/${userId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
},
|
|
|
|
async getSpaceLists(spaceId: string): Promise<SharedList[]> {
|
|
const res = await request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
|
|
return ensureArray(res).map((l) => ({
|
|
...l,
|
|
items: ensureArray(l.items),
|
|
}));
|
|
},
|
|
|
|
async getAllLists(): Promise<SharedList[]> {
|
|
const res = await request<SharedList[]>('/api/v1/lists');
|
|
return ensureArray(res).map((l) => ({
|
|
...l,
|
|
items: ensureArray(l.items),
|
|
shares: ensureArray(l.shares),
|
|
}));
|
|
},
|
|
|
|
async getListById(listId: string): Promise<SharedList> {
|
|
const l = await request<SharedList>(`/api/v1/lists/${listId}`);
|
|
return {
|
|
...l,
|
|
items: ensureArray(l.items),
|
|
shares: ensureArray(l.shares),
|
|
};
|
|
},
|
|
|
|
async createList(titleOrSpaceId: string, titleOrIcon?: string, iconOrType?: string, listType?: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
|
|
let title = titleOrSpaceId;
|
|
let icon = titleOrIcon || '🛒';
|
|
let type = (iconOrType as any) || 'shopping';
|
|
if (listType !== undefined) {
|
|
title = titleOrIcon || '';
|
|
icon = iconOrType || '🛒';
|
|
type = listType;
|
|
}
|
|
const l = await request<SharedList>('/api/v1/lists', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ title, icon, listType: type }),
|
|
});
|
|
return {
|
|
...l,
|
|
items: ensureArray(l.items),
|
|
shares: ensureArray(l.shares),
|
|
};
|
|
},
|
|
|
|
async deleteList(listId: string): Promise<void> {
|
|
await request(`/api/v1/lists/${listId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async getListShares(listId: string): Promise<ListShare[]> {
|
|
const res = await request<ListShare[]>(`/api/v1/lists/${listId}/shares`);
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async addListShare(listId: string, target: { targetType: 'team' | 'user'; targetId: string; role?: string }): Promise<ListShare> {
|
|
return request<ListShare>(`/api/v1/lists/${listId}/shares`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(target),
|
|
});
|
|
},
|
|
|
|
async removeListShare(listId: string, shareId: string): Promise<void> {
|
|
await request(`/api/v1/lists/${listId}/shares/${shareId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> {
|
|
return request<ListItem>(`/api/v1/lists/${listId}/items`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ content, category, quantity }),
|
|
});
|
|
},
|
|
|
|
async toggleListItem(listId: string, itemId: string, isCompleted?: boolean): Promise<ListItem> {
|
|
try {
|
|
return await request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ isCompleted }),
|
|
});
|
|
} catch (err) {
|
|
if (!(err instanceof OfflineError) || isCompleted === undefined) throw err;
|
|
enqueue('PATCH', `/api/v1/lists/${listId}/items/${itemId}`, { isCompleted });
|
|
return { id: itemId, listId, isCompleted } as ListItem;
|
|
}
|
|
},
|
|
|
|
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' });
|
|
},
|
|
|
|
async clearCompletedItems(listId: string): Promise<{ removed: number }> {
|
|
return request<{ removed: number }>(`/api/v1/lists/${listId}/clear-completed`, {
|
|
method: 'POST',
|
|
});
|
|
},
|
|
|
|
async reorderListItems(
|
|
listId: string,
|
|
items: { id: string; sortOrder: number; category?: string; content?: string }[]
|
|
): Promise<{ success: boolean }> {
|
|
return request<{ success: boolean }>(`/api/v1/lists/${listId}/items/reorder`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ items }),
|
|
});
|
|
},
|
|
|
|
// --- GROUPS (user-managed) ---
|
|
async createTeam(name: string, description = ''): Promise<Team> {
|
|
return request<Team>('/api/v1/teams', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, description }),
|
|
});
|
|
},
|
|
|
|
async updateTeam(teamId: string, name: string, description = ''): Promise<Team> {
|
|
return request<Team>(`/api/v1/teams/${teamId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ name, description }),
|
|
});
|
|
},
|
|
|
|
async deleteTeam(teamId: string): Promise<void> {
|
|
await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async getUserTeams(): Promise<Team[]> {
|
|
const res = await request<Team[]>('/api/v1/teams');
|
|
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
|
|
},
|
|
|
|
async addTeamMember(
|
|
teamId: string,
|
|
payload: { userId?: string; email?: string; role: string }
|
|
): Promise<void> {
|
|
await request(`/api/v1/teams/${teamId}/members`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async updateTeamMemberRole(
|
|
teamId: string,
|
|
userId: string,
|
|
role: 'admin' | 'member'
|
|
): Promise<void> {
|
|
await request(`/api/v1/teams/${teamId}/members/${userId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ role }),
|
|
});
|
|
},
|
|
|
|
async removeTeamMember(teamId: string, userId: string): Promise<void> {
|
|
await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' });
|
|
},
|
|
|
|
// --- ADMIN & STAFF PANEL ---
|
|
async adminGetUsers(): Promise<User[]> {
|
|
const res = await request<User[]>('/api/v1/admin/users');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async adminCreateUser(payload: {
|
|
email: string;
|
|
password: string;
|
|
displayName: string;
|
|
role: string;
|
|
organizationId?: string;
|
|
}): Promise<User> {
|
|
return request<User>('/api/v1/admin/users', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async adminUpdateUser(
|
|
id: string,
|
|
patch: {
|
|
displayName?: string;
|
|
email?: string;
|
|
role?: string;
|
|
isActive?: boolean;
|
|
discordWebhookUrl?: string;
|
|
}
|
|
): Promise<User> {
|
|
return request<User>(`/api/v1/admin/users/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(patch),
|
|
});
|
|
},
|
|
|
|
async adminResetPassword(id: string, password: string): Promise<void> {
|
|
await request(`/api/v1/admin/users/${id}/password`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ password }),
|
|
});
|
|
},
|
|
|
|
async adminDeleteUser(id: string): Promise<void> {
|
|
await request(`/api/v1/admin/users/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async adminGetInvites(): Promise<UserInvite[]> {
|
|
const res = await request<UserInvite[]>('/api/v1/admin/invites');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async adminCreateInvite(payload: {
|
|
email?: string;
|
|
role: string;
|
|
organizationId?: string;
|
|
}): Promise<UserInvite> {
|
|
return request<UserInvite>('/api/v1/admin/invites', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async adminDeleteInvite(id: string): Promise<void> {
|
|
await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async adminVoiceStatus(): Promise<VoiceAdminStatus> {
|
|
return request<VoiceAdminStatus>('/api/v1/admin/voice');
|
|
},
|
|
|
|
// Sends a recording to the own Whisper server (voice mode "server") and returns the text
|
|
async transcribe(audio: Blob): Promise<string> {
|
|
const token = getToken();
|
|
const ext = audio.type.includes('mp4') ? 'm4a' : audio.type.includes('ogg') ? 'ogg' : 'webm';
|
|
const form = new FormData();
|
|
form.append('file', audio, `aufnahme.${ext}`);
|
|
const res = await fetch('/api/v1/voice/transcribe', {
|
|
method: 'POST',
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
body: form,
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || `Spracherkennung fehlgeschlagen (HTTP ${res.status})`);
|
|
return data.text || '';
|
|
},
|
|
|
|
async adminGetSettings(): Promise<SystemSettings> {
|
|
return request<SystemSettings>('/api/v1/admin/settings');
|
|
},
|
|
|
|
async adminUpdateSettings(settings: SystemSettings): Promise<SystemSettings> {
|
|
return request<SystemSettings>('/api/v1/admin/settings', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(settings),
|
|
});
|
|
},
|
|
|
|
// Counts shares and group memberships a switch to `mode` would remove
|
|
async adminSharingImpact(mode: SharingMode): Promise<number> {
|
|
const res = await request<{ affected: number }>(`/api/v1/admin/settings/sharing-impact?mode=${mode}`);
|
|
return res.affected || 0;
|
|
},
|
|
|
|
// --- ORGANIZATIONS (server admin only) ---
|
|
async adminGetOrganizations(): Promise<Organization[]> {
|
|
return ensureArray(await request<Organization[]>('/api/v1/admin/organizations'));
|
|
},
|
|
|
|
async adminCreateOrganization(name: string): Promise<Organization> {
|
|
return request<Organization>('/api/v1/admin/organizations', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
},
|
|
|
|
async adminRenameOrganization(id: string, name: string): Promise<void> {
|
|
await request(`/api/v1/admin/organizations/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
},
|
|
|
|
async adminDeleteOrganization(id: string): Promise<void> {
|
|
await request(`/api/v1/admin/organizations/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
async adminSetUserOrganization(userId: string, organizationId: string): Promise<void> {
|
|
await request(`/api/v1/admin/users/${userId}/organization`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ organizationId }),
|
|
});
|
|
},
|
|
|
|
async adminTestSMTP(toEmail: string): Promise<{ status: string }> {
|
|
return request<{ status: string }>('/api/v1/admin/smtp/test', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ toEmail }),
|
|
});
|
|
},
|
|
|
|
// --- CUSTOM THEMES ---
|
|
async getThemes(): Promise<UserTheme[]> {
|
|
return ensureArray(await request<UserTheme[]>('/api/v1/themes'));
|
|
},
|
|
|
|
// Creates a theme, or updates it when `id` is given
|
|
async saveTheme(theme: { id?: string; name: string; css: string }): Promise<UserTheme> {
|
|
return request<UserTheme>('/api/v1/themes', {
|
|
method: 'POST',
|
|
body: JSON.stringify(theme),
|
|
});
|
|
},
|
|
|
|
async deleteTheme(id: string): Promise<void> {
|
|
await request(`/api/v1/themes/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
// --- API KEYS, WEBHOOKS & EXPORT ---
|
|
async getAPIKeys(): Promise<APIKey[]> {
|
|
const res = await request<APIKey[]>('/api/v1/keys');
|
|
return ensureArray(res);
|
|
},
|
|
|
|
async createAPIKey(name: string, scopes: ApiKeyScope[]): Promise<APIKey> {
|
|
return request<APIKey>('/api/v1/keys', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, scopes }),
|
|
});
|
|
},
|
|
|
|
async deleteAPIKey(id: string): Promise<void> {
|
|
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
|
|
},
|
|
|
|
/** Simulator: sends like an external system would, with the given key or else the own session. */
|
|
async testWebhookIngest(
|
|
apiKey: string | null,
|
|
payload: {
|
|
title: string;
|
|
priority?: PriorityLevel;
|
|
tags?: string[];
|
|
target?: 'triage' | 'task' | 'list_item';
|
|
taskListId?: string;
|
|
listId?: string;
|
|
}
|
|
): Promise<{ status: string }> {
|
|
return request<{ status: string }>('/api/v1/webhooks/ingest', {
|
|
method: 'POST',
|
|
headers: apiKey ? { 'X-API-Key': apiKey } : {},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
|
|
async searchAll(q: string): Promise<SearchResults> {
|
|
return request<SearchResults>(
|
|
`/api/v1/search?q=${encodeURIComponent(q)}`
|
|
);
|
|
},
|
|
|
|
async exportAll(): Promise<any> {
|
|
return request<any>('/api/v1/export');
|
|
},
|
|
|
|
// Import of MindSift data (JSON backup or Markdown archive) or an Obsidian vault (ZIP).
|
|
// preview = true only reports what would be created.
|
|
// mode 'restore' (MindSift data only) deletes the user's notes, tasks and lists before importing
|
|
async importData(
|
|
source: 'mindsift' | 'obsidian',
|
|
file: File,
|
|
preview: boolean,
|
|
mode: 'merge' | 'restore' = 'merge',
|
|
sections?: DataSection[]
|
|
): Promise<ImportSummary> {
|
|
const token = getToken();
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
const params = new URLSearchParams();
|
|
if (preview) params.set('preview', '1');
|
|
if (mode === 'restore') params.set('mode', 'restore');
|
|
if (sections && sections.length > 0) params.set('sections', sections.join(','));
|
|
const res = await fetch(`/api/v1/import/${source}${params.toString() ? `?${params}` : ''}`, {
|
|
method: 'POST',
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
body: form,
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || `Import fehlgeschlagen (HTTP ${res.status})`);
|
|
return data as ImportSummary;
|
|
},
|
|
|
|
// --- Automatic server backups ---
|
|
async adminListBackups(): Promise<BackupFile[]> {
|
|
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups'));
|
|
},
|
|
|
|
async adminRunBackup(): Promise<BackupFile[]> {
|
|
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups', { method: 'POST' }));
|
|
},
|
|
|
|
async adminDownloadBackup(name: string): Promise<Blob> {
|
|
return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`);
|
|
},
|
|
|
|
async listMyBackups(): Promise<BackupFile[]> {
|
|
return ensureArray(await request<BackupFile[]>('/api/v1/backups'));
|
|
},
|
|
|
|
async downloadMyBackup(name: string): Promise<Blob> {
|
|
return downloadBlob(`/api/v1/backups/${encodeURIComponent(name)}`);
|
|
},
|
|
|
|
async restoreMyBackup(name: string, mode: 'merge' | 'restore', sections: DataSection[], preview = false): Promise<ImportSummary> {
|
|
const params = new URLSearchParams({ mode, sections: sections.join(',') });
|
|
if (preview) params.set('preview', '1');
|
|
return request<ImportSummary>(`/api/v1/backups/${encodeURIComponent(name)}/restore?${params}`, { method: 'POST' });
|
|
},
|
|
|
|
// Danger zone: both need the current password
|
|
async purgeOwnData(password: string): Promise<void> {
|
|
await request('/api/v1/user/data', { method: 'DELETE', body: JSON.stringify({ password }) });
|
|
},
|
|
|
|
async deleteOwnAccount(password: string): Promise<void> {
|
|
await request('/api/v1/user/account', { method: 'DELETE', body: JSON.stringify({ password }) });
|
|
},
|
|
|
|
// Full backup (all data as JSON) packed into a ZIP; restorable via importData('mindsift', …)
|
|
async exportBackupZip(): Promise<Blob> {
|
|
return downloadBlob('/api/v1/export?format=zip');
|
|
},
|
|
|
|
// Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout
|
|
async exportMarkdownVault(): Promise<Blob> {
|
|
return downloadBlob('/api/v1/export/markdown');
|
|
},
|
|
};
|
|
|
|
|
|
export type RealtimeTransport = 'ws' | 'sse' | 'none';
|
|
|
|
let activeTransport: RealtimeTransport = 'none';
|
|
let activeGlobalWS: WebSocket | null = null;
|
|
let activeGlobalSSE: EventSource | null = null;
|
|
let activeSSEId: string | null = null;
|
|
let wsReconnectTimer: any = null;
|
|
let wsConnectionWatchdog: any = null;
|
|
let wsListeners: ((msg: WSMessage) => void)[] = [];
|
|
let realtimeStatusListeners: ((connected: boolean, transport: RealtimeTransport) => void)[] = [];
|
|
let isRealtimeConnectedState = false;
|
|
let manualDisconnect = false;
|
|
let wsReconnectDelay = 2000;
|
|
|
|
// WebSocket and EventSource cannot send headers, so instead of the session token (which would land in
|
|
// proxy logs) every connection gets a one-time ticket, valid for a minute (POST /auth/stream-ticket).
|
|
async function fetchStreamTicket(): Promise<string | null> {
|
|
try {
|
|
const res = await request<{ ticket?: string }>('/api/v1/auth/stream-ticket', { method: 'POST' });
|
|
return res?.ticket || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function buildWebSocketURL(ticket: string, listId?: string): string {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
if (listId) {
|
|
return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?ticket=${encodeURIComponent(ticket)}`;
|
|
}
|
|
return `${protocol}//${window.location.host}/api/v1/ws?ticket=${encodeURIComponent(ticket)}`;
|
|
}
|
|
|
|
export function buildSSEURL(ticket: string, listId?: string, docId?: string, rooms?: string[]): string {
|
|
const params = new URLSearchParams();
|
|
params.set('ticket', ticket);
|
|
if (listId) params.set('listId', listId);
|
|
if (docId) params.set('docId', docId);
|
|
if (rooms && rooms.length > 0) params.set('rooms', rooms.join(','));
|
|
return `/api/v1/events?${params.toString()}`;
|
|
}
|
|
|
|
export function isWebSocketConnected(): boolean {
|
|
return activeTransport === 'ws' && activeGlobalWS !== null && activeGlobalWS.readyState === WebSocket.OPEN;
|
|
}
|
|
|
|
export function isRealtimeConnected(): boolean {
|
|
return isRealtimeConnectedState;
|
|
}
|
|
|
|
export function getRealtimeTransport(): RealtimeTransport {
|
|
return activeTransport;
|
|
}
|
|
|
|
export function getSSEId(): string | null {
|
|
return activeSSEId;
|
|
}
|
|
|
|
export function onWebSocketStatus(callback: (connected: boolean) => void): () => void {
|
|
const wrapper = (connected: boolean) => callback(connected);
|
|
realtimeStatusListeners.push(wrapper);
|
|
callback(isRealtimeConnected());
|
|
return () => {
|
|
realtimeStatusListeners = realtimeStatusListeners.filter((fn) => fn !== wrapper);
|
|
};
|
|
}
|
|
|
|
export function onRealtimeStatus(callback: (connected: boolean, transport: RealtimeTransport) => void): () => void {
|
|
realtimeStatusListeners.push(callback);
|
|
callback(isRealtimeConnected(), activeTransport);
|
|
return () => {
|
|
realtimeStatusListeners = realtimeStatusListeners.filter((fn) => fn !== callback);
|
|
};
|
|
}
|
|
|
|
let isWsBlockedByProxy = typeof window !== 'undefined' && sessionStorage.getItem('mindsift_ws_blocked') === 'true';
|
|
// Some proxies buffer event streams: the connection opens, but events only arrive much later (or never).
|
|
// Then SSE is useless and the note sync engines fall back to long polling.
|
|
let isSseBufferedByProxy = typeof window !== 'undefined' && sessionStorage.getItem('mindsift_sse_buffered') === 'true';
|
|
let sseFirstEventWatchdog: any = null;
|
|
const SSE_FIRST_EVENT_TIMEOUT_MS = 5000;
|
|
// Some proxies accept the WebSocket upgrade but then forward nothing. An app-level ping detects that.
|
|
let wsHeartbeatTimer: any = null;
|
|
let wsLastMessageAt = 0;
|
|
const WS_PING_INTERVAL_MS = 15000;
|
|
const WS_DEAD_AFTER_MS = 35000;
|
|
let sseReconnectTimer: any = null;
|
|
let sseReconnectDelay = 2000;
|
|
const globalSubscribedRooms = new Set<string>();
|
|
|
|
function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTransport) {
|
|
isRealtimeConnectedState = connected;
|
|
activeTransport = transport;
|
|
realtimeStatusListeners.forEach((fn) => {
|
|
try {
|
|
fn(connected, transport);
|
|
} catch (e) {
|
|
console.error('Realtime status listener error', e);
|
|
}
|
|
});
|
|
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.`);
|
|
clearInterval(wsHeartbeatTimer);
|
|
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();
|
|
}
|
|
|
|
let sseTicketPending = false;
|
|
|
|
export async function connectSSE(rooms?: string[]): Promise<void> {
|
|
if (rooms) {
|
|
rooms.forEach((r) => globalSubscribedRooms.add(r));
|
|
}
|
|
clearTimeout(sseReconnectTimer);
|
|
clearTimeout(wsReconnectTimer);
|
|
clearTimeout(wsConnectionWatchdog);
|
|
clearTimeout(sseFirstEventWatchdog);
|
|
|
|
if (isSseBufferedByProxy) {
|
|
notifyRealtimeStatusChange(false, 'none');
|
|
return;
|
|
}
|
|
|
|
if (activeGlobalSSE) {
|
|
try { activeGlobalSSE.close(); } catch {}
|
|
activeGlobalSSE = null;
|
|
}
|
|
|
|
if (sseTicketPending) return;
|
|
sseTicketPending = true;
|
|
const ticket = await fetchStreamTicket();
|
|
sseTicketPending = false;
|
|
if (manualDisconnect || !getToken()) return;
|
|
if (!ticket) {
|
|
// Server unreachable or session gone: try again later like after a dropped stream
|
|
notifyRealtimeStatusChange(false, 'none');
|
|
sseReconnectTimer = setTimeout(() => connectSSE(), sseReconnectDelay);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const url = buildSSEURL(ticket, undefined, undefined, Array.from(globalSubscribedRooms));
|
|
const es = new EventSource(url);
|
|
activeGlobalSSE = es;
|
|
|
|
es.onopen = () => {
|
|
sseReconnectDelay = 2000;
|
|
// Connected only counts once the first event really arrives (see SSE_CONNECTED below)
|
|
clearTimeout(sseFirstEventWatchdog);
|
|
sseFirstEventWatchdog = setTimeout(() => {
|
|
console.warn('[MindSift Realtime] SSE liefert keine Ereignisse (Proxy puffert) -> Long-Polling');
|
|
isSseBufferedByProxy = true;
|
|
try {
|
|
sessionStorage.setItem('mindsift_sse_buffered', 'true');
|
|
} catch {}
|
|
try {
|
|
es.close();
|
|
} catch {}
|
|
if (activeGlobalSSE === es) activeGlobalSSE = null;
|
|
notifyRealtimeStatusChange(false, 'none');
|
|
}, SSE_FIRST_EVENT_TIMEOUT_MS);
|
|
};
|
|
|
|
es.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data) as WSMessage;
|
|
if (msg.event === 'SSE_CONNECTED') {
|
|
clearTimeout(sseFirstEventWatchdog);
|
|
notifyRealtimeStatusChange(true, 'sse');
|
|
activeSSEId = msg.payload?.sseId || null;
|
|
if (activeSSEId && globalSubscribedRooms.size > 0) {
|
|
globalSubscribedRooms.forEach((room) => {
|
|
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
|
|
});
|
|
}
|
|
}
|
|
wsListeners.forEach((fn) => {
|
|
try {
|
|
fn(msg);
|
|
} catch (e) {
|
|
console.error('SSE listener error', e);
|
|
}
|
|
});
|
|
window.dispatchEvent(new CustomEvent('mindsift:ws', { detail: msg }));
|
|
} catch (err) {
|
|
console.error('Failed to parse SSE message', err);
|
|
}
|
|
};
|
|
|
|
es.onerror = (e) => {
|
|
clearTimeout(sseFirstEventWatchdog);
|
|
console.warn('[MindSift Realtime] SSE Verbindungsfehler:', e);
|
|
if (activeTransport === 'sse') {
|
|
notifyRealtimeStatusChange(false, 'none');
|
|
}
|
|
try { es.close(); } catch {}
|
|
activeGlobalSSE = null;
|
|
if (!manualDisconnect && getToken()) {
|
|
clearTimeout(sseReconnectTimer);
|
|
const delay = sseReconnectDelay;
|
|
sseReconnectDelay = Math.min(30000, Math.round(sseReconnectDelay * 1.5));
|
|
sseReconnectTimer = setTimeout(() => {
|
|
if (!manualDisconnect && getToken()) {
|
|
connectSSE();
|
|
}
|
|
}, delay);
|
|
}
|
|
};
|
|
} catch (err) {
|
|
console.error('Failed to initialize EventSource SSE', err);
|
|
notifyRealtimeStatusChange(false, 'none');
|
|
}
|
|
}
|
|
|
|
let wsTicketPending = false;
|
|
|
|
export async function connectWebSocket(): Promise<void> {
|
|
const token = getToken();
|
|
if (!token) return;
|
|
manualDisconnect = false;
|
|
|
|
// If proxy blocked WebSocket previously, do not attempt failing WebSocket; stay on SSE
|
|
if (isWsBlockedByProxy) {
|
|
return connectSSE();
|
|
}
|
|
|
|
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
|
|
clearTimeout(wsConnectionWatchdog);
|
|
clearTimeout(wsReconnectTimer);
|
|
clearTimeout(sseReconnectTimer);
|
|
|
|
if (wsTicketPending) return;
|
|
wsTicketPending = true;
|
|
const ticket = await fetchStreamTicket();
|
|
wsTicketPending = false;
|
|
if (manualDisconnect || !getToken()) return;
|
|
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
if (!ticket) {
|
|
notifyRealtimeStatusChange(false, 'none');
|
|
wsReconnectTimer = setTimeout(() => connectWebSocket(), wsReconnectDelay);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const url = buildWebSocketURL(ticket);
|
|
const ws = new WebSocket(url);
|
|
activeGlobalWS = ws;
|
|
|
|
// 2.5s watchdog for corporate proxies silently stripping Upgrade header or hanging
|
|
wsConnectionWatchdog = setTimeout(() => {
|
|
if (ws && ws.readyState !== WebSocket.OPEN) {
|
|
switchToSSE('WebSocket Timeout nach 2.5s (Proxy Upgrade Header blockiert)');
|
|
}
|
|
}, 2500);
|
|
|
|
ws.onopen = () => {
|
|
clearTimeout(wsConnectionWatchdog);
|
|
wsLastMessageAt = Date.now();
|
|
clearInterval(wsHeartbeatTimer);
|
|
wsHeartbeatTimer = setInterval(() => {
|
|
if (Date.now() - wsLastMessageAt > WS_DEAD_AFTER_MS) {
|
|
switchToSSE('WebSocket liefert keine Antworten mehr (Proxy leitet nichts weiter)');
|
|
return;
|
|
}
|
|
try {
|
|
ws.send(JSON.stringify({ event: 'PING' }));
|
|
} catch {}
|
|
}, WS_PING_INTERVAL_MS);
|
|
if (activeGlobalSSE) {
|
|
try { activeGlobalSSE.close(); } catch {}
|
|
activeGlobalSSE = null;
|
|
}
|
|
wsReconnectDelay = 2000;
|
|
notifyRealtimeStatusChange(true, 'ws');
|
|
if (globalSubscribedRooms.size > 0) {
|
|
globalSubscribedRooms.forEach((room) => {
|
|
try {
|
|
ws.send(JSON.stringify({ event: 'JOIN_ROOM', room }));
|
|
} catch {}
|
|
});
|
|
}
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
wsLastMessageAt = Date.now();
|
|
try {
|
|
const msg = JSON.parse(event.data) as WSMessage;
|
|
if (msg.event === 'PONG') return;
|
|
wsListeners.forEach((fn) => {
|
|
try {
|
|
fn(msg);
|
|
} catch (e) {
|
|
console.error('WS listener error', e);
|
|
}
|
|
});
|
|
window.dispatchEvent(new CustomEvent('mindsift:ws', { detail: msg }));
|
|
} catch (err) {
|
|
console.error('Failed to parse WS message', err);
|
|
}
|
|
};
|
|
|
|
ws.onclose = (evt: CloseEvent) => {
|
|
clearTimeout(wsConnectionWatchdog);
|
|
clearInterval(wsHeartbeatTimer);
|
|
activeGlobalWS = null;
|
|
|
|
// 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;
|
|
}
|
|
|
|
if (activeTransport === 'ws') {
|
|
notifyRealtimeStatusChange(false, 'none');
|
|
}
|
|
|
|
if (!manualDisconnect && getToken() && activeTransport !== 'sse') {
|
|
clearTimeout(wsReconnectTimer);
|
|
const delay = wsReconnectDelay;
|
|
wsReconnectDelay = Math.min(30000, Math.round(wsReconnectDelay * 1.5));
|
|
wsReconnectTimer = setTimeout(() => {
|
|
if (!manualDisconnect && getToken()) {
|
|
connectWebSocket();
|
|
}
|
|
}, delay);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
clearTimeout(wsConnectionWatchdog);
|
|
switchToSSE('WebSocket Fehler (Handshake/Verbindung verweigert)');
|
|
};
|
|
} catch (e) {
|
|
switchToSSE('WebSocket Exception beim Initialisieren');
|
|
}
|
|
}
|
|
|
|
// Ensure reconnect when browser recovers from sleep or tab visibility
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('online', () => {
|
|
if (getToken() && !isRealtimeConnected()) {
|
|
if (isWsBlockedByProxy) {
|
|
connectSSE();
|
|
} else {
|
|
connectWebSocket();
|
|
}
|
|
}
|
|
});
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible' && getToken() && !isRealtimeConnected()) {
|
|
if (isWsBlockedByProxy) {
|
|
connectSSE();
|
|
} else {
|
|
connectWebSocket();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export function disconnectWebSocket(): void {
|
|
clearInterval(wsHeartbeatTimer);
|
|
clearTimeout(sseFirstEventWatchdog);
|
|
manualDisconnect = true;
|
|
clearTimeout(wsReconnectTimer);
|
|
clearTimeout(wsConnectionWatchdog);
|
|
clearTimeout(sseReconnectTimer);
|
|
if (activeGlobalWS) {
|
|
try {
|
|
activeGlobalWS.close();
|
|
} catch {}
|
|
activeGlobalWS = null;
|
|
}
|
|
if (activeGlobalSSE) {
|
|
try {
|
|
activeGlobalSSE.close();
|
|
} catch {}
|
|
activeGlobalSSE = null;
|
|
}
|
|
notifyRealtimeStatusChange(false, 'none');
|
|
}
|
|
|
|
export function sendWebSocketMessage(msg: any): void {
|
|
if (activeGlobalWS && activeGlobalWS.readyState === WebSocket.OPEN) {
|
|
activeGlobalWS.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
|
return;
|
|
}
|
|
|
|
// SSE Fallback: dispatch via REST endpoints
|
|
try {
|
|
const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg;
|
|
if (parsed.event === 'JOIN_ROOM' && parsed.room) {
|
|
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'join').catch(() => {});
|
|
} else if (parsed.event === 'LEAVE_ROOM' && parsed.room) {
|
|
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'leave').catch(() => {});
|
|
}
|
|
} catch (err) {
|
|
console.error('Error dispatching fallback REST message for SSE:', err);
|
|
}
|
|
}
|
|
|
|
export function onWebSocketMessage(callback: (msg: WSMessage) => void): () => void {
|
|
wsListeners.push(callback);
|
|
return () => {
|
|
wsListeners = wsListeners.filter((fn) => fn !== callback);
|
|
};
|
|
}
|
|
|
|
export function createRealtimeConnection(options: {
|
|
listId?: string;
|
|
docId?: string;
|
|
rooms?: string[];
|
|
onMessage?: (msg: WSMessage) => void;
|
|
onStatusChange?: (status: { connected: boolean; transport: RealtimeTransport; reason?: string }) => void;
|
|
}) {
|
|
let isClosed = false;
|
|
|
|
const joinedRooms: string[] = [];
|
|
if (options.docId) joinedRooms.push(`doc:${options.docId}`);
|
|
if (options.listId) joinedRooms.push(options.listId);
|
|
if (options.rooms) {
|
|
options.rooms.forEach((r) => {
|
|
if (!joinedRooms.includes(r)) joinedRooms.push(r);
|
|
});
|
|
}
|
|
|
|
joinedRooms.forEach((r) => globalSubscribedRooms.add(r));
|
|
|
|
// Subscribe to status changes
|
|
const unsubStatus = onRealtimeStatus((connected, transport) => {
|
|
if (isClosed) return;
|
|
options.onStatusChange?.({
|
|
connected,
|
|
transport,
|
|
reason: connected ? undefined : 'Verbindung getrennt',
|
|
});
|
|
});
|
|
|
|
// Subscribe to messages
|
|
let unsubMsg: (() => void) | null = null;
|
|
if (options.onMessage) {
|
|
unsubMsg = onWebSocketMessage((msg) => {
|
|
if (isClosed) return;
|
|
options.onMessage?.(msg);
|
|
});
|
|
}
|
|
|
|
// Join rooms on the active connection
|
|
const joinAll = () => {
|
|
joinedRooms.forEach((r) => {
|
|
sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
|
|
if (activeSSEId) {
|
|
api.joinSSERoom(activeSSEId, r, 'join').catch(() => {});
|
|
}
|
|
});
|
|
};
|
|
|
|
if (isRealtimeConnected()) {
|
|
joinAll();
|
|
}
|
|
|
|
const unsubRejoin = onRealtimeStatus((connected) => {
|
|
if (isClosed) return;
|
|
if (connected) {
|
|
joinAll();
|
|
}
|
|
});
|
|
|
|
return {
|
|
getTransport: () => activeTransport,
|
|
isConnected: () => isRealtimeConnected(),
|
|
send: (msg: any) => {
|
|
if (isClosed) return;
|
|
sendWebSocketMessage(msg);
|
|
},
|
|
close: () => {
|
|
isClosed = true;
|
|
unsubStatus();
|
|
unsubRejoin();
|
|
if (unsubMsg) unsubMsg();
|
|
joinedRooms.forEach((r) => {
|
|
globalSubscribedRooms.delete(r);
|
|
sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
|
|
if (activeSSEId) {
|
|
api.joinSSERoom(activeSSEId, r, 'leave').catch(() => {});
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|