export class ApiError extends Error { status: number; body: Record; constructor(status: number, body: Record) { const msg = (body.error as string) || `API error: ${status}`; super(msg); this.name = "ApiError"; this.status = status; this.body = body; } } async function handleResponse(res: Response, path: string): Promise { if (!res.ok) { let body: Record = {}; try { body = await res.json(); } catch { body = { error: `API error: ${res.status}` }; } // Redirect to login on 401 (except for auth endpoints) if (res.status === 401 && !path.startsWith("/api/auth/")) { const { default: router } = await import("@/router/index"); const currentPath = window.location.pathname; if (currentPath !== "/login" && currentPath !== "/register") { router.push({ name: "login", query: { redirect: currentPath } }); } } throw new ApiError(res.status, body); } // Handle 204 No Content if (res.status === 204) { return undefined as T; } return res.json() as Promise; } export async function apiGet(path: string): Promise { const res = await fetch(path); return handleResponse(res, path); } export async function apiPost(path: string, body: unknown): Promise { const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return handleResponse(res, path); } export async function apiPut(path: string, body: unknown): Promise { const res = await fetch(path, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return handleResponse(res, path); } export async function apiPatch(path: string, body: unknown): Promise { const res = await fetch(path, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return handleResponse(res, path); } export async function apiDelete(path: string): Promise { const res = await fetch(path, { method: "DELETE" }); return handleResponse(res, path); } // --------------------------------------------------------------------------- // Sharing, Groups, Notifications, User search // --------------------------------------------------------------------------- export interface ShareEntry { id: number permission: string shared_with_user_id: number | null shared_with_group_id: number | null username?: string group_name?: string invited_by: number created_at: string } export interface GroupEntry { id: number name: string description: string | null created_by: number | null member_count: number is_member: boolean created_at: string updated_at: string } export interface GroupMember { id: number group_id: number user_id: number role: string username: string email: string | null created_at: string } export interface NotificationEntry { id: number user_id: number type: string payload: Record read_at: string | null created_at: string } export interface UserSearchResult { id: number username: string email: string | null } // --- User search --- export const searchUsers = (q: string) => apiGet<{ users: UserSearchResult[] }>(`/api/users/search?q=${encodeURIComponent(q)}`).then(r => r.users) // --- Groups --- export const listGroups = () => apiGet<{ groups: GroupEntry[] }>('/api/groups').then(r => r.groups) export const createGroup = (name: string, description?: string) => apiPost('/api/groups', { name, description }) export const updateGroup = (id: number, data: { name?: string; description?: string }) => apiPatch(`/api/groups/${id}`, data) export const deleteGroup = (id: number) => apiDelete(`/api/groups/${id}`) export const getGroupDetail = (id: number) => apiGet(`/api/groups/${id}`) export const listGroupMembers = (id: number) => apiGet<{ members: GroupMember[] }>(`/api/groups/${id}/members`).then(r => r.members) export const addGroupMember = (groupId: number, userId: number, role: string) => apiPost(`/api/groups/${groupId}/members`, { user_id: userId, role }) export const updateGroupMember = (groupId: number, userId: number, role: string) => apiPatch(`/api/groups/${groupId}/members/${userId}`, { role }) export const removeGroupMember = (groupId: number, userId: number) => apiDelete(`/api/groups/${groupId}/members/${userId}`) // --- Project shares --- export const listProjectShares = (projectId: number) => apiGet<{ shares: ShareEntry[] }>(`/api/projects/${projectId}/shares`).then(r => r.shares) export const createProjectShare = (projectId: number, body: { user_id?: number; group_id?: number; permission: string }) => apiPost(`/api/projects/${projectId}/shares`, body) export const updateProjectShare = (projectId: number, shareId: number, permission: string) => apiPatch(`/api/projects/${projectId}/shares/${shareId}`, { permission }) export const deleteProjectShare = (projectId: number, shareId: number) => apiDelete(`/api/projects/${projectId}/shares/${shareId}`) // --- Note / task shares --- export const listNoteShares = (noteId: number) => apiGet<{ shares: ShareEntry[] }>(`/api/notes/${noteId}/shares`).then(r => r.shares) export const createNoteShare = (noteId: number, body: { user_id?: number; group_id?: number; permission: string }) => apiPost(`/api/notes/${noteId}/shares`, body) export const updateNoteShare = (noteId: number, shareId: number, permission: string) => apiPatch(`/api/notes/${noteId}/shares/${shareId}`, { permission }) export const deleteNoteShare = (noteId: number, shareId: number) => apiDelete(`/api/notes/${noteId}/shares/${shareId}`) // --- Shared-with-me --- export const getSharedWithMe = () => apiGet<{ projects: Record[]; notes: Record[] }>('/api/shared-with-me') // --- In-app notifications --- export const getNotifications = (all = false) => apiGet<{ notifications: NotificationEntry[] }>(`/api/notifications${all ? '?all=true' : ''}`).then(r => r.notifications) export const getNotificationCount = () => apiGet<{ count: number }>('/api/notifications/count').then(r => r.count) export const markNotificationRead = (id: number) => apiPost(`/api/notifications/${id}/read`, {}) export const markAllNotificationsRead = () => apiPost<{ marked: number }>('/api/notifications/read-all', {}) export interface SSEStreamHandle { close(): void; /** Resolves when the stream closes (normally or via error/abort). */ done: Promise; } export interface SSENamedEvent { id: number; event: string; data: Record; } export function apiSSEStream( path: string, onEvent: (event: SSENamedEvent) => void, options?: { lastEventId?: number; signal?: AbortSignal }, ): SSEStreamHandle { const controller = new AbortController(); const combinedSignal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal; const headers: Record = {}; if (options?.lastEventId !== undefined) { headers["Last-Event-ID"] = String(options.lastEventId); } const done = (async () => { const res = await fetch(path, { headers, signal: combinedSignal }); if (!res.ok) { let body: Record = {}; try { body = await res.json(); } catch { body = { error: `API error: ${res.status}` }; } if (res.status === 401 && !path.startsWith("/api/auth/")) { const { default: router } = await import("@/router/index"); router.push({ name: "login" }); } throw new ApiError(res.status, body); } const reader = res.body?.getReader(); if (!reader) throw new Error("No response body"); const decoder = new TextDecoder(); let buffer = ""; // SSE field parsing state let currentId = -1; let currentEvent = "message"; let currentData = ""; function dispatch() { if (!currentData) return; let parsed: Record; try { parsed = JSON.parse(currentData); } catch { return; } onEvent({ id: currentId, event: currentEvent, data: parsed }); // Reset for next event currentEvent = "message"; currentData = ""; } function processLine(line: string) { if (line === "") { // Empty line = end of event dispatch(); return; } if (line.startsWith(":")) return; // comment / keepalive const colonIdx = line.indexOf(":"); let field: string; let value: string; if (colonIdx === -1) { field = line; value = ""; } else { field = line.slice(0, colonIdx); value = line.slice(colonIdx + 1); if (value.startsWith(" ")) value = value.slice(1); } switch (field) { case "id": currentId = parseInt(value, 10); break; case "event": currentEvent = value; break; case "data": currentData += value; break; } } while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { processLine(line); } } // Process remaining buffer if (buffer) { processLine(buffer); } dispatch(); })().catch(() => { // Stream closed or aborted — handled by caller via reconnection }); return { close: () => controller.abort(), done }; } // --------------------------------------------------------------------------- // Briefing // --------------------------------------------------------------------------- export interface BriefingLocation { label: string; address: string; lat?: number; lon?: number; } export interface BriefingSlots { compilation: boolean; morning: boolean; midday: boolean; afternoon: boolean; } export interface BriefingConfig { enabled: boolean; locations: { home?: BriefingLocation; work?: BriefingLocation; }; use_caldav_event_locations: boolean; work_days: number[]; slots: BriefingSlots; notifications: boolean; temp_unit: 'C' | 'F'; timezone: string; } export interface BriefingFeed { id: number; title: string; url: string; category: string | null; last_fetched_at: string | null; } export interface BriefingConversation { id: number; title: string; briefing_date: string | null; message_count: number; created_at: string; } export interface BriefingMessage { id: number; role: 'user' | 'assistant' | 'system'; content: string; created_at: string; metadata?: Record | null; } const DEFAULT_BRIEFING_CONFIG: BriefingConfig = { enabled: false, locations: {}, use_caldav_event_locations: false, work_days: [1, 2, 3, 4, 5], slots: { compilation: true, morning: true, midday: false, afternoon: false }, notifications: true, temp_unit: 'C', timezone: '', }; export async function getBriefingConfig(): Promise { try { const data = await apiGet('/api/briefing/config'); return { ...DEFAULT_BRIEFING_CONFIG, ...data }; } catch { return { ...DEFAULT_BRIEFING_CONFIG }; } } export async function saveBriefingConfig(config: BriefingConfig): Promise { await apiPut('/api/briefing/config', config); } export async function getBriefingFeeds(): Promise { const data = await apiGet('/api/briefing/feeds'); return data; } export async function createBriefingFeed(url: string, category?: string): Promise { const body: Record = { url }; if (category?.trim()) body.category = category.trim(); const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', body); return { ...data, last_fetched_at: null }; } export async function refreshBriefingFeeds(): Promise<{ feeds_refreshed: number; new_items: number }> { return apiPost('/api/briefing/feeds/refresh', {}); } export async function deleteBriefingFeed(id: number): Promise { await apiDelete(`/api/briefing/feeds/${id}`); } export async function getBriefingConversations(): Promise { const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations'); return data.conversations; } export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> { return apiGet('/api/briefing/conversations/today'); } export async function getBriefingConvMessages(id: number): Promise { const data = await apiGet<{ messages: BriefingMessage[] }>(`/api/briefing/conversations/${id}/messages`); return data.messages; } export async function triggerBriefingSlot(slot: string): Promise { await apiPost('/api/briefing/trigger', { slot }); } export async function postRssReaction( rssItemId: number, reaction: 'up' | 'down' ): Promise<{ ok: boolean; action: string }> { return apiPost('/api/briefing/rss-reactions', { rss_item_id: rssItemId, reaction }); } export async function deleteRssReaction(rssItemId: number): Promise { return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`); } export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> { try { const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/briefing/weather/geocode', { query: address }); return { lat: r.lat, lon: r.lon, display_name: r.label }; } catch { return null; } } export async function getFableMcpInfo(): Promise<{ available: boolean; filename: string | null }> { return apiGet('/api/fable-mcp/info'); } export async function apiStreamPost( path: string, body: unknown, onChunk: (data: Record) => void ): Promise { const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { let errBody: Record = {}; try { errBody = await res.json(); } catch { errBody = { error: `API error: ${res.status}` }; } if (res.status === 401 && !path.startsWith("/api/auth/")) { const { default: router } = await import("@/router/index"); router.push({ name: "login" }); } throw new ApiError(res.status, errBody); } const reader = res.body?.getReader(); if (!reader) throw new Error("No response body"); const decoder = new TextDecoder(); let buffer = ""; function processLine(line: string) { const trimmed = line.trim(); if (trimmed.startsWith("data: ")) { let data; try { data = JSON.parse(trimmed.slice(6)); } catch { return; // Skip malformed JSON lines } onChunk(data); } } function processBuffer() { const lines = buffer.split("\n"); // Keep the last (possibly incomplete) line in the buffer buffer = lines.pop() || ""; for (const line of lines) { processLine(line); } } try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); processBuffer(); } } catch { // Stream may close with a network error after all data was sent. } // Flush any remaining complete lines in the buffer if (buffer.trim()) { // The buffer may contain one or more unprocessed lines const remaining = buffer; buffer = ""; for (const line of remaining.split("\n")) { processLine(line); } } } // --------------------------------------------------------------------------- // Calendar events // --------------------------------------------------------------------------- export interface EventEntry { id: number; uid: string; title: string; start_dt: string; end_dt: string | null; all_day: boolean; description: string; location: string; color: string; recurrence: string | null; caldav_uid: string; project_id: number | null; user_id: number; created_at: string | null; updated_at: string | null; } export interface EventCreatePayload { title: string; start_dt: string; end_dt?: string; all_day?: boolean; description?: string; location?: string; color?: string; recurrence?: string; project_id?: number; } export interface EventUpdatePayload { title?: string; start_dt?: string; end_dt?: string; all_day?: boolean; description?: string; location?: string; color?: string; recurrence?: string; project_id?: number; } export async function listEvents(from: string, to: string): Promise { return apiGet(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`); } export async function createEvent(payload: EventCreatePayload): Promise { return apiPost('/api/events', payload); } export async function getEvent(id: number): Promise { return apiGet(`/api/events/${id}`); } export async function updateEvent(id: number, payload: EventUpdatePayload): Promise { return apiPatch(`/api/events/${id}`, payload); } export async function deleteEvent(id: number): Promise { return apiDelete(`/api/events/${id}`); }