76 lines
3.1 KiB
JavaScript
76 lines
3.1 KiB
JavaScript
// Minimal Kimai REST client (https://www.kimai.org/documentation/rest-api.html)
|
|
var Kimai = (() => {
|
|
class Client {
|
|
constructor({ kimaiUrl, authMode, kimaiUser, kimaiToken }) {
|
|
this.base = String(kimaiUrl || '').replace(/\/+$/, '');
|
|
this.authMode = authMode || 'bearer';
|
|
this.user = kimaiUser;
|
|
this.token = kimaiToken;
|
|
}
|
|
|
|
headers() {
|
|
const h = { Accept: 'application/json' };
|
|
if (this.authMode === 'bearer') h.Authorization = `Bearer ${this.token}`;
|
|
else { h['X-AUTH-USER'] = this.user; h['X-AUTH-TOKEN'] = this.token; }
|
|
return h;
|
|
}
|
|
|
|
async req(path, { method = 'GET', query, body } = {}) {
|
|
if (!this.base) throw new Error('Kimai URL not configured');
|
|
const url = new URL(this.base + path);
|
|
for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, v);
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { ...this.headers(), ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
credentials: 'omit',
|
|
});
|
|
const text = await res.text();
|
|
let data = null;
|
|
try { data = text ? JSON.parse(text) : null; } catch { /* keep raw text */ }
|
|
if (!res.ok) {
|
|
const msg = data?.message || data?.error?.message || text.slice(0, 200) || res.statusText;
|
|
const details = data?.errors ? ' ' + JSON.stringify(data.errors) : '';
|
|
throw new Error(`Kimai ${res.status}: ${msg}${details}`);
|
|
}
|
|
return { data, res };
|
|
}
|
|
|
|
async all(path, query = {}) {
|
|
const out = [];
|
|
for (let page = 1; ; page++) {
|
|
const { data, res } = await this.req(path, { query: { ...query, page, size: 250 } });
|
|
out.push(...(data || []));
|
|
const pages = +res.headers.get('X-Total-Pages') || 1;
|
|
if (page >= pages || !data?.length) return out;
|
|
}
|
|
}
|
|
|
|
version() { return this.req('/api/version').then((r) => r.data); }
|
|
me() { return this.req('/api/users/me').then((r) => r.data); }
|
|
projects() { return this.req('/api/projects', { query: { visible: 1 } }).then((r) => r.data); }
|
|
activities() { return this.req('/api/activities', { query: { visible: 1 } }).then((r) => r.data); }
|
|
|
|
// Entries of the current user between two ISO dates (inclusive)
|
|
timesheets(fromIso, toIso) {
|
|
return this.all('/api/timesheets', { begin: `${fromIso}T00:00:00`, end: `${toIso}T23:59:59` });
|
|
}
|
|
|
|
createTimesheet(entry) {
|
|
return this.req('/api/timesheets', { method: 'POST', body: entry }).then((r) => r.data);
|
|
}
|
|
}
|
|
|
|
// Kimai returns "2026-10-07T08:00:00+0200" in the user's timezone: read wall-clock directly.
|
|
function toLocal(ts) {
|
|
if (!ts.begin || !ts.end) return null; // running timer
|
|
const date = ts.begin.slice(0, 10);
|
|
const start = U.toMin(ts.begin.slice(11, 16));
|
|
let end = U.toMin(ts.end.slice(11, 16));
|
|
if (ts.end.slice(0, 10) !== date) end = 24 * 60; // crosses midnight, clamp
|
|
return { id: ts.id, date, start, end, project: ts.project, activity: ts.activity, description: ts.description };
|
|
}
|
|
|
|
return { Client, toLocal };
|
|
})();
|