diff --git a/web/src/lib/api.js b/web/src/lib/api.js index d49240d..cea5fd9 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -90,8 +90,45 @@ async function request(method, path, body, options = {}) { const get = (path, options) => request('GET', path, undefined, options); const post = (path, body, options) => request('POST', path, body ?? {}, options); const patch = (path, body) => request('PATCH', path, body); +const put = (path, body) => request('PUT', path, body); const del = (path) => request('DELETE', path); +/** + * Uploads a file. Not routed through `request`, which serialises JSON and sets a + * Content-Type — a multipart body has to set its own boundary, and the browser + * only does that when nothing has claimed the header. + */ +async function upload(path, file, field = 'file') { + const form = new FormData(); + form.append(field, file); + + let res; + try { + res = await fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' }); + } catch { + throw new ApiError(0, 'offline', 'ingen forbindelse'); + } + + const text = await res.text(); + let payload = null; + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = null; + } + } + if (!res.ok) { + throw new ApiError( + res.status, + payload?.code ?? 'internal_error', + payload?.error ?? res.statusText, + payload?.fields + ); + } + return payload; +} + export const api = { // --- auth setupState: () => get('/auth/setup'), @@ -100,6 +137,7 @@ export const api = { loginTOTP: (code) => post('/auth/login/totp', { code }), logout: () => post('/auth/logout'), me: () => get('/auth/me'), + updateProfile: (data) => patch('/auth/me', data), signup: (data) => post('/auth/signup', data), forgotPassword: (email) => post('/auth/password/forgot', { email }), resetPassword: (token, password) => post('/auth/password/reset', { token, password }), @@ -175,6 +213,54 @@ export const api = { useTemplate: (id, data) => post(`/templates/${id}/use`, data), deleteTemplate: (id) => del(`/templates/${id}`), + // --- comments and attachments + listComments: (taskId) => get(`/tasks/${taskId}/comments`), + createComment: (taskId, body) => post(`/tasks/${taskId}/comments`, { body }), + updateComment: (id, body) => patch(`/comments/${id}`, { body }), + deleteComment: (id) => del(`/comments/${id}`), + + uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file), + deleteAttachment: (id) => del(`/attachments/${id}`), + attachmentURL: (id) => `/api/v1/attachments/${id}`, + + // --- API tokens + listTokens: () => get('/tokens'), + createToken: (name, expiresInDays) => + post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }), + deleteToken: (id) => del(`/tokens/${id}`), + + // --- notifications and push + listNotifications: () => get('/notifications'), + markNotificationsRead: (id) => post(id ? `/notifications/${id}/read` : '/notifications/read'), + + pushKey: () => get('/push/key'), + subscribePush: (subscription) => post('/push/subscribe', subscription), + unsubscribePush: (endpoint) => post('/push/unsubscribe', { endpoint, keys: {} }), + + // --- integrations + getMailAddress: () => get('/mail-address'), + rotateMailAddress: () => post('/mail-address/rotate'), + + getGmail: () => get('/gmail'), + setGmail: (data) => put('/gmail', data), + disconnectGmail: () => del('/gmail'), + authorizeGmail: () => post('/gmail/authorize'), + syncGmail: () => post('/gmail/sync'), + + getAISettings: () => get('/ai/settings'), + setAISettings: (data) => put('/ai/settings', data), + aiSummary: () => post('/ai/summary'), + aiSplit: (taskId) => post(`/ai/tasks/${taskId}/split`), + + version: () => get('/version'), + + // --- import and export + importTodoist: (file) => upload('/import/todoist', file), + importCSV: (data) => post('/import/csv', data), + exportAccountURL: () => '/api/v1/export/account', + exportProjectCSVURL: (id) => `/api/v1/export/projects/${id}.csv`, + exportProjectICSURL: (id) => `/api/v1/export/projects/${id}.ics`, + // --- views today: () => get('/today'), upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`), diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 5766eea..6d5043d 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -154,6 +154,21 @@ {/if}
+ Tidszonen er ikke pynt: alle datoer i appen bliver afgjort i den, så + “i morgen kl. 9” betyder noget andet, når du skifter den. +
++ At skifte den logger alle andre sessioner ud — hvilket for det meste er + grunden til at gøre det. +
+En engangskode fra en authenticator-app oven i adgangskoden.
++ Skriv dem ned nu. De vises kun denne ene gang — serveren + har kun deres hash. +
++ Slået til.{#if remaining !== null} + {remaining} gendannelseskode{remaining === 1 ? '' : 'r'} tilbage.{/if} +
+ + + {:else if totpSecret} +Scan i din authenticator-app, eller indtast nøglen manuelt.
+{totpSecret}
+{totpURI}
++ Bruges to steder: at dele en opgave op i undertasks, og at skrive et kort + overblik over det, der er udestående. Nøglen ligger på din server og sendes + kun til den udbyder, du vælger her. +
+…
+ {:else} + + {/if} +En kort note om, hvad der ser vigtigst ud lige nu.
+{summary}
+ {/if} ++ Vælg CSV-eksporten af ét projekt. Prioriteterne bliver konverteret, ikke + kopieret: Todoist skriver 4 for det, grænsefladen kalder P1. +
+Importerer …
+ {/if} + + {#if result} ++ {result.tasks} opgave{result.tasks === 1 ? '' : 'r'}, {result.sections} + sektion{result.sections === 1 ? '' : 'er'} og {result.comments} + kommentar{result.comments === 1 ? '' : 'er'} hentet ind. +
+ {#if result.warnings?.length} + ++ Alt, i den rå form. Det er garantien for, at du kan gå igen — en + eksport, der er blevet pyntet, er en eksport, der har mistet noget. +
++ Et projekt gemt som en form, der kan bruges igen. Datoerne i skabelonen er + relative, så de bliver regnet ud fra den startdato, du vælger. +
+…
+ {:else if templates.length === 0} +Ingen skabeloner endnu.
+ {:else} ++ Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave. + Envejs: at fjerne stjernen gør ikke noget ved opgaven. +
+{callback.text}
+ {/if} + + {#if gmail === null} +…
+ {:else if !gmail.connected} ++ Kræver at administratoren har registreret en OAuth-klient hos Google og sat + VERDANDE_GMAIL_CLIENT_ID og + _SECRET. +
+ {:else} +Forbundet som {gmail.email || 'ukendt konto'}.
+ + + {/if} ++ Abonnér i Apple Kalender, Google eller Thunderbird. En kalenderklient kan + ikke logge ind, så nøglen i adressen er hele adgangskoden — del den + ikke. +
++ Et nyt link bryder alle eksisterende abonnementer med det samme. Det er + pointen: det er det, man gør, når det gamle er havnet et forkert sted. +
++ Send eller videresend en mail hertil, og emnelinjen bliver tolket som en + hurtig tilføjelse — “Fakturer Anders p1 #Firma” virker også her. +
++ Tovejs, i modsætning til feedet ovenfor: Apple Påmindelser og Thunderbird + kan også skrive tilbage. +
++ Brugernavn er din e-mail. Adgangskoden er en + API-token — ikke din rigtige adgangskode. +
++ Påmindelser og beskeder fra andre, også når fanen er lukket. Beskeden + krypteres til din browser undervejs — push-tjenesten kan ikke læse den. +
+…
+ {:else if pushState === 'unsupported'} ++ Denne browser understøtter det ikke. Safari kræver, at appen er lagt på + hjemmeskærmen; over almindelig HTTP virker det kun på localhost. +
+ {:else if pushState === 'blocked'} ++ Browseren har blokeret notifikationer for dette site. Det kan kun laves om i + browserens egne indstillinger — en side kan ikke spørge igen, når svaret har + været nej. +
+ {:else if pushState === 'on'} +Slået til på denne enhed.
+Gælder kun den browser, du sidder i nu.
+ {/if} +{unread} ulæst{unread === 1 ? '' : 'e'}.
+ {/if} +…
+ {:else if notifications.length === 0} +Ingenting endnu.
+ {:else} ++ Kører {version.current}. + {#if version.disabled} + Der bliver ikke set efter opdateringer. + {:else if version.update_available} + {version.latest} er ude. + {:else if version.checked_at} + Ingen nyere version fundet. + {/if} +
+ + {#if version.update_available} + {#if version.notes} +{version.notes}
+ {/if} + {#if version.url} + + {/if} + {/if} ++ Det, et script, en CalDAV-klient eller en MCP-forbindelse logger ind med. En + token bærer dine rettigheder præcist — den kan intet, du ikke selv kan. +
++ Kopiér den nu. Den vises kun denne ene gang — serveren har + kun dens hash. +
+{fresh.token}
+…
+ {:else if tokens.length === 0} +Ingen tokens endnu.
+ {:else} +