Files
verdande/web/src/lib/api.js
T
Kristian WindandClaude Opus 5 4d8b70918b feat(i18n): the interface speaks both languages
The account has had a `locale` since the beginning and it only chose which grammar
quick add read a line with — so picking English changed nothing anybody could see,
and the field said so honestly: "Sprog i hurtig tilføjelse". It says "Sprog" now,
because it means it.

Every component goes through `t()`. Danish is the source: a key missing from
English falls through to Danish rather than to the key, because a Danish sentence
in an English screen can be read and `settings.notifications.title` cannot.

`tag()` is the single place `Intl` is asked. Before, `'da-DK'` was written into a
dozen components, and that is the half of a translation that gets left behind — an
English interface still saying "24. aug." is exactly the shape of a job half done.
There is a smoke test that switches to English and refuses to see a Danish month
name in the calendar's heading.

Colour and theme names are keys now. `colors.js` keeps its ids untouched — they
are what the database stores, and `internal/store` reads them out of that file —
but `name` is `'color.tomato'`, resolved where it is drawn.

The Inbox keeps its name. It is a project row the server creates at signup, not a
string in the interface, and it can be renamed like any other project.

Four copies of "3 hours ago" became one, in `web/src/lib/when.js`. They had
already drifted: one said "aldrig logget ind", another "aldrig", and two stopped
at days where the others carried on to a date.

Routes stay Danish. `/indstillinger`, `/projekt`, `/uddelegeret` are bookmarks and
they are in every smoke test; translating them buys nothing and costs both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:46:08 +02:00

354 lines
13 KiB
JavaScript

