Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
vej at spørge om en kollega, man kender navnet på og sidder i samme rum som —
og beslutningen om, hvorvidt en instans' konti må vises for hinanden, var
allerede taget: `UsersForSharing` har gjort det for noter hele tiden, med
begrundelsen skrevet ved siden af. Der er ingen åben oprettelse, så konti er en
lukket, inviteret mængde, og det er en adressebog frem for en udlevering.
Projekterne bruger nu den samme.
`GET /members` svarer med tre lister frem for én, så panelet åbner færdigt i ét
kald — samme form som notens delingspanel. `POST /invites` tager `user_id` ved
siden af `email`, og de to veje mødes ét sted: en adresse, der viser sig at høre
til en konto, ER den konto, og skal ikke gennem en oprettelsesside, den ikke kan
komme igennem.
De ventende invitationer kom med, fordi den nye liste skabte behovet for dem:
den, der er inviteret og ikke kommet, har ingen konto og står derfor hverken
blandt medlemmerne eller blandt dem, man kan vælge. Før betød det, at man ikke
kunne se sin egen invitation; nu ville det betyde, at man leder efter en person,
der mangler i listen, og inviterer igen. Det andet link virker lige så godt som
det første. De kan trækkes tilbage, for ellers er den ventende liste kun til at
se på.
Tre ting, der kostede en kørsel hver:
`OpenAPI is valid` fangede den nye DELETE-rute, før jeg selv gjorde.
To felter hed "Rolle" på den samme side. Det er ikke kun en prøve, der brokker
sig — en skærmlæser siger "Rolle" to gange uden at kunne skelne. De har nu hver
sin tilstand og hvert sit navn, og den gamle prøves `getByLabel('Rolle')` er
blevet `exact`, fordi den matchede som understreng.
Og e2e-prøven lånte først sin anden person fra prøven ovenfor. Det så jeg kun,
fordi jeg kørte den alene: basen tømmes for hver kørsel, så en prøve, der kun
består, når hele filen kører i rækkefølge, består ikke af sine egne grunde. Den
laver nu selv sin — og viser dermed skiftet: den samme person er først en
ventende adresse og bagefter et navn, der kan vælges.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
527 lines
22 KiB
JavaScript
527 lines
22 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';
|
|
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
|
|
|
|
/**
|
|
* How long a request keeps retrying a network failure before giving up.
|
|
*
|
|
* This is the whole of the "offline" feature and its whole limit: it rides out a
|
|
* blip of a few seconds so a save is not lost to it, and then — because the app is
|
|
* a live view of a shared list, not a local database — it stops and says so rather
|
|
* than holding an edit for a network that is not coming back. Fifteen seconds is
|
|
* long enough for a handover or a tunnel and short enough that a real outage fails
|
|
* while the user is still looking at what they did.
|
|
*/
|
|
const RETRY_WINDOW_MS = 15000;
|
|
|
|
/** Backoff between attempts: 0.5s, 1s, 2s, then 3s, capped. A waiting sleep also
|
|
* wakes the instant the OS reports a link again, so most blips end on the first. */
|
|
const backoff = (attempt) => Math.min(500 * 2 ** attempt, 3000);
|
|
|
|
/**
|
|
* Runs `send` (a fetch), retrying only network failures for a short window.
|
|
*
|
|
* A network failure is fetch rejecting — no HTTP status ever arrived. An HTTP
|
|
* error (4xx/5xx) is the server answering, so it is returned, not retried: retrying
|
|
* a 400 just asks the same wrong question again. An abort is the caller changing
|
|
* its mind (a superseded search), so it is left to propagate.
|
|
*
|
|
* Retrying is not free of risk: a create whose request reached the server but whose
|
|
* response was lost would be made twice. For a drop of seconds that is rare, and the
|
|
* alternative — losing the save every time the network hiccups — is the thing this
|
|
* exists to stop. A server-side idempotency key would remove even that; it is a
|
|
* larger change noted for later.
|
|
*/
|
|
async function withRetry(send, noRetry = false) {
|
|
const deadline = Date.now() + (noRetry ? 0 : RETRY_WINDOW_MS);
|
|
let attempt = 0;
|
|
for (;;) {
|
|
try {
|
|
const res = await send();
|
|
connectivity.reachable();
|
|
return res;
|
|
} catch (e) {
|
|
if (e?.name === 'AbortError') throw e;
|
|
if (Date.now() >= deadline) {
|
|
connectivity.lost();
|
|
throw new ApiError(0, 'offline', 'ingen forbindelse');
|
|
}
|
|
connectivity.retrying();
|
|
await sleepOrOnline(backoff(attempt++));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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',
|
|
// The fallback for when Gmail refused and said nothing usable. When it did say
|
|
// something, `humanMessage` quotes it instead — see below.
|
|
gmail_failed: 'error.gmailRefused',
|
|
// Same shape for a mailbox read over IMAP: quoted when the host said something,
|
|
// this when it did not.
|
|
mailbox_failed: 'error.mailboxRefused',
|
|
// And for a calendar read from Google. Its own code, because the sentence this
|
|
// is wrapped in names where it came from — and "Gmail said no" about a calendar
|
|
// sends somebody to the wrong panel.
|
|
calendar_failed: 'error.calendarRefused',
|
|
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);
|
|
|
|
// Gmail's own words are the diagnosis — "invalid_grant", "insufficient
|
|
// authentication scopes" — and a generic sentence in their place throws away
|
|
// the only thing that says what to do. Wrapped so it reads as a quotation
|
|
// rather than as a stray English string in a Danish interface.
|
|
if (err.code === 'mailbox_failed' && err.message) {
|
|
return t('error.mailboxSaid', { what: err.message });
|
|
}
|
|
if (err.code === 'gmail_failed' && err.message) {
|
|
return t('error.gmailSaid', { what: err.message });
|
|
}
|
|
if (err.code === 'calendar_failed' && err.message) {
|
|
return t('error.calendarSaid', { what: err.message });
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Retries a network failure for a few seconds so a short drop does not lose the
|
|
// call; `withRetry` turns an exhausted window into the same `offline` error the
|
|
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
|
|
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
|
|
|
|
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);
|
|
|
|
const res = await withRetry(() =>
|
|
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
|
|
);
|
|
|
|
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.
|
|
setNavOrder: (order) => put('/auth/nav-order', { order }),
|
|
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: (password) => post('/auth/totp/setup', { password }),
|
|
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
|
|
totpDisable: (password) => post('/auth/totp/disable', { password }),
|
|
listSessions: () => get('/auth/sessions'),
|
|
|
|
// --- passkeys
|
|
listPasskeys: () => get('/auth/passkeys'),
|
|
beginPasskeyRegistration: () => post('/auth/passkeys/register/begin'),
|
|
finishPasskeyRegistration: (data) => post('/auth/passkeys/register/finish', data),
|
|
renamePasskey: (id, name) => patch(`/auth/passkeys/${id}`, { name }),
|
|
deletePasskey: (id) => del(`/auth/passkeys/${id}`),
|
|
beginPasskeyLogin: () => post('/auth/passkey/login/begin'),
|
|
finishPasskeyLogin: (data) => post('/auth/passkey/login/finish', data),
|
|
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'),
|
|
panelStatus: () => get('/panel'),
|
|
restartFromPanel: () => post('/panel/restart'),
|
|
|
|
// 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),
|
|
|
|
beacon: () => get('/beacon/settings'),
|
|
setBeacon: (body) => put('/beacon/settings', body),
|
|
// Beskeden om, hvad installationen melder ind, og svaret på den. `keep` sendes
|
|
// altid — en tom krop måtte ikke kunne betyde "slå det fra", og serveren
|
|
// afviser den derfor.
|
|
beaconNotice: () => get('/beacon/notice'),
|
|
answerBeaconNotice: (keep) => post('/beacon/notice', { keep }),
|
|
|
|
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}`),
|
|
// The whole list, the same as projects and groups: a project has a handful of
|
|
// sections, and an order that cannot land half-applied is worth more than the
|
|
// bytes a single move would save.
|
|
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
|
|
|
|
listMembers: (projectId) => get(`/projects/${projectId}/members`),
|
|
// Én vej ind, to måder at sige hvem: et id, man har peget på, eller en adresse,
|
|
// man har skrevet. Serveren slår adressen op og ender det samme sted, hvis den
|
|
// hører til en konto her.
|
|
invite: (projectId, who, role) => post(`/projects/${projectId}/invites`, { ...who, role }),
|
|
withdrawInvite: (projectId, inviteId) => del(`/projects/${projectId}/invites/${inviteId}`),
|
|
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),
|
|
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
|
|
|
|
quickAdd: (text, projectId, sectionId) =>
|
|
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
|
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),
|
|
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
|
|
noteBulk: (body) => post('/notes/bulk', body),
|
|
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: {} }),
|
|
// Teksten kommer herfra, fordi ordbøgerne gør. Serveren har ingen.
|
|
testPush: (title, body) => post('/push/test', { title, body }),
|
|
|
|
// --- integrations
|
|
getMailAddress: () => get('/mail-address'),
|
|
rotateMailAddress: () => post('/mail-address/rotate'),
|
|
|
|
// Krogen ind i indbakken. Selve skubbet sker ikke herfra — det er et andet
|
|
// program, der gør det — så der er kun adressen at hente og at skifte.
|
|
getHookURL: () => get('/hook-url'),
|
|
rotateHookURL: () => post('/hook-url/rotate'),
|
|
|
|
getGmail: () => get('/gmail'),
|
|
setGmail: (data) => put('/gmail', data),
|
|
disconnectGmail: () => del('/gmail'),
|
|
authorizeGmail: () => post('/gmail/authorize'),
|
|
syncGmail: () => post('/gmail/sync'),
|
|
|
|
// The Google Calendar connection. Read-only: there is nothing here that writes
|
|
// an event, only which calendars to look at.
|
|
getCalendar: () => get('/calendar'),
|
|
setCalendars: (shown) => put('/calendar/calendars', { shown }),
|
|
// En kalender, man kun har en adresse til. Flere pr. person, hvor Google er
|
|
// én: adressen er identiteten, som tokenet er det for OAuth.
|
|
subscribeCalendar: (url, name) => post('/calendar/subscriptions', { url, name }),
|
|
unsubscribeCalendar: (id) => del(`/calendar/subscriptions/${id}`),
|
|
disconnectCalendar: () => del('/calendar'),
|
|
authorizeCalendar: () => post('/calendar/authorize'),
|
|
syncCalendar: () => post('/calendar/sync'),
|
|
calendarEvents: (from, to) =>
|
|
get(`/calendar/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`),
|
|
|
|
// Notes. Listing and searching are one endpoint because they answer the same
|
|
// question with different words.
|
|
notes: (params = {}) => {
|
|
const q = new URLSearchParams(Object.entries(params).filter(([, v]) => v)).toString();
|
|
return get('/notes' + (q ? `?${q}` : ''));
|
|
},
|
|
note: (id) => get(`/notes/${id}`),
|
|
createNote: (body) => post('/notes', body),
|
|
updateNote: (id, body) => patch(`/notes/${id}`, body),
|
|
deleteNote: (id) => del(`/notes/${id}`),
|
|
notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`),
|
|
|
|
// Sharing a note with people directly — its own access list, beside the project
|
|
// path. Owner only; the list call also returns who it can still be shared with.
|
|
noteShares: (id) => get(`/notes/${id}/shares`),
|
|
shareNote: (id, userId, role) => post(`/notes/${id}/shares`, { user_id: userId, role }),
|
|
// Deling ved adresse. Har adressen en konto, bliver det en almindelig deling —
|
|
// svaret siger hvem — og ellers en invitation, der venter på, at kontoen bliver
|
|
// oprettet.
|
|
shareNoteByEmail: (id, email, role) => post(`/notes/${id}/shares`, { email, role }),
|
|
unshareNote: (id, userId) => del(`/notes/${id}/shares/${userId}`),
|
|
revokeNoteInvite: (id, inviteId) => del(`/notes/${id}/invites/${inviteId}`),
|
|
|
|
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
|
|
// there is no instance-wide registration behind these the way Gmail has one.
|
|
mailboxes: () => get('/mailboxes'),
|
|
addMailbox: (body) => post('/mailboxes', body),
|
|
deleteMailbox: (id) => del(`/mailboxes/${id}`),
|
|
syncMailbox: (id) => post(`/mailboxes/${id}/sync`),
|
|
|
|
getAISettings: () => get('/ai/settings'),
|
|
setAISettings: (data) => put('/ai/settings', data),
|
|
aiSummary: () => post('/ai/summary'),
|
|
aiSplit: (taskId) => post(`/ai/tasks/${taskId}/split`),
|
|
// Forslag frem for skrivninger: de to første spørger, de to sidste er svaret
|
|
// på et ja. Linjen sendes med, som den står i feltet — den kan være rettet i
|
|
// hånden, inden den blev accepteret, og skal læses på samme måde som en, der
|
|
// ikke blev rørt.
|
|
aiTidyInbox: () => post('/ai/inbox/tidy'),
|
|
aiApplyToTask: (taskId, line) => post('/ai/inbox/tidy/apply', { task_id: taskId, line }),
|
|
aiNoteActions: (noteId) => post(`/ai/notes/${noteId}/actions`),
|
|
aiCreateFromNote: (noteId, line) => post(`/ai/notes/${noteId}/actions/apply`, { line }),
|
|
// Spørg om sine egne noter og opgaver. Basen finder kandidaterne, modellen
|
|
// læser dem — svaret bærer, hvad det står på.
|
|
aiAsk: (question) => post('/ai/ask', { question }),
|
|
// Dagens plan om morgenen.
|
|
planSettings: () => get('/ai/plan'),
|
|
setPlanSettings: (body) => put('/ai/plan', body),
|
|
// `silent` er kortet på I dag, der beder om en plan, mens man kigger på den:
|
|
// en besked om noget, man står og ser på, lærer folk at ignorere klokken.
|
|
planNow: (options) => post('/ai/plan/now', options ?? {}),
|
|
|
|
version: () => get('/version'),
|
|
|
|
// --- import and export
|
|
importTodoist: (file) => upload('/import/todoist', file),
|
|
importCSV: (data) => post('/import/csv', data),
|
|
exportAccountURL: () => '/api/v1/export/account',
|
|
exportNotesURL: () => '/api/v1/export/notes.zip',
|
|
// Through `upload`, not `request`: a multipart body has to set its own boundary,
|
|
// and the browser only does that when nothing has claimed the header.
|
|
importNotes: (file) => upload('/notes/import', file),
|
|
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)}`)
|
|
};
|