Files
abacus-to-kimai-filler/app/app.js
T

446 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const $ = (id) => document.getElementById(id);
// Tiny DOM builder (no innerHTML: values come from Abacus/Kimai)
function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (k === 'class') el.className = v;
else if (v === true) el.setAttribute(k, '');
else if (v !== false && v != null) el.setAttribute(k, v);
}
for (const c of children.flat()) if (c != null) el.append(c instanceof Node ? c : String(c));
return el;
}
let state = { settings: null, rules: [], catalog: { projects: [], activities: [] }, plan: [], editingRule: null };
const client = () => new Kimai.Client(state.settings);
/* ---------- tabs ---------- */
function showTab(name) {
document.querySelectorAll('nav button').forEach((b) => b.classList.toggle('active', b.dataset.tab === name));
document.querySelectorAll('main > section').forEach((s) => s.classList.toggle('hidden', s.id !== `tab-${name}`));
location.hash = name;
}
document.querySelectorAll('nav button').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab)));
/* ---------- catalog (projects / activities) ---------- */
const projectLabel = (p) => (p.parentTitle ? `${p.parentTitle} / ${p.name}` : p.name);
const projectById = (id) => state.catalog.projects.find((p) => p.id == id);
const activityById = (id) => state.catalog.activities.find((a) => a.id == id);
const labelsFor = (project, activity) => ({
projectLabel: projectById(project) ? projectLabel(projectById(project)) : `#${project}`,
activityLabel: activityById(activity)?.name || `#${activity}`,
});
async function reloadCatalog() {
const c = client();
const [projects, activities] = await Promise.all([c.projects(), c.activities()]);
projects.sort((a, b) => projectLabel(a).localeCompare(projectLabel(b)));
activities.sort((a, b) => a.name.localeCompare(b.name));
state.catalog = { projects, activities, fetchedAt: new Date().toISOString() };
await Store.set({ catalog: state.catalog });
fillProjectSelect($('s-project'), state.settings.defaultProject);
fillActivitySelect($('s-activity'), state.settings.defaultProject, state.settings.defaultActivity);
fillProjectSelect($('r-project'), $('r-project').value);
fillActivitySelect($('r-activity'), $('r-project').value, $('r-activity').value);
}
function fillProjectSelect(sel, value) {
sel.replaceChildren(h('option', { value: '' }, '—'),
...state.catalog.projects.map((p) => h('option', { value: p.id, selected: p.id == value }, projectLabel(p))));
}
function fillActivitySelect(sel, projectId, value) {
const acts = state.catalog.activities.filter((a) => a.project == null || a.project == projectId);
sel.replaceChildren(h('option', { value: '' }, '—'),
...acts.map((a) => h('option', { value: a.id, selected: a.id == value }, a.project == null ? a.name : `${a.name} (project)`)));
}
/* ---------- SYNC tab ---------- */
function currentDays() {
return AbacusParse.fromText($('days').value);
}
function updateDaysSummary() {
const { days, errors } = currentDays();
const total = days.reduce((a, d) => a + d.minutes, 0);
$('days-summary').textContent = days.length
? `${days.length} day(s), ${U.fmtDur(total)} total` + (errors.length ? ` — ${errors.join('; ')}` : '')
: errors.join('; ');
$('days-summary').className = errors.length ? 'warn' : 'muted';
if (days.length && !$('from').value) U.setField($('from'), days[0].date);
if (days.length && !$('to').value) U.setField($('to'), days[days.length - 1].date);
}
$('days').addEventListener('input', () => {
Store.set({ daysText: $('days').value });
updateDaysSummary();
});
$('parse-raw').addEventListener('click', () => {
const p = state.settings.parse;
const lines = $('raw').value.split('\n');
const days = AbacusParse.parseRows(lines, {
timeMode: p.timeMode, durationIndex: +p.durationIndex, sameDateRows: p.sameDateRows,
year: +p.year || new Date().getFullYear(),
});
$('days').value = AbacusParse.toText(days);
$('from').value = ''; $('to').value = '';
Store.set({ daysText: $('days').value });
updateDaysSummary();
});
async function buildPlan() {
$('plan-status').textContent = 'Loading Kimai entries…';
$('plan-status').className = 'muted';
const from = U.getField($('from')), to = U.getField($('to'));
const days = currentDays().days.filter((d) => (!from || d.date >= from) && (!to || d.date <= to));
if (!days.length) throw new Error('No Abacus days in range');
const ts = await client().timesheets(days[0].date, days[days.length - 1].date);
const existing = new Map();
for (const t of ts) {
const e = Kimai.toLocal(t);
if (!e) continue;
Object.assign(e, labelsFor(e.project, e.activity));
if (!existing.has(e.date)) existing.set(e.date, []);
existing.get(e.date).push(e);
}
const settings = { ...state.settings, ...labelsFor(state.settings.defaultProject, state.settings.defaultActivity) };
settings.defaultProjectLabel = settings.projectLabel;
settings.defaultActivityLabel = settings.activityLabel;
const rules = state.rules.map((r) => ({ ...r, ...labelsFor(r.project, r.activity) }));
state.plan = Planner.plan(days, existing, rules, settings).map((p) => ({ ...p, include: p.entries.length > 0, status: '' }));
renderPlan();
const toCreate = state.plan.reduce((a, p) => a + p.entries.length, 0);
const ok = state.plan.filter((p) => p.diff === 0 && p.existingMin === p.target).length;
$('plan-status').textContent = `${state.plan.length} day(s): ${ok} already match, ${toCreate} entr${toCreate === 1 ? 'y' : 'ies'} to create`;
}
function renderPlan() {
$('plan-panel').classList.remove('hidden');
$('plan-body').replaceChildren(...state.plan.map((p) => {
const matched = p.existingMin === p.target;
const entryLine = (e, cls) => h('div', { class: cls },
`${U.fmtTime(e.start)}–${U.fmtTime(e.end)} ${e.projectLabel} · ${e.activityLabel}`,
e.description ? h('span', { class: 'muted' }, ` “${e.description}”`) : null,
e.source ? h('span', { class: 'muted' }, ` [${e.source}]`) : null);
return h('tr', {},
h('td', {}, h('input', {
type: 'checkbox', checked: p.include, disabled: !p.entries.length,
onchange: (ev) => { p.include = ev.target.checked; },
})),
h('td', { class: 'num' }, `${U.weekdayName(p.date)} ${U.fmtDate(p.date)}`),
h('td', { class: 'num' }, U.fmtDur(p.target)),
h('td', { class: 'num' }, U.fmtDur(p.existingMin)),
h('td', { class: 'num' }, p.plannedMin ? U.fmtDur(p.plannedMin) : '—'),
h('td', { class: 'entries' },
p.existing.map((e) => entryLine({ ...e, source: 'in Kimai' }, 'muted')),
p.entries.map((e) => entryLine(e, '')),
p.warnings.map((w) => h('div', { class: 'warn' }, '⚠ ' + w))),
h('td', { class: p.status.startsWith('error') ? 'err' : matched ? 'ok' : p.diff === 0 ? '' : 'warn' },
p.status || (matched ? '✓ 100%' : p.diff === 0 ? 'ready' : `${U.fmtDur(p.diff)} missing`)));
}));
}
$('check-all').addEventListener('change', (ev) => {
state.plan.forEach((p) => { if (p.entries.length) p.include = ev.target.checked; });
renderPlan();
});
$('build').addEventListener('click', () => buildPlan().catch((e) => {
$('plan-status').textContent = e.message;
$('plan-status').className = 'err';
}));
$('create').addEventListener('click', async () => {
const todo = state.plan.filter((p) => p.include && p.entries.length);
const total = todo.reduce((a, p) => a + p.entries.length, 0);
if (!total) return;
if (!confirm(`Create ${total} entr${total === 1 ? 'y' : 'ies'} in Kimai?`)) return;
$('create').disabled = true;
let done = 0, failed = 0;
const c = client();
for (const p of todo) {
p.status = '';
for (const e of p.entries) {
try {
await c.createTimesheet({
begin: U.kimaiDateTime(p.date, e.start),
end: U.kimaiDateTime(p.date, e.end),
project: +e.project,
activity: +e.activity,
description: e.description || undefined,
tags: state.settings.tags || undefined,
});
done++;
} catch (err) {
failed++;
p.status = 'error: ' + err.message;
}
$('create-status').textContent = `${done + failed}/${total}…`;
}
}
$('create').disabled = false;
$('create-status').textContent = `Created ${done}, failed ${failed}. Re-checking…`;
const statuses = new Map(todo.filter((p) => p.status).map((p) => [p.date, p.status]));
await buildPlan().catch((e) => { $('plan-status').textContent = e.message; });
state.plan.forEach((p) => { if (statuses.has(p.date)) p.status = statuses.get(p.date); });
renderPlan();
$('create-status').textContent = `Created ${done}, failed ${failed}.`;
});
/* ---------- RULES tab ---------- */
$('r-weekdays').append(...U.WEEKDAYS.map((d, i) =>
h('label', { class: 'inline' }, h('input', { type: 'checkbox', value: i + 1, checked: i === 0 }), d)));
$('r-project').addEventListener('change', () => fillActivitySelect($('r-activity'), $('r-project').value, null));
function resetRuleForm(rule = null) {
state.editingRule = rule?.id || null;
$('rule-form-title').textContent = rule ? `Edit rule “${rule.name}”` : 'New rule';
$('r-name').value = rule?.name || '';
$('r-hours').value = rule && +rule.minutes ? U.fmtDur(rule.minutes) : '';
U.setField($('r-start'), rule?.startTime);
$('r-enabled').checked = rule ? rule.enabled : true;
$('r-desc').value = rule?.description || '';
$('r-every').value = rule?.everyNWeeks || 1;
U.setField($('r-anchor'), rule?.anchorDate);
U.setField($('r-from'), rule?.validFrom);
U.setField($('r-to'), rule?.validTo);
const wd = rule?.weekdays || [1];
$('r-weekdays').querySelectorAll('input').forEach((i) => { i.checked = wd.includes(+i.value); });
fillProjectSelect($('r-project'), rule?.project);
fillActivitySelect($('r-activity'), rule?.project, rule?.activity);
$('r-status').textContent = '';
}
$('r-cancel').addEventListener('click', () => resetRuleForm());
$('r-save').addEventListener('click', async () => {
const hoursRaw = $('r-hours').value.trim();
const minutes = hoursRaw ? U.parseHours(hoursRaw) : 0;
const weekdays = [...$('r-weekdays').querySelectorAll('input:checked')].map((i) => +i.value);
const err = (m) => { $('r-status').textContent = m; $('r-status').className = 'err'; };
if (hoursRaw && !minutes) return err('Invalid hours');
const bad = ['r-start', 'r-anchor', 'r-from', 'r-to'].find((id) => $(id).value.trim() && !U.getField($(id)));
if (bad) return err(`Invalid ${$(bad).dataset.kind === 'date' ? 'date (dd/mm/yyyy)' : 'time (hh:mm)'}`);
if (!weekdays.length) return err('Pick at least one weekday');
if (!$('r-project').value || !$('r-activity').value) return err('Pick project and activity');
const rule = {
id: state.editingRule || U.uid(),
name: $('r-name').value.trim() || 'Rule',
enabled: $('r-enabled').checked,
weekdays, minutes,
startTime: U.getField($('r-start')),
project: +$('r-project').value,
activity: +$('r-activity').value,
description: $('r-desc').value.trim(),
everyNWeeks: Math.max(1, +$('r-every').value || 1),
anchorDate: U.getField($('r-anchor')),
validFrom: U.getField($('r-from')),
validTo: U.getField($('r-to')),
};
const i = state.rules.findIndex((r) => r.id === rule.id);
if (i >= 0) state.rules[i] = rule; else state.rules.push(rule);
await Store.set({ rules: state.rules });
renderRules();
resetRuleForm();
$('r-status').textContent = 'Saved'; $('r-status').className = 'ok';
});
function describeWhen(r) {
let s = r.weekdays.map((d) => U.WEEKDAYS[d - 1]).join(', ');
if (+r.everyNWeeks > 1) s += ` · every ${r.everyNWeeks} weeks`;
if (r.startTime) s += ` @ ${r.startTime}`;
if (r.validFrom || r.validTo) s += ` · ${U.fmtDate(r.validFrom) || '…'} → ${U.fmtDate(r.validTo) || '…'}`;
return s;
}
function renderRules() {
if (!state.rules.length) {
$('rules-body').replaceChildren(h('tr', {}, h('td', { colspan: 6, class: 'muted' }, 'No rules yet.')));
return;
}
$('rules-body').replaceChildren(...state.rules.map((r) => {
const l = labelsFor(r.project, r.activity);
return h('tr', {},
h('td', {}, h('input', {
type: 'checkbox', checked: r.enabled,
onchange: async (ev) => { r.enabled = ev.target.checked; await Store.set({ rules: state.rules }); },
})),
h('td', {}, r.name, r.description ? h('div', { class: 'muted' }, r.description) : null),
h('td', {}, describeWhen(r)),
h('td', { class: 'num' }, +r.minutes ? U.fmtDur(r.minutes) : 'remainder'),
h('td', {}, `${l.projectLabel} · ${l.activityLabel}`),
h('td', { class: 'num' },
h('button', { class: 'small', onclick: () => { resetRuleForm(r); window.scrollTo(0, 0); } }, 'Edit'), ' ',
h('button', { class: 'small', onclick: async () => {
if (!confirm(`Delete rule “${r.name}”?`)) return;
state.rules = state.rules.filter((x) => x.id !== r.id);
await Store.set({ rules: state.rules });
renderRules();
} }, 'Delete')));
}));
}
/* ---------- SETTINGS tab ---------- */
function loadSettingsForm() {
const s = state.settings;
$('s-url').value = s.kimaiUrl;
$('s-auth').value = s.authMode;
$('s-user').value = s.kimaiUser;
$('s-token').value = s.kimaiToken;
$('s-desc').value = s.defaultDescription;
$('s-tags').value = s.tags;
U.setField($('s-daystart'), s.dayStart);
$('s-skip').checked = s.skipIfRulePresent;
$('p-selector').value = s.parse.rowSelector;
$('p-mode').value = s.parse.timeMode;
$('p-index').value = s.parse.durationIndex;
$('p-same').value = s.parse.sameDateRows;
$('p-year').value = s.parse.year;
fillProjectSelect($('s-project'), s.defaultProject);
fillActivitySelect($('s-activity'), s.defaultProject, s.defaultActivity);
toggleUser();
}
function readSettingsForm() {
return {
...state.settings,
kimaiUrl: $('s-url').value.trim().replace(/\/+$/, ''),
authMode: $('s-auth').value,
kimaiUser: $('s-user').value.trim(),
kimaiToken: $('s-token').value.trim(),
defaultProject: +$('s-project').value || null,
defaultActivity: +$('s-activity').value || null,
defaultDescription: $('s-desc').value.trim(),
tags: $('s-tags').value.trim(),
dayStart: U.getField($('s-daystart')) || '08:00',
skipIfRulePresent: $('s-skip').checked,
parse: {
rowSelector: $('p-selector').value.trim(),
timeMode: $('p-mode').value,
durationIndex: +$('p-index').value || 0,
sameDateRows: $('p-same').value,
year: $('p-year').value.trim(),
},
};
}
const toggleUser = () => $('s-user-wrap').classList.toggle('hidden', $('s-auth').value === 'bearer');
$('s-auth').addEventListener('change', toggleUser);
$('s-project').addEventListener('change', () => fillActivitySelect($('s-activity'), $('s-project').value, null));
async function ensurePermission(url) {
const origin = new URL(url).origin + '/*';
if (await browser.permissions.contains({ origins: [origin] })) return true;
return browser.permissions.request({ origins: [origin] });
}
async function saveSettings() {
state.settings = readSettingsForm();
await Store.set({ settings: state.settings });
}
$('s-save').addEventListener('click', async () => {
const wasUrl = state.settings.kimaiUrl;
await saveSettings();
if (state.settings.kimaiUrl) await ensurePermission(state.settings.kimaiUrl).catch(() => {});
$('s-status').textContent = 'Saved'; $('s-status').className = 'ok';
if (state.settings.kimaiUrl && state.settings.kimaiUrl !== wasUrl) reloadCatalog().catch(() => {});
renderRules();
});
$('s-test').addEventListener('click', async () => {
const st = $('s-test-status');
try {
state.settings = readSettingsForm();
if (!(await ensurePermission(state.settings.kimaiUrl))) throw new Error('Permission to access Kimai denied');
const c = client();
const [v, me] = await Promise.all([c.version(), c.me()]);
st.textContent = `✓ Connected to Kimai ${v.version || ''} as ${me.alias || me.username}`;
st.className = 'ok';
await Store.set({ settings: state.settings });
await reloadCatalog();
renderRules();
} catch (e) {
st.textContent = '✗ ' + e.message;
st.className = 'err';
}
});
$('s-reload').addEventListener('click', async () => {
try {
await saveSettings();
await reloadCatalog();
$('s-status').textContent = `Loaded ${state.catalog.projects.length} projects, ${state.catalog.activities.length} activities`;
$('s-status').className = 'ok';
renderRules();
} catch (e) {
$('s-status').textContent = e.message; $('s-status').className = 'err';
}
});
$('s-export').addEventListener('click', () => {
const { kimaiToken, ...settings } = state.settings; // never export the token
const blob = new Blob([JSON.stringify({ settings, rules: state.rules }, null, 2)], { type: 'application/json' });
h('a', { href: URL.createObjectURL(blob), download: 'abacus-kimai-config.json' }).click();
});
$('s-import-btn').addEventListener('click', () => $('s-import').click());
$('s-import').addEventListener('change', async (ev) => {
const file = ev.target.files[0];
if (!file) return;
try {
const data = JSON.parse(await file.text());
if (data.settings) state.settings = { ...state.settings, ...data.settings, kimaiToken: state.settings.kimaiToken };
if (Array.isArray(data.rules)) state.rules = data.rules;
await Store.set({ settings: state.settings, rules: state.rules });
loadSettingsForm();
renderRules();
$('s-status').textContent = 'Imported'; $('s-status').className = 'ok';
} catch (e) {
$('s-status').textContent = 'Import failed: ' + e.message; $('s-status').className = 'err';
}
});
/* ---------- init ---------- */
(async () => {
U.setupFields();
const s = await Store.get();
state.settings = s.settings;
state.rules = s.rules;
state.catalog = s.catalog;
$('days').value = s.daysText;
if (s.extractInfo) {
$('extract-info').textContent = `Last extraction: ${s.extractInfo.count} day(s) from ${s.extractInfo.url} at ${U.fmtDateTime(new Date(s.extractInfo.at))}`;
$('raw-last').textContent = (s.extractInfo.raw || []).join('\n');
}
loadSettingsForm();
resetRuleForm();
renderRules();
updateDaysSummary();
showTab(location.hash.slice(1) || (s.settings.kimaiUrl ? 'sync' : 'settings'));
})();
// Popup writes new extraction while dashboard is open → refresh
browser.storage.onChanged.addListener((changes) => {
if (changes.daysText && changes.daysText.newValue !== $('days').value) {
$('days').value = changes.daysText.newValue || '';
$('from').value = ''; $('to').value = '';
updateDaysSummary();
showTab('sync');
}
if (changes.extractInfo?.newValue) {
const i = changes.extractInfo.newValue;
$('extract-info').textContent = `Last extraction: ${i.count} day(s) from ${i.url} at ${U.fmtDateTime(new Date(i.at))}`;
$('raw-last').textContent = (i.raw || []).join('\n');
}
});