import { BusError } from '../../bus/src/broker.mjs'; // Vikunja v2 HTTP client. It never throws: a network error or timeout is status 0, so // credentials.use() gets a result back instead of flattening a throw to service-failed. // The token goes only into the Authorization header; nothing here logs. export function apiRoot(base) { let u; try { u = new URL(base); } catch { throw new BusError('tracker-config'); } if ( !['http:', 'https:'].includes(u.protocol) || u.username || u.password || u.search || u.hash || !['', '/'].includes(u.pathname) ) throw new BusError('tracker-config'); return u.origin + '/api/v2'; } // URLSearchParams writes a space as '+'; the probes sent %20, so this does too. const query = (q) => Object.entries(q ?? {}) .filter(([, v]) => v !== undefined && v !== null) .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) .join('&'); export function client({ baseUrl, fetch = globalThis.fetch, timeoutMs = 15000 }) { const root = apiRoot(baseUrl); return async function call(token, method, path, { body, query: q, etag } = {}) { const qs = query(q); const headers = { Accept: 'application/json' }; if (token) headers.Authorization = `Bearer ${token}`; if (body !== undefined) headers['Content-Type'] = 'application/json'; if (etag) headers['If-None-Match'] = etag; try { const r = await fetch(root + path + (qs ? '?' + qs : ''), { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(timeoutMs), }); const text = await r.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } return { status: r.status, json, etag: r.headers.get('etag') }; } catch { return { status: 0, json: null, etag: null }; } }; } // What a status means to S3. 5xx and network errors on a write leave the outcome unknown. export function outcome(r) { const s = r.status; if ((s >= 200 && s < 300) || s === 304) return 'ok'; if (s === 401) return 'unauthorized'; if (s === 403) return 'forbidden'; if (s === 404) return r.json?.code === 4002 ? 'not-found' : 'missing'; if (s === 400 || s === 422) return 'invalid'; if (s === 429) return 'rate-limited'; if (s === 0 || s >= 500) return 'uncertain'; return 'unexpected'; } const CODES = { unauthorized: 'tracker-unauthorized', forbidden: 'tracker-forbidden', 'not-found': 'task-not-found', missing: 'tracker-not-found', invalid: 'tracker-invalid', 'rate-limited': 'tracker-rate-limited', uncertain: 'tracker-unavailable', unexpected: 'tracker-unexpected', }; export const refusal = (r) => CODES[outcome(r)] ?? 'tracker-unexpected'; // Every item of a paginated v2 list. `page` is 1-based; a short page ends the walk. export async function all(call, token, path, q = {}, per = 50, max = 200) { const items = []; for (let page = 1; page <= max; page++) { const r = await call(token, 'GET', path, { query: { ...q, per_page: per, page } }); if (outcome(r) !== 'ok' || !Array.isArray(r.json?.items)) return { ok: false, r, items }; items.push(...r.json.items); if (r.json.items.length < per || page >= (r.json.total_pages ?? Infinity)) return { ok: true, r, items }; } return { ok: false, r: { status: 0, json: null }, items }; }