/**
* The one place the app talks to the server.
*
* Every call goes through `request`, so authentication failures, error shapes and
* the CSRF header are handled once rather than at each of forty call sites.
*/
/** Thrown for any non-2xx response. Carries the server's stable `code` so callers
* can branch on the reason without matching on prose. */
export class ApiError extends Error {
constructor(status, code, message, fields) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.fields = fields ?? {};
}
/** True when the session has gone — the app should show the sign-in screen. */
get isAuthError() {
return this.status === 401;
}
}
import { t } from './i18n.svelte.js';
/**
* The key for each code the API can return.
*
* Keys rather than sentences, because the server's own prose is English and
* written for a log — what a person reads is decided here, where the locale is
* known. `errorcodes_test.go` walks this table against the codes Go can emit, so
* a code the server gained and this did not fails the build rather than falling
* through to that log prose.
*/
const MESSAGES = {
unauthorized: 'error.unauthorized',
totp_required: 'error.totpRequired',
forbidden: 'error.forbidden',
not_found: 'error.notFound',
conflict: 'error.conflict',
rate_limited: 'error.rateLimited',
validation_failed: 'error.validation',
payload_too_large: 'error.tooLarge',
internal_error: 'error.internal',
bad_request: 'error.badRequest',
// Client-only: a network failure is not an HTTP status, so the server has no
// code for it.
offline: 'error.offline',
// Situations that used to arrive as a plain `conflict` and were therefore
// shown as "that already exists" — which was not merely vague but wrong. An
// unconfigured Gmail is not a Gmail that is already connected.
gmail_not_configured: 'error.gmailNotConfigured',
ai_not_configured: 'error.aiNotConfigured',
totp_not_enabled: 'error.totpNotEnabled',
inbox_protected: 'error.inboxProtected',
last_admin: 'error.lastAdmin'
};
/**
* What to show a person for a failed request.
*
* A code with no key here falls back to the server's prose, which is English and
* written for a log — not good, but better than swallowing the only explanation
* there is. A code that turns up in that fallback is a code this table is missing.
*/
export function humanMessage(err) {
if (!(err instanceof ApiError)) return t(MESSAGES.offline);
const key = MESSAGES[err.code];
if (key) return t(key);
return err.message ?? t(MESSAGES.internal_error);
}
async function request(method, path, body, options = {}) {
const init = {
method,
headers: {},
// The session cookie is httpOnly and same-origin; nothing here handles a token.
credentials: 'same-origin',
signal: options.signal
};
if (body !== undefined) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
let res;
try {
res = await fetch(`/api/v1${path}`, init);
} catch (e) {
// A network failure is not an HTTP status; give it one the app can handle
// the same way as everything else.
throw new ApiError(0, 'offline', 'ingen forbindelse');
}
if (res.status === 204) return null;
let payload = null;
const text = await res.text();
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;
}
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'),
setup: (data) => post('/auth/setup', data),
login: (email, password) => post('/auth/login', { email, password }),
loginTOTP: (code) => post('/auth/login/totp', { code }),
logout: () => post('/auth/logout'),
me: () => get('/auth/me'),
updateProfile: (data) => patch('/auth/me', data),
// Its own route rather than a field on the profile: that one backs a form with
// a save button, and this writes on every click of a chevron.
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
signup: (data) => post('/auth/signup', data),
forgotPassword: (email) => post('/auth/password/forgot', { email }),
resetPassword: (token, password) => post('/auth/password/reset', { token, password }),
changePassword: (current_password, new_password) =>
post('/auth/password/change', { current_password, new_password }),
totpSetup: () => post('/auth/totp/setup'),
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
totpDisable: (password) => post('/auth/totp/disable', { password }),
listSessions: () => get('/auth/sessions'),
endSession: (id) => del(`/auth/sessions/${id}`),
// --- users, administrators only
listUsers: () => get('/users'),
inviteUser: (email) => post('/users', { email }),
setUserAdmin: (id, isAdmin) => patch(`/users/${id}`, { is_admin: isAdmin }),
deleteUser: (id) => del(`/users/${id}`),
revokeInvite: (id) => del(`/invites/${id}`),
listErrors: () => get('/errors'),
// The nightly backup, which ran since the beginning with nothing to show it.
// The OAuth client itself, which is the instance's registration with Google
// rather than anybody's mailbox.
gmailClient: () => get('/gmail/client'),
setGmailClient: (data) => put('/gmail/client', data),
listBackups: () => get('/backups'),
runBackup: () => post('/backups'),
// A link rather than a fetch: the browser's own download handles a file of
// this size, and a blob in memory of the whole database does not.
backupURL: (id) => `/api/v1/backups/${id}`,
// The instance-wide history, which is the other half of the error log: that
// one is what broke, this is what was done. Paged with the cursor the server
// hands back rather than an offset — see the OpenAPI note on /activity.
auditLog: ({ before, user_id, project_id, event, limit } = {}) => {
const q = new URLSearchParams();
if (before) q.set('before', before);
if (user_id) q.set('user_id', user_id);
if (project_id) q.set('project_id', project_id);
if (event) q.set('event', event);
if (limit) q.set('limit', limit);
const query = q.toString();
return get(`/activity${query ? `?${query}` : ''}`);
},
auditEvents: () => get('/activity/events'),
recoveryCodes: () => get('/auth/recovery-codes'),
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
// --- projects
listProjects: (archived = false) => get(`/projects${archived ? '?archived=true' : ''}`),
getProject: (id) => get(`/projects/${id}`),
createProject: (data) => post('/projects', data),
updateProject: (id, data) => patch(`/projects/${id}`, data),
deleteProject: (id) => del(`/projects/${id}`),
reorderProjects: (ids) => post('/projects/reorder', { ids }),
listTrashedProjects: () => get('/trash/projects'),
restoreProject: (id) => post(`/trash/projects/${id}/restore`),
listProjectGroups: () => get('/project-groups'),
createProjectGroup: (name) => post('/project-groups', { name }),
getProjectGroup: (id) => get(`/project-groups/${id}`),
updateProjectGroup: (id, data) => patch(`/project-groups/${id}`, data),
uploadGroupAttachment: (id, file) => upload(`/project-groups/${id}/attachments`, file),
deleteProjectGroup: (id) => del(`/project-groups/${id}`),
reorderProjectGroups: (ids) => post('/project-groups/reorder', { ids }),
listSections: (projectId) => get(`/projects/${projectId}/sections`),
createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }),
updateSection: (id, data) => patch(`/sections/${id}`, data),
deleteSection: (id) => del(`/sections/${id}`),
listMembers: (projectId) => get(`/projects/${projectId}/members`),
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
setMemberRole: (projectId, userId, role) =>
patch(`/projects/${projectId}/members/${userId}`, { role }),
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
activity: (projectId) => get(`/projects/${projectId}/activity`),
// --- tasks
listTasks: (params = {}) => {
const query = new URLSearchParams(
Object.entries(params).filter(([, v]) => v !== undefined && v !== '')
);
const suffix = query.toString();
return get(`/tasks${suffix ? `?${suffix}` : ''}`);
},
getTask: (id) => get(`/tasks/${id}`),
createTask: (data) => post('/tasks', data),
updateTask: (id, data) => patch(`/tasks/${id}`, data),
deleteTask: (id) => del(`/tasks/${id}`),
completeTask: (id) => post(`/tasks/${id}/complete`),
reopenTask: (id) => post(`/tasks/${id}/reopen`),
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
quickAdd: (text, projectId) => post('/tasks/quick-add', { text, project_id: projectId }),
quickAddPreview: (text, signal) =>
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
// --- filters and labels
listFilters: () => get('/filters'),
createFilter: (data) => post('/filters', data),
updateFilter: (id, data) => patch(`/filters/${id}`, data),
deleteFilter: (id) => del(`/filters/${id}`),
runFilter: (id) => get(`/filters/${id}/tasks`),
previewFilter: (query) => get(`/filters/preview?query=${encodeURIComponent(query)}`),
listLabels: () => get('/labels'),
createLabel: (name) => post('/labels', { name }),
deleteLabel: (id) => del(`/labels/${id}`),
// --- reminders, feed and templates
listReminders: (taskId) => get(`/tasks/${taskId}/reminders`),
createReminder: (taskId, data) => post(`/tasks/${taskId}/reminders`, data),
deleteReminder: (id) => del(`/reminders/${id}`),
feed: () => get('/feed'),
rotateFeed: () => post('/feed/rotate'),
listTemplates: () => get('/templates'),
saveTemplate: (data) => post('/templates', data),
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}` : ''}`),
delegated: () => get('/delegated'),
people: () => get('/people'),
search: (q) => get(`/search?q=${encodeURIComponent(q)}`)
};