// Typed API client. All fetch calls go through here — never use fetch directly in components. export interface User { id: string username: string display_name: string is_admin: boolean quota_bytes: number used_bytes: number /** Persönliche Palette – bleibt erhalten, auch wenn sie gerade nicht * angewendet wird (siehe use_custom_theme). */ primary_color: string accent_color: string use_custom_theme: boolean /** Konto meldet sich über den Identitätsanbieter an und hat hier kein * Passwort – die Passwortänderung wird dann ausgeblendet. */ is_oidc?: boolean /** Zeitstempel des Profilbildes in Sekunden; 0 oder fehlend = keins. * Adresse dazu über avatarUrl(). */ avatar_ver?: number created_at: string } /** * Adresse des Profilbildes – oder null, wenn keines hinterlegt ist. * * Der Zeitstempel hängt als ?v= dran, damit der Browser das Bild lange behalten * darf und es trotzdem sofort wechselt, sobald ein anderes hinterlegt wird. */ export function avatarUrl(user: Pick): string | null { return user.avatar_ver ? `/api/v1/users/${user.id}/avatar?v=${user.avatar_ver}` : null } export interface FileItem { id: string /** Datum auf dem Beleg (YYYY-MM-DD) – nicht das des Uploads. */ document_date?: string owner_id: string | null group_id: string | null folder_id: string | null name: string mime_type: string size: number checksum: string has_thumbnail: boolean version: number versioning_enabled: boolean created_at: string updated_at: string } export interface FolderItem { id: string /** Genau eines von beiden ist gesetzt: persönlicher Ordner oder Gruppenordner. */ owner_id: string | null group_id: string | null parent_id: string | null name: string created_at: string } export interface ListResponse { files: FileItem[] folders: FolderItem[] /** Etiketten je Datei-ID. Kommen mit der Liste in einer Abfrage mit – bei * fuenfzig Kacheln waeren fuenfzig Einzelabfragen der falsche Weg. */ tags?: Record /** Absender je Datei-ID – auf demselben Weg wie die Etiketten. */ correspondents?: Record } export interface AuthResponse { user: User csrf_token: string } export interface ShareLink { id: string file_id: string owner_id: string token: string has_password: boolean expires_at: string | null download_count: number created_at: string } export interface CreateShareRequest { password?: string expires_in_hours?: number } /** Was ein Freigabelink **ohne Anmeldung** über sich preisgibt. Bewusst * nicht der ganze ShareLink: Besitzer und interne Kennungen gehen niemanden * etwas an, der bloß den Link hat. */ export interface PublicShareInfo { has_password: boolean expires_at?: string | null download_count: number created_at: string file_name: string file_size: number mime_type: string } /** Ein einzelner Befund des Abgleichs zwischen Platte und Datenbank. */ export interface RescanEntry { kind: 'new' | 'orphaned' | 'changed' storage_key: string /** Pfad, wie er in Aether erscheint – nicht der auf der Platte. */ path: string owner: string size: number /** Gesetzt, wenn das Anwenden an diesem Eintrag gescheitert ist. */ reason?: string } export interface RescanReport { new: number orphaned: number changed: number scanned: number samples: RescanEntry[] aborted?: boolean /** Nur nach dem Anwenden gefüllt. */ adopted?: number removed?: number corrected?: number errors?: number } export interface FileVersion { id: string file_id: string version: number size: number checksum: string created_at: string } export interface UploadProgress { loaded: number total: number percent: number } class APIError extends Error { constructor(public status: number, message: string) { super(message) this.name = 'APIError' } } let csrfToken = '' export function setCSRFToken(token: string) { csrfToken = token } async function request( method: string, path: string, body?: unknown, isFormData = false, /** Abbruchsignal – die Suche verwirft damit überholte Anfragen. */ signal?: AbortSignal, ): Promise { const headers: Record = {} if (csrfToken) headers['X-CSRF-Token'] = csrfToken if (body && !isFormData) headers['Content-Type'] = 'application/json' const res = await fetch(`/api/v1${path}`, { method, headers, credentials: 'include', signal, body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined, }) if (res.status === 204) return undefined as T const json = await res.json().catch(() => ({ error: res.statusText })) if (!res.ok) throw new APIError(res.status, json.error ?? 'Unknown error') return json.data ?? json as T } // XHR-based upload with real progress reporting. export function uploadWithProgress( file: File, folderId: string | undefined, onProgress: (p: UploadProgress) => void ): Promise { return new Promise((resolve, reject) => { const form = new FormData() // folder_id MUST come before the file so the server can stream the file // straight to storage without buffering the whole body first. if (folderId) form.append('folder_id', folderId) form.append('file', file) const xhr = new XMLHttpRequest() xhr.open('POST', '/api/v1/files') xhr.withCredentials = true if (csrfToken) xhr.setRequestHeader('X-CSRF-Token', csrfToken) xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { onProgress({ loaded: e.loaded, total: e.total, percent: Math.round((e.loaded / e.total) * 100) }) } }) xhr.addEventListener('load', () => { if (xhr.status === 201) { try { const json = JSON.parse(xhr.responseText) resolve(json.data ?? json) } catch { reject(new APIError(xhr.status, 'Invalid response')) } } else { let msg = 'Upload failed' try { msg = JSON.parse(xhr.responseText).error ?? msg } catch { /* kein JSON – Standardtext behalten */ } // 413 kommt hier nur vom Quota-Check; der rohe Servertext sagt einem // Nutzer vor dem Upload-Fenster wenig. if (xhr.status === 413) msg = 'Storage full – your quota is not sufficient' reject(new APIError(xhr.status, msg)) } }) xhr.addEventListener('error', () => reject(new APIError(0, 'Network error'))) xhr.addEventListener('abort', () => reject(new APIError(0, 'Upload aborted'))) xhr.send(form) }) } // ── Auth ───────────────────────────────────────────────────────────────────── export interface PublicConfig { oidc_enabled: boolean cloud_name: string /** Leer, wenn kein Logo hinterlegt ist. Enthält einen Versionsanteil, der * sich bei jedem Austausch ändert – damit Browser nicht das alte zeigen. */ logo_url?: string /** Tab-Symbol (64 px) aus dem hochgeladenen App-Symbol. Leer, wenn keins * hinterlegt ist. Ebenfalls mit Versionsanteil. */ favicon_url?: string /** Dasselbe Symbol in 192 px – für den Startbildschirm von iOS. */ apple_touch_icon_url?: string /** Gibt es einen Office-Server? Sonst bietet das Menue das Bearbeiten gar * nicht erst an – ein Punkt, der immer scheitert, ist schlimmer als keiner. */ office_enabled?: boolean /** 'collabora' | 'onlyoffice' | '' – entscheidet über die Handy-Ansicht. */ office_provider?: string /** Wie viele frühere Fassungen der Server aufhebt (MAX_VERSIONS_KEPT). */ max_versions_kept?: number /** Beschriftung des SSO-Knopfes (OIDC_BUTTON_LABEL). Leer, wenn OIDC aus ist. */ oidc_button_label?: string primary_color?: string accent_color?: string /** Ursprung der HoneyOS-Leiste (HONEYBAR_ORIGIN). Leer = keine Leiste. */ honeybar_origin?: string } export const publicApi = { config: (): Promise => fetch('/api/v1/public/config').then(r => r.json()).then(j => j.data ?? j), } export const authApi = { login: (username: string, password: string) => request('POST', '/auth/login', { username, password }), logout: () => request('POST', '/auth/logout'), me: () => request('GET', '/auth/me'), updateProfile: (displayName: string) => request('PATCH', '/auth/profile', { display_name: displayName }), updateColors: (primaryColor: string, accentColor: string) => request('PATCH', '/auth/profile', { primary_color: primaryColor, accent_color: accentColor, }), setUseCustomTheme: (enabled: boolean) => request('PATCH', '/auth/profile', { use_custom_theme: enabled }), changePassword: (currentPassword: string, newPassword: string) => request('POST', '/auth/change-password', { current_password: currentPassword, new_password: newPassword }), } // ── Internal shares (people and groups) ─────────────────────────────────────── export type SharePermission = 'read' | 'write' export interface AclEntry { id: string subject_type: 'user' | 'group' subject_id: string subject_name: string permission: SharePermission created_at: string } export interface ShareRecipient { id: string name: string type: 'user' | 'group' } export interface SharedResource { type: 'folder' | 'file' id: string name: string owner_id: string owner_name: string permission: SharePermission size: number mime_type: string shared_at: string /** Gesetzt, wenn der Zugriff über eine Gruppe kommt statt persönlich. */ via_group: string } export const aclApi = { recipients: () => request('GET', '/share-recipients'), sharedWithMe: () => request('GET', '/shared-with-me'), listForFolder: (id: string) => request('GET', `/folders/${id}/acl`), listForFile: (id: string) => request('GET', `/files/${id}/acl`), grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) => request('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }), grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) => request('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }), revoke: (entryId: string) => request('DELETE', `/acl/${entryId}`), } // ── App passwords (WebDAV / sync clients) ───────────────────────────────────── export interface AppPassword { id: string name: string last_used_at: string | null created_at: string } // The plaintext password is returned exactly once, on creation. export interface AppPasswordCreated extends AppPassword { password: string } export const appPasswordsApi = { list: () => request('GET', '/app-passwords'), create: (name: string) => request('POST', '/app-passwords', { name }), revoke: (id: string) => request('DELETE', `/app-passwords/${id}`), } // ── Files ───────────────────────────────────────────────────────────────────── // ── Suche ──────────────────────────────────────────────────────────────────── export interface SearchHit { kind: 'folder' | 'file' id: string name: string /** Ordner, in dem der Treffer liegt – null heisst Wurzel. */ folder_id: string | null folder_name: string mime_type?: string size: number has_thumbnail: boolean updated_at: string /** true, wenn nicht der Name passte, sondern der erkannte Text im Dokument. */ matched_content?: boolean /** Fundstelle im Text; Treffer in …. Nur bei matched_content. */ snippet?: string } export interface OcrStatus { enabled: boolean /** Fehlen alle, wenn enabled false ist. */ pending?: number running?: number done?: number failed?: number /** Dateien mit gespeichertem Text. */ indexed?: number } export interface SearchResponse { hits: SearchHit[] query: string /** true, wenn die Liste bei der Obergrenze abgeschnitten wurde. */ truncated?: boolean } export interface Tag { id: string name: string /** Leer heisst: die Oberflaeche leitet eine Farbe aus dem Namen ab. */ color: string /** Emoji oder leer. Steht **neben** der Farbe: Die Farbe traegt die * Wiedererkennung im Augenwinkel, das Symbol die Bedeutung. Ab etwa fuenf * Etiketten reicht Farbe allein nicht mehr – dann fragt man sich, ob Blau * nun „Uni" oder „Versicherung" war. */ icon: string /** Gesetzt, wenn das Etikett einer Gruppe gehoert – dann sehen es alle * Mitglieder, und es gilt nur fuer Dateien in deren Gruppenordner. */ group_id?: string group_name?: string file_count: number created_at: string } /** * Absender („Korrespondent") – von wem ein Dokument kommt. * * Der Unterschied zum Etikett ist die Anzahl: Etiketten sagen, *wofür* ein * Dokument gebraucht wird, davon trägt eine Datei beliebig viele. Der Absender * sagt, *von wem* es kommt – davon hat eine Datei genau einen. Deshalb ein * Feld und keine Mehrfachauswahl, und deshalb eine eigene Liste: Sonst stünde * „Stadtwerke" neben „absetzbar" in derselben Aufzählung. */ export interface Correspondent { id: string name: string /** Emoji oder leer – dieselbe Regel wie beim Etikett. */ icon: string group_id?: string group_name?: string file_count: number created_at: string } /** Datei-Treffer beim Filtern nach einem Etikett. */ export interface TaggedFile { id: string name: string folder_id: string | null folder_name: string mime_type: string size: number has_thumbnail: boolean /** Datum auf dem Beleg – nicht das des Uploads. */ document_date?: string updated_at: string } /** * Meldet, dass sich an den Etiketten etwas geändert hat. * * Die Seitenleiste zeigt Zählstände, die sich ändern, sobald irgendwo * etikettiert wird – und sie steht neben dem Dialog, in dem das passiert. Eine * Zahl, die daneben stehenbleibt, während man sie gerade verändert hat, sieht * nach einem Fehler aus. * * Ein Ereignis statt Zustand durch drei Ebenen zu reichen: Der Dialog steckt * tief im Dateibrowser, die Seitenleiste hängt am Shell – ein gemeinsamer * Zustand nur für einen Zählstand wäre viel Verkabelung für wenig. */ export const tagsChangedEvent = 'aether:tags-changed' export function notifyTagsChanged() { window.dispatchEvent(new Event(tagsChangedEvent)) } /** * Startet einen Archiv-Download. * * Über ein abgeschicktes Formular, nicht über fetch(): Nur eine **Navigation** * behandelt der Browser als Download. Ein fetch() müsste die Antwort erst * vollständig in den Speicher holen – bei einem 5-GB-Ordner also 5 GB im * Arbeitsspeicher, und der Nutzer sähe bis dahin nichts. * * POST statt GET, weil eine Auswahl beliebig lang sein kann: Fünfzig * Kennungen sind schon knapp 1900 Zeichen, und Adressen haben eine Grenze. */ export function downloadZip(opts: { fileIds?: string[]; folderId?: string; name?: string }) { const form = document.createElement('form') form.method = 'POST' form.action = '/api/v1/download/zip' // Das CSRF-Token als Feld – ein Formular kann keine Header setzen. Der // Server vergleicht es weiterhin gegen das Cookie (Double-Submit). const add = (name: string, value: string) => { const input = document.createElement('input') input.type = 'hidden' input.name = name input.value = value form.appendChild(input) } add('csrf_token', csrfToken) if (opts.folderId) add('folder_id', opts.folderId) if (opts.name) add('name', opts.name) for (const id of opts.fileIds ?? []) add('file_ids', id) document.body.appendChild(form) form.submit() form.remove() } export const tagsApi = { list: () => request('GET', '/tags'), create: (name: string, color = '', groupId?: string, icon = '') => request('POST', '/tags', { name, color, icon, group_id: groupId ?? '' }), update: (id: string, name: string, color: string, icon = '') => request('PATCH', `/tags/${id}`, { name, color, icon }), remove: (id: string) => request('DELETE', `/tags/${id}`), files: (id: string) => request('GET', `/tags/${id}/files`), /** * Setzt Etiketten, Belegdatum und Absender einer Datei in **einem** Vorgang. * * Alles drei steht im selben Dialog, also geht es auch zusammen zum Server – * sonst gäbe es einen Zustand, in dem die Etiketten sitzen und der Absender * nicht. * * Weglassen heisst jeweils „unveraendert", leerer String heisst „loeschen". * `correspondentName` legt den Absender bei Bedarf an; das ist der Normalfall * beim Tippen. */ setForFile: (fileId: string, tagIds: string[], documentDate?: string, correspondent?: { id?: string; name?: string }) => request('PUT', `/files/${fileId}/tags`, { tag_ids: tagIds, ...(documentDate === undefined ? {} : { document_date: documentDate }), ...(correspondent?.name ? { correspondent_name: correspondent.name } : correspondent?.id === undefined ? {} : { correspondent_id: correspondent.id }), }), } export const correspondentsApi = { list: () => request('GET', '/correspondents'), create: (name: string, groupId?: string, icon = '') => request('POST', '/correspondents', { name, icon, group_id: groupId ?? '' }), update: (id: string, name: string, icon = '') => request('PATCH', `/correspondents/${id}`, { name, icon }), remove: (id: string) => request('DELETE', `/correspondents/${id}`), files: (id: string) => request('GET', `/correspondents/${id}/files`), } /** Was der Server über eine Office-Sitzung mitteilt. */ export interface OfficeSession { enabled: boolean provider?: string /** Adresse der Bedienoberflaeche beim Office-Server, samt WOPISrc. */ url: string /** Kurzlebiges Zugriffstoken – gilt fuer **genau diese** Datei. */ token: string token_ttl: number can_write: boolean file_name: string } /** * Endungen, die ein Office-Server bearbeiten kann. * * Dieselbe Positivliste wie im Backend (internal/office). Hier steht sie nur, * damit das Kontextmenue den Punkt gar nicht erst anbietet, wo er nicht ginge – * entschieden wird serverseitig. */ const officeExtensions = new Set([ 'odt', 'docx', 'doc', 'rtf', 'txt', 'ods', 'xlsx', 'xls', 'csv', 'odp', 'pptx', 'ppt', 'odg', ]) export function isOfficeFile(name: string): boolean { const i = name.lastIndexOf('.') return i >= 0 && officeExtensions.has(name.slice(i + 1).toLowerCase()) } export const officeApi = { session: (fileId: string) => request('GET', `/office/session/${fileId}`), } export const ocrApi = { /** Stand der Texterkennung für die **eigenen** Dateien. Braucht keine * Adminrechte – anders als adminApi.ocrStatus, das den ganzen Server zählt. */ myStatus: () => request('GET', '/ocr'), } export const searchApi = { query: (q: string, signal?: AbortSignal) => request('GET', `/search?q=${encodeURIComponent(q)}`, undefined, false, signal), } export const filesApi = { list: (folderId?: string) => { const qs = folderId ? `?folder_id=${folderId}` : '' return request('GET', `/files${qs}`) }, upload: (file: File, folderId?: string) => { const form = new FormData() // folder_id before file – see uploadWithProgress note (streaming upload). if (folderId) form.append('folder_id', folderId) form.append('file', file) return request('POST', '/files', form, true) }, download: (id: string) => `/api/v1/files/${id}/download`, /** Dieselbe Datei, aber zum Anzeigen im Browser statt zum Speichern. Der * Server liefert das nur für eine feste Liste harmloser Typen inline aus. */ inline: (id: string) => `/api/v1/files/${id}/download?inline=1`, /** * Adresse des Vorschaubildes. * * `version` gehört mit hinein, auch wenn die Adresse ohne sie funktioniert: * Lädt man eine Datei unter **gleichem Namen** erneut hoch, entsteht keine * neue Datei, sondern eine neue Fassung – gleiche ID, gleiche Adresse. Das * Vorschaubild darf einen Tag im Browser liegen (`max-age=86400`), also * zeigte die Kachel danach bis zu 24 Stunden lang das **alte** Bild. * * Gemessen: dreimal dieselbe Datei hochgeladen ergibt dreimal dieselbe ID * mit den Fassungen 1, 2, 3. */ /** * Ein Office-Dokument als PDF – zum Ansehen, ohne den Editor. * * Die Fassung steht in der Adresse: Nach einer Bearbeitung ändert sie sich, * und der Browser holt das PDF von selbst neu, statt ein Dokument von * gestern aus seinem Zwischenspeicher zu zeigen. Derselbe Kniff wie beim * Vorschaubild. */ officePreview: (id: string, version?: number) => `/api/v1/files/${id}/preview.pdf${version ? `?v=${version}` : ''}`, thumbnail: (id: string, version?: number | string) => `/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`, /** * Der erkannte Text einer Datei, in Seiten zerlegt. * * Für die Suche im PDF-Betrachter: Bei einem Scan steht im PDF selbst kein * Wort, nur ein Bild – ohne diesen Weg fände die Suche im Dokument nichts. * `source` sagt, woher der Text stammt ('layer' aus dem PDF, 'ocr' erkannt). */ textPages: (id: string) => request<{ pages: string[]; source: string }>('GET', `/files/${id}/text`), delete: (id: string) => request('DELETE', `/files/${id}`), /** * Mehrere Dateien in den Papierkorb legen. * * Vier gleichzeitig, nicht alle auf einmal: Bei achtzig markierten Dateien * schickte der Browser sonst achtzig Anfragen in derselben Sekunde – die * Verbindungsgrenze staut sie ohnehin, und der Server verschiebt für jede * einen Blob. Nacheinander wäre umgekehrt spürbar zäh. * * Der Rückgabewert nennt, was **nicht** ging. Eine einzelne fehlgeschlagene * Datei darf die übrigen nicht aufhalten – sie soll nur nicht verschwiegen * werden. */ deleteMany: async (ids: string[]): Promise<{ ok: string[]; failed: string[] }> => { const ok: string[] = [] const failed: string[] = [] const queue = [...ids] const worker = async () => { for (;;) { const id = queue.shift() if (!id) return try { await request('DELETE', `/files/${id}`) ok.push(id) } catch { failed.push(id) } } } await Promise.all(Array.from({ length: Math.min(4, ids.length) }, worker)) return { ok, failed } }, rename: (id: string, name: string) => request('PATCH', `/files/${id}/rename`, { name }), move: (id: string, folderId: string | null) => request('PATCH', `/files/${id}/move`, { folder_id: folderId }), } // ── Folders ─────────────────────────────────────────────────────────────────── export const foldersApi = { list: async (parentId?: string): Promise => { const data = await filesApi.list(parentId) return data.folders ?? [] }, create: (name: string, parentId?: string) => request('POST', '/folders', { name, parent_id: parentId }), rename: (id: string, name: string) => request('PATCH', `/folders/${id}/rename`, { name }), move: (id: string, parentId: string | null) => request('PATCH', `/folders/${id}/move`, { parent_id: parentId }), delete: (id: string) => request('DELETE', `/folders/${id}`), } // ── Favorites ───────────────────────────────────────────────────────────────── export interface FavoriteItem { id: string folder_id: string folder_name: string folder_parent_id: string | null position: number is_expanded: boolean } export const favoritesApi = { list: () => request('GET', '/favorites'), add: (folderId: string) => request('POST', '/favorites', { folder_id: folderId }), remove: (folderId: string) => request('DELETE', `/favorites/${folderId}`), setExpanded: (folderId: string, expanded: boolean) => request('PATCH', `/favorites/${folderId}/expand`, { expanded }), reorder: (folderIds: string[]) => request('PATCH', '/favorites/reorder', { folder_ids: folderIds }), } // ── Trash ───────────────────────────────────────────────────────────────────── export interface TrashItem extends FileItem { folder_path: string | null } export const trashApi = { list: () => request('GET', '/trash'), restore: (id: string) => request('POST', `/trash/${id}/restore`), deletePermanently: (id: string) => request('DELETE', `/trash/${id}`), emptyAll: () => request('DELETE', '/trash'), } // ── Shares ──────────────────────────────────────────────────────────────────── export const sharesApi = { create: (fileId: string, opts: CreateShareRequest) => request('POST', `/files/${fileId}/share`, opts), list: () => request('GET', '/shares'), delete: (id: string) => request('DELETE', `/shares/${id}`), // Public endpoints (no auth) – /share/ prefix avoids chi /api/v1 middleware conflict getInfo: (token: string) => fetch(`/share/${token}`) .then(r => r.json()) .then(j => (j.data ?? j) as PublicShareInfo), download: (token: string, password?: string) => fetch(`/share/${token}/download`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), }), } // ── Versions ────────────────────────────────────────────────────────────────── export const versionsApi = { list: (fileId: string) => request('GET', `/files/${fileId}/versions`), download: (fileId: string, versionId: string) => `/api/v1/files/${fileId}/versions/${versionId}/download`, restore: (fileId: string, versionId: string) => request('POST', `/files/${fileId}/versions/${versionId}/restore`), setEnabled: (fileId: string, enabled: boolean) => request('PATCH', `/files/${fileId}/versioning`, { enabled }), } // ── Admin ───────────────────────────────────────────────────────────────────── export interface AdminUser { id: string username: string display_name: string is_admin: boolean quota_bytes: number used_bytes: number groups: string[] created_at: string } export interface AdminGroup { id: string name: string description: string member_count: number created_at: string } export interface GroupMember { user_id: string username: string display_name: string joined_at: string } export interface AppSettings { cloud_name: string primary_color: string accent_color: string logo_storage_key: string app_icon_storage_key: string } export interface AuditEntry { id: string actor_id: string | null actor_name: string action: string target_type: string | null target_id: string | null target_name: string | null ip: string | null detail: string | null created_at: string } export interface GroupFolder { id: string name: string group_id: string group_name: string members: number used_bytes: number quota_bytes: number } function uploadBranding(kind: 'logo' | 'icon', file: File) { const form = new FormData() form.append('file', file) return request<{ url: string }>('POST', `/admin/branding/${kind}`, form, true) } export const adminApi = { // Branding uploadLogo: (file: File) => uploadBranding('logo', file), deleteLogo: () => request('DELETE', '/admin/branding/logo'), uploadAppIcon: (file: File) => uploadBranding('icon', file), deleteAppIcon: () => request('DELETE', '/admin/branding/icon'), // Group folders listGroupFolders: () => request('GET', '/admin/group-folders'), createGroupFolder: (groupId: string, name: string) => request('POST', '/admin/group-folders', { group_id: groupId, name }), deleteGroupFolder: (id: string) => request('DELETE', `/admin/group-folders/${id}`), // Audit log listAudit: (params: { action?: string; actorId?: string; limit?: number; offset?: number } = {}) => { const qs = new URLSearchParams() if (params.action) qs.set('action', params.action) if (params.actorId) qs.set('actor_id', params.actorId) if (params.limit != null) qs.set('limit', String(params.limit)) if (params.offset != null) qs.set('offset', String(params.offset)) const suffix = qs.toString() ? `?${qs}` : '' return request<{ entries: AuditEntry[]; total: number }>('GET', `/admin/audit${suffix}`) }, listAuditActions: () => request('GET', '/admin/audit/actions'), /** Platzbedarf der abgeleiteten Daten – zählt gegen kein Kontingent. */ derivedStorage: () => request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'), /** Abgleich Platte ↔ Datenbank. Ohne `apply` wird **nur gelesen**. */ rescan: (apply = false) => request('POST', `/admin/rescan${apply ? '?anwenden=true' : ''}`), runCleanup: () => request<{ scanned: number; orphans: number; deleted: number freed_bytes: number; skipped_young: number }>('POST', '/admin/gc'), /** Stand der Texterkennung. `enabled: false` heißt: abgeschaltet oder die * Programme fehlen im Image – kein Fehler, sondern eine Auskunft. */ ocrStatus: () => request('GET', '/admin/ocr'), // Users listUsers: () => request('GET', '/admin/users'), createUser: (data: { username: string; display_name: string; password: string; is_admin: boolean; quota_bytes: number }) => request('POST', '/admin/users', data), updateUser: (id: string, data: { display_name: string; is_admin: boolean; quota_bytes: number }) => request('PATCH', `/admin/users/${id}`, data), resetPassword: (id: string, password?: string) => request<{ new_password: string }>('POST', `/admin/users/${id}/reset-password`, password ? { password } : {}), deleteUser: (id: string) => request('DELETE', `/admin/users/${id}`), // Groups listGroups: () => request('GET', '/admin/groups'), createGroup: (name: string, description: string) => request('POST', '/admin/groups', { name, description }), updateGroup: (id: string, name: string, description: string) => request('PATCH', `/admin/groups/${id}`, { name, description }), deleteGroup: (id: string) => request('DELETE', `/admin/groups/${id}`), listMembers: (groupId: string) => request('GET', `/admin/groups/${groupId}/members`), addMember: (groupId: string, userId: string) => request('POST', `/admin/groups/${groupId}/members`, { user_id: userId }), removeMember: (groupId: string, userId: string) => request('DELETE', `/admin/groups/${groupId}/members/${userId}`), // Settings getSettings: () => request('GET', '/admin/settings'), updateSettings: (data: Partial) => request('PATCH', '/admin/settings', data), } // ── Calendar ───────────────────────────────────────────────────────────── // Termine selbst laufen für CalDAV-Clients (DAVx5 & Co.) über /caldav/ mit // App-Passwörtern – ein eigenes, Basic-Auth-geschütztes Protokoll. Diese // Web-Oberfläche spricht stattdessen /api/v1/calendars mit der normalen // Session, wie jeder andere Bereich. export interface CalendarItem { id: string owner_id: string name: string color: string description: string supports_todo: boolean timezone: string ctag: number created_at: string } /** Die Wiederholung, die das Formular anbietet: alle N Tage/Wochen/Monate/ * Jahre, wahlweise mit Ende. Verwickeltere Regeln aus fremden Clients * bleiben erhalten, kommen hier aber als `recurrence: undefined` an und * sind dann nur lesbar (siehe events_api.go). */ export type WeekdayCode = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU' export interface Recurrence { freq: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' interval: number count?: number /** ISO 8601. Der Tag, an dem die Reihe endet (einschließlich). */ until?: string /** Nur WEEKLY: an welchen Wochentagen. Leer = Wochentag des Starts. */ byday?: WeekdayCode[] /** Nur MONTHLY/YEARLY: am festen Kalendertag (am 4.) oder am n-ten * Wochentag (am 1. Freitag). */ mode?: 'DAY' | 'WEEKDAY' /** Nur zum Anzeigen – der Server rechnet beides aus dem Startdatum aus. */ nth?: number weekday?: WeekdayCode } export interface CalendarEvent { uid: string summary: string description: string location: string /** ISO 8601, immer UTC ("...Z"). Bei ganztägigen Terminen Mitternacht UTC * als reine Datumsangabe – nie über new Date(...) in Ortszeit umrechnen, * sondern über eventDay() aus components/calendar/dates.ts. */ start: string end?: string all_day: boolean /** Leer = Farbe des Kalenders. */ color?: string /** Es hängt überhaupt eine Wiederholung dran. */ recurring: boolean /** Die Regel – nur gesetzt, wenn sie sich im Formular abbilden lässt. */ recurrence?: Recurrence /** Bei aufgeklappten Wiederholungen: Anfang/Ende der **Reihe**, während * start/end den einzelnen Termin tragen. Das Formular bearbeitet immer * die Reihe. */ series_start?: string series_end?: string etag: string } export interface EventInput { summary: string description?: string location?: string start: string end?: string all_day: boolean color?: string recurrence?: Recurrence | null } export const calendarApi = { list: () => request('GET', '/calendars'), create: (name: string, color: string, description = '') => request('POST', '/calendars', { name, color, description }), update: (id: string, patch: Partial>) => request('PATCH', `/calendars/${id}`, patch), remove: (id: string) => request('DELETE', `/calendars/${id}`), exportUrl: (id: string) => `/api/v1/calendars/${id}/export`, import: async (id: string, file: File): Promise<{ imported: number }> => { const headers: Record = { 'Content-Type': 'text/calendar' } if (csrfToken) headers['X-CSRF-Token'] = csrfToken const res = await fetch(`/api/v1/calendars/${id}/import`, { method: 'POST', headers, credentials: 'include', body: file, }) const json = await res.json().catch(() => ({ error: res.statusText })) if (!res.ok) throw new APIError(res.status, json.error ?? 'Import failed') return json.data }, /** Termine im Zeitfenster. Wiederholungen kommen vom Server bereits in * einzelne Termine aufgeklappt zurück – deshalb braucht es die Grenzen: * eine tägliche Reihe ohne Ende hat sonst kein natürliches Ende. */ listEvents: (id: string, from?: Date, to?: Date) => { const qs = from && to ? `?from=${from.toISOString()}&to=${to.toISOString()}` : '' return request('GET', `/calendars/${id}/events${qs}`) }, createEvent: (id: string, input: EventInput) => request('POST', `/calendars/${id}/events`, input), updateEvent: (id: string, uid: string, input: EventInput) => request('PATCH', `/calendars/${id}/events/${encodeURIComponent(uid)}`, input), /** Hängt einen Termin in einen anderen Kalender um – UID und Inhalt bleiben, * nur die Sammlung wechselt. Bei einer Reihe zieht die ganze Reihe mit: * Sie ist eine einzige Datei, kein Bündel von Einzelterminen. */ moveEvent: (id: string, uid: string, targetId: string) => request('POST', `/calendars/${id}/events/${encodeURIComponent(uid)}/move`, { calendar_id: targetId }), /** Ohne `occurrence` verschwindet der Termin ganz – bei einer Reihe also * die ganze Reihe. Mit `occurrence` (ISO-Anfang des einzelnen Termins) * fällt nur dieser eine aus, die Reihe bleibt bestehen. */ deleteEvent: (id: string, uid: string, occurrence?: string) => { const qs = occurrence ? `?occurrence=${encodeURIComponent(occurrence)}` : '' return request('DELETE', `/calendars/${id}/events/${encodeURIComponent(uid)}${qs}`) }, } export { APIError }