Files
Aether/frontend/src/api/client.ts
T
marcandClaude Opus 5 55dcf517f2 feat(admin): Abgleich zwischen Platte und Datenbank
Aether fuehrt zwei Buecher: die Bytes auf der Platte und die Eintraege in
der Datenbank. Wer Dateien von aussen hineinlegt -- per SFTP oder aus
einer Sicherung zurueckgeholt --, erzeugt nur das eine. Die Datei liegt
da, Aether weiss nichts von ihr, und der GC haelt sie nach 24 Stunden
fuer einen Rest und loescht sie.

Neu deshalb internal/rescan, die Gegenrichtung zum GC, und ein Knopf
unter Admin -> Audit. Drei Faelle:

  Datei da, kein Eintrag    Eintrag anlegen, samt Ordnerkette,
                            Pruefsumme und Dateityp
  Eintrag da, Datei fort    Eintrag entfernen, dazu Vorschaubild und
                            fruehere Fassungen
  von aussen ersetzt        Groesse und Pruefsumme uebernehmen

Zwei Schritte, nie einer: Check liest nur, Apply aendert. Ein Knopf, der
beides in einem taete, gaebe niemandem die Gelegenheit, vorher
hinzusehen -- und was hier geloescht wird, ist nicht der Papierkorb.

Nicht angefasst werden .trash/ und .versions/. Beide liegen im selben
Baum, gehoeren aber dem System: Wuerden sie uebernommen, tauchten
geloeschte Dateien wieder auf und jede fruehere Fassung wuerde zu einer
eigenen Datei.

Zwei Fallen, die erst der Praxistest zeigte:

  - Der Dateityp muss aus Endung *oder Inhalt* kommen. Beim Hochladen
    schickt der Browser ihn mit; eine per SFTP abgelegte Datei bringt
    niemanden mit, der ihn kennt. Dazu hat das Alpine-Abbild keine
    /etc/mime.types, und Gos eingebaute Liste kennt nicht einmal .txt --
    jede Textdatei waere als application/octet-stream gelandet.
  - COALESCE($1, $2) ohne Cast: Postgres haelt die Parameter dann fuer
    Text und bricht mit "operator does not exist: uuid = text" ab. Die
    Ordnerkette wurde nie angelegt.

Die Pruefsumme wird trotz des vollstaendigen Lesevorgangs berechnet. Ein
Eintrag ohne sie waere ein Eintrag, dessen Beschaedigung beim
Herunterladen niemand bemerkt -- genau die Zusage, die Aether sonst
ueberall einhaelt.

scripts/rescan-selbsttest.py mit 20 Pruefungen in fuenf Gruppen, darunter
"Pruefen aendert nichts" und "Papierkorb bleibt unangetastet". Nebenbei
raeumt der Freigabe-Selbsttest jetzt auch seinen Papierkorb und nimmt
einen eindeutigen Kontonamen je Lauf -- ein abgebrochener Lauf blockierte
sonst jeden weiteren mit 409.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 01:10:25 +02:00

1047 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<User, 'id' | 'avatar_ver'>): 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<string, Tag[]>
/** Absender je Datei-ID – auf demselben Weg wie die Etiketten. */
correspondents?: Record<string, Correspondent>
}
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<T>(
method: string,
path: string,
body?: unknown,
isFormData = false,
/** Abbruchsignal – die Suche verwirft damit überholte Anfragen. */
signal?: AbortSignal,
): Promise<T> {
const headers: Record<string, string> = {}
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<FileItem> {
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<PublicConfig> =>
fetch('/api/v1/public/config').then(r => r.json()).then(j => j.data ?? j),
}
export const authApi = {
login: (username: string, password: string) =>
request<AuthResponse>('POST', '/auth/login', { username, password }),
logout: () => request<void>('POST', '/auth/logout'),
me: () => request<AuthResponse>('GET', '/auth/me'),
updateProfile: (displayName: string) =>
request<User>('PATCH', '/auth/profile', { display_name: displayName }),
updateColors: (primaryColor: string, accentColor: string) =>
request<User>('PATCH', '/auth/profile', {
primary_color: primaryColor,
accent_color: accentColor,
}),
setUseCustomTheme: (enabled: boolean) =>
request<User>('PATCH', '/auth/profile', { use_custom_theme: enabled }),
changePassword: (currentPassword: string, newPassword: string) =>
request<void>('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<ShareRecipient[]>('GET', '/share-recipients'),
sharedWithMe: () => request<SharedResource[]>('GET', '/shared-with-me'),
listForFolder: (id: string) => request<AclEntry[]>('GET', `/folders/${id}/acl`),
listForFile: (id: string) => request<AclEntry[]>('GET', `/files/${id}/acl`),
grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
request<AclEntry>('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
request<AclEntry>('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
revoke: (entryId: string) => request<void>('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<AppPassword[]>('GET', '/app-passwords'),
create: (name: string) =>
request<AppPasswordCreated>('POST', '/app-passwords', { name }),
revoke: (id: string) => request<void>('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 <b>…</b>. 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<Tag[]>('GET', '/tags'),
create: (name: string, color = '', groupId?: string, icon = '') =>
request<Tag>('POST', '/tags', { name, color, icon, group_id: groupId ?? '' }),
update: (id: string, name: string, color: string, icon = '') =>
request<Tag>('PATCH', `/tags/${id}`, { name, color, icon }),
remove: (id: string) => request<void>('DELETE', `/tags/${id}`),
files: (id: string) => request<TaggedFile[]>('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<Tag[]>('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<Correspondent[]>('GET', '/correspondents'),
create: (name: string, groupId?: string, icon = '') =>
request<Correspondent>('POST', '/correspondents', { name, icon, group_id: groupId ?? '' }),
update: (id: string, name: string, icon = '') =>
request<Correspondent>('PATCH', `/correspondents/${id}`, { name, icon }),
remove: (id: string) => request<void>('DELETE', `/correspondents/${id}`),
files: (id: string) => request<TaggedFile[]>('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<OfficeSession>('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<OcrStatus>('GET', '/ocr'),
}
export const searchApi = {
query: (q: string, signal?: AbortSignal) =>
request<SearchResponse>('GET', `/search?q=${encodeURIComponent(q)}`, undefined, false, signal),
}
export const filesApi = {
list: (folderId?: string) => {
const qs = folderId ? `?folder_id=${folderId}` : ''
return request<ListResponse>('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<FileItem>('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<void>('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<void>('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<FileItem>('PATCH', `/files/${id}/rename`, { name }),
move: (id: string, folderId: string | null) =>
request<FileItem>('PATCH', `/files/${id}/move`, { folder_id: folderId }),
}
// ── Folders ───────────────────────────────────────────────────────────────────
export const foldersApi = {
list: async (parentId?: string): Promise<FolderItem[]> => {
const data = await filesApi.list(parentId)
return data.folders ?? []
},
create: (name: string, parentId?: string) =>
request<FolderItem>('POST', '/folders', { name, parent_id: parentId }),
rename: (id: string, name: string) =>
request<FolderItem>('PATCH', `/folders/${id}/rename`, { name }),
move: (id: string, parentId: string | null) =>
request<FolderItem>('PATCH', `/folders/${id}/move`, { parent_id: parentId }),
delete: (id: string) =>
request<void>('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<FavoriteItem[]>('GET', '/favorites'),
add: (folderId: string) => request<FavoriteItem>('POST', '/favorites', { folder_id: folderId }),
remove: (folderId: string) => request<void>('DELETE', `/favorites/${folderId}`),
setExpanded: (folderId: string, expanded: boolean) =>
request<void>('PATCH', `/favorites/${folderId}/expand`, { expanded }),
reorder: (folderIds: string[]) =>
request<void>('PATCH', '/favorites/reorder', { folder_ids: folderIds }),
}
// ── Trash ─────────────────────────────────────────────────────────────────────
export interface TrashItem extends FileItem {
folder_path: string | null
}
export const trashApi = {
list: () => request<TrashItem[]>('GET', '/trash'),
restore: (id: string) => request<FileItem>('POST', `/trash/${id}/restore`),
deletePermanently: (id: string) => request<void>('DELETE', `/trash/${id}`),
emptyAll: () => request<void>('DELETE', '/trash'),
}
// ── Shares ────────────────────────────────────────────────────────────────────
export const sharesApi = {
create: (fileId: string, opts: CreateShareRequest) =>
request<ShareLink>('POST', `/files/${fileId}/share`, opts),
list: () => request<ShareLink[]>('GET', '/shares'),
delete: (id: string) => request<void>('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<FileVersion[]>('GET', `/files/${fileId}/versions`),
download: (fileId: string, versionId: string) =>
`/api/v1/files/${fileId}/versions/${versionId}/download`,
restore: (fileId: string, versionId: string) =>
request<FileItem>('POST', `/files/${fileId}/versions/${versionId}/restore`),
setEnabled: (fileId: string, enabled: boolean) =>
request<FileItem>('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<void>('DELETE', '/admin/branding/logo'),
uploadAppIcon: (file: File) => uploadBranding('icon', file),
deleteAppIcon: () => request<void>('DELETE', '/admin/branding/icon'),
// Group folders
listGroupFolders: () => request<GroupFolder[]>('GET', '/admin/group-folders'),
createGroupFolder: (groupId: string, name: string) =>
request<FolderItem>('POST', '/admin/group-folders', { group_id: groupId, name }),
deleteGroupFolder: (id: string) => request<void>('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<string[]>('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<RescanReport>('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<OcrStatus>('GET', '/admin/ocr'),
// Users
listUsers: () => request<AdminUser[]>('GET', '/admin/users'),
createUser: (data: { username: string; display_name: string; password: string; is_admin: boolean; quota_bytes: number }) =>
request<AdminUser>('POST', '/admin/users', data),
updateUser: (id: string, data: { display_name: string; is_admin: boolean; quota_bytes: number }) =>
request<AdminUser>('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<void>('DELETE', `/admin/users/${id}`),
// Groups
listGroups: () => request<AdminGroup[]>('GET', '/admin/groups'),
createGroup: (name: string, description: string) =>
request<AdminGroup>('POST', '/admin/groups', { name, description }),
updateGroup: (id: string, name: string, description: string) =>
request<AdminGroup>('PATCH', `/admin/groups/${id}`, { name, description }),
deleteGroup: (id: string) => request<void>('DELETE', `/admin/groups/${id}`),
listMembers: (groupId: string) => request<GroupMember[]>('GET', `/admin/groups/${groupId}/members`),
addMember: (groupId: string, userId: string) =>
request<void>('POST', `/admin/groups/${groupId}/members`, { user_id: userId }),
removeMember: (groupId: string, userId: string) =>
request<void>('DELETE', `/admin/groups/${groupId}/members/${userId}`),
// Settings
getSettings: () => request<AppSettings>('GET', '/admin/settings'),
updateSettings: (data: Partial<AppSettings>) =>
request<AppSettings>('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<CalendarItem[]>('GET', '/calendars'),
create: (name: string, color: string, description = '') =>
request<CalendarItem>('POST', '/calendars', { name, color, description }),
update: (id: string, patch: Partial<Pick<CalendarItem, 'name' | 'color' | 'description' | 'timezone'>>) =>
request<CalendarItem>('PATCH', `/calendars/${id}`, patch),
remove: (id: string) => request<void>('DELETE', `/calendars/${id}`),
exportUrl: (id: string) => `/api/v1/calendars/${id}/export`,
import: async (id: string, file: File): Promise<{ imported: number }> => {
const headers: Record<string, string> = { '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<CalendarEvent[]>('GET', `/calendars/${id}/events${qs}`)
},
createEvent: (id: string, input: EventInput) =>
request<CalendarEvent>('POST', `/calendars/${id}/events`, input),
updateEvent: (id: string, uid: string, input: EventInput) =>
request<CalendarEvent>('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<CalendarEvent>('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<void>('DELETE', `/calendars/${id}/events/${encodeURIComponent(uid)}${qs}`)
},
}
export { APIError }