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'); } });