first version
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
// 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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user