commit 9d8c2a0488f0c5b977612f4a4641f8062ff7b9d7 Author: Loïc Gremaud Date: Wed Oct 7 15:53:12 2026 +0200 first version diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e323db --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Abacus → Kimai + +Firefox extension: grab worked hours from MyAbacus, then create Kimai timesheets so that every day matches 100%. + +## Install (dev) + +`about:debugging#/runtime/this-firefox` → *Load Temporary Add-on…* → pick `manifest.json`. +Or `npx web-ext run` from this folder. + +## Setup + +1. Dashboard → **Settings**: Kimai URL + API token (Kimai 2: *My profile → API access*). Click **Test**, which also loads projects/activities. +2. Pick a **default project / activity**: everything not covered by a rule goes there. +3. **Recurring rules**: e.g. `Weekly meeting`, 1h, Mon, start 09:00 → Customer X / Project Y, activity A. + - Leave *hours* empty for a "remainder" rule: e.g. Fridays rest goes to an internal project instead of the default. + - *Every N weeks* + *anchor* for bi-weekly stuff, *valid from/to* for temporary ones. + +## Usage + +1. Open MyAbacus on the **Gestion des temps** card, click the toolbar icon, pick *From / To*, **Read days from MyAbacus**. + It clicks ‹ / › day by day, reads every *Arrivée / Départ* pair (and *Total heures*), and saves as it goes + (popup can be closed, there's a *Stop* button). Days with an open clock-in (no *Départ*) are skipped and flagged. + *Generic extraction* (under "Other pages") is the old table/text scraper, for other layouts. +2. Dashboard opens with one line per day (editable): + ``` + 2026-10-05 08:00-12:00 13:00-17:24 # stamped intervals + 2026-10-06 8:24 # total only + ``` + If extraction fails, select the table in Abacus, copy, and use *Paste raw text*. +3. **Compare with Kimai & plan**: for each day it computes `Abacus - existing Kimai`, then fills the gap: + fixed rules first (at their start time if free), then remainder rule / default. + Entries go into the Abacus intervals, never overlapping existing Kimai entries. +4. Review, untick days you don't want, **Create entries in Kimai**. It re-checks afterwards; matched days show `✓ 100%`. + +Re-running is safe: existing Kimai time counts toward the total, so only the missing part is created. + +## Extraction tuning + +Settings → *Abacus extraction*: +- **Row selector**: default `tr, [role="row"]`. If nothing dated is found, falls back to page text lines. +- **Time mode**: `ranges` (`08:00 - 12:00`), `pairs` (in/out in separate columns), `duration` (only totals). +- **Duration column**: which duration in a row is the worked time (`0` first, `-1` last), useful when rows also show target/balance. + +The raw text read during the last extraction is visible on the Sync tab, to help tweak those. + +## Files + +- `lib/parse.js`: Abacus text → days +- `lib/planner.js`: days + existing Kimai + rules → entries to create +- `lib/kimai.js`: REST client +- `lib/abacus-crawl.js`: day-by-day reader for the MyAbacus card (injected in the page) +- `popup/`: extraction from the active tab +- `app/`: dashboard (sync, rules, settings) diff --git a/app/app.css b/app/app.css new file mode 100644 index 0000000..6da8596 --- /dev/null +++ b/app/app.css @@ -0,0 +1,38 @@ +:root { + --bg: #f7f7f8; --panel: #fff; --fg: #1d1d1f; --muted: #6b6b73; --border: #dcdce0; + --accent: #2563eb; --ok: #15803d; --warn: #b45309; --err: #b91c1c; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #18181b; --panel: #222226; --fg: #ececf0; --muted: #9a9aa3; --border: #3a3a40; + --accent: #60a5fa; --ok: #4ade80; --warn: #fbbf24; --err: #f87171; + } +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.45 system-ui, sans-serif; } +main { max-width: 1100px; margin: 0 auto; padding: 16px; } +h1 { font-size: 20px; margin: 0 0 12px; } +h2 { font-size: 16px; margin: 0 0 8px; } +.muted { color: var(--muted); } +.ok { color: var(--ok); } .warn { color: var(--warn); } .err { color: var(--err); } +nav { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; } +nav button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; padding: 8px 14px; } +nav button.active { border-bottom-color: var(--accent); color: var(--accent); } +.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 14px; } +.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: end; } +label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: var(--muted); } +label.inline { flex-direction: row; align-items: center; gap: 6px; color: var(--fg); font-size: 14px; } +input, select, textarea, button { font: inherit; color: var(--fg); background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; } +textarea { width: 100%; font-family: ui-monospace, monospace; font-size: 13px; } +button { cursor: pointer; } +button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +button:disabled { opacity: .5; cursor: default; } +button.small { padding: 2px 8px; font-size: 12px; } +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; } +th { font-size: 12px; color: var(--muted); font-weight: 600; } +td.num { font-variant-numeric: tabular-nums; white-space: nowrap; } +.entries div { white-space: nowrap; font-size: 13px; } +.weekdays { display: flex; gap: 6px; } +.hidden { display: none !important; } +pre { white-space: pre-wrap; font-size: 12px; max-height: 300px; overflow: auto; } diff --git a/app/app.html b/app/app.html new file mode 100644 index 0000000..89be378 --- /dev/null +++ b/app/app.html @@ -0,0 +1,171 @@ + + + + + Abacus → Kimai + + + + +
+

Abacus → Kimai

+ + + +
+
+

1. Abacus hours

+

Open MyAbacus, then click the toolbar icon → “Extract hours from this tab”. Or paste below.

+ +
+ +
+
+ Paste raw text from Abacus +

Select the table in MyAbacus, copy, paste here. Parsed with the options from Settings.

+ + +
+
+ Raw text read during last extraction +

+      
+
+ +
+

2. Plan

+
+ + + + +
+
+ + +
+ + + + + + +
+ + + + + + + + diff --git a/app/app.js b/app/app.js new file mode 100644 index 0000000..fd6c645 --- /dev/null +++ b/app/app.js @@ -0,0 +1,442 @@ +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) $('from').value = days[0].date; + if (days.length && !$('to').value) $('to').value = 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 = $('from').value, to = $('to').value; + 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)} ${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) : ''; + $('r-start').value = rule?.startTime || ''; + $('r-enabled').checked = rule ? rule.enabled : true; + $('r-desc').value = rule?.description || ''; + $('r-every').value = rule?.everyNWeeks || 1; + $('r-anchor').value = rule?.anchorDate || ''; + $('r-from').value = rule?.validFrom || ''; + $('r-to').value = 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'); + 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: $('r-start').value, + project: +$('r-project').value, + activity: +$('r-activity').value, + description: $('r-desc').value.trim(), + everyNWeeks: Math.max(1, +$('r-every').value || 1), + anchorDate: $('r-anchor').value, + validFrom: $('r-from').value, + validTo: $('r-to').value, + }; + 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 += ` · ${r.validFrom || '…'} → ${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; + $('s-daystart').value = 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: $('s-daystart').value || '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 () => { + 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 ${new Date(s.extractInfo.at).toLocaleString()}`; + $('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 ${new Date(i.at).toLocaleString()}`; + $('raw-last').textContent = (i.raw || []).join('\n'); + } +}); diff --git a/icons/icon.svg b/icons/icon.svg new file mode 100644 index 0000000..a7bff4e --- /dev/null +++ b/icons/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/lib/abacus-crawl.js b/lib/abacus-crawl.js new file mode 100644 index 0000000..d494d5b --- /dev/null +++ b/lib/abacus-crawl.js @@ -0,0 +1,128 @@ +// Injected into the MyAbacus page (content script world, needs util.js + parse.js). +// Walks the "Gestion des temps" card day by day with the ‹ › buttons and stores results +// in browser.storage.local as it goes, so the popup can be closed meanwhile. +var AbacusCrawl = (() => { + const SEL = { + left: 'vaadin-button[movie-id="leftButton"]', + right: 'vaadin-button[movie-id="rightButton"]', + nav: '.proj-date-navigation', + from: 'vaadin-text-field[id^="id_history_from_"]', + to: 'vaadin-text-field[id^="id_history_to_"]', + total: '#inOut .total-label .va-label-text', + }; + const DATE_RE = /(\d{1,2})\.(\d{1,2})\.(\d{4})/; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + const present = () => !!document.querySelector(SEL.right); + + function readDate() { + const nav = document.querySelector(SEL.nav); + if (!nav) return null; + // sr-only "Date actuelle 02.10.2026", else the date picker input + const m = DATE_RE.exec(nav.textContent) || DATE_RE.exec(nav.querySelector('input')?.value || ''); + return m ? `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}` : null; + } + + // Native inside the vaadin-text-field (custom element props aren't visible from here) + const fieldValue = (tf) => (tf.querySelector('input')?.value || '').trim(); + const suffix = (tf) => tf.id.replace(/^id_history_(from|to)_/, ''); + + function readDay() { + const tos = new Map([...document.querySelectorAll(SEL.to)].map((tf) => [suffix(tf), fieldValue(tf)])); + const intervals = [], open = []; + for (const tf of document.querySelectorAll(SEL.from)) { + const s = U.toMin(fieldValue(tf)), e = U.toMin(tos.get(suffix(tf)) || ''); + if (s == null) continue; + if (e == null) open.push(U.fmtTime(s)); + else if (e > s) intervals.push({ start: s, end: e }); + } + const totalText = document.querySelector(SEL.total)?.textContent.trim() || ''; + return { date: readDate(), intervals: AbacusParse.union(intervals), open, total: U.toMin(totalText) }; + } + + // Wait until the date changed and the card content stopped moving + async function waitLoaded(prevDate, timeout = 15000) { + const t0 = Date.now(); + while (readDate() === prevDate) { + if (Date.now() - t0 > timeout) throw new Error(`MyAbacus did not move away from ${prevDate}`); + await sleep(100); + } + let last = '', stable = 0; + while (stable < 3 && Date.now() - t0 < timeout) { + await sleep(150); + const snap = JSON.stringify(readDay()); + stable = snap === last ? stable + 1 : 0; + last = snap; + } + } + + async function step(dir) { + const prev = readDate(); + document.querySelector(dir < 0 ? SEL.left : SEL.right).click(); + await waitLoaded(prev); + } + + function toLines(days) { + return days.map((d) => { + const iv = d.intervals.map((i) => `${U.fmtTime(i.start)}-${U.fmtTime(i.end)}`).join(' '); + const sum = AbacusParse.sumIntervals(d.intervals); + const notes = []; + if (d.open.length) notes.push(`clocked in at ${d.open.join(', ')} without leaving`); + if (d.total != null && d.intervals.length && d.total !== sum) notes.push(`Abacus total says ${U.fmtDur(d.total)}`); + const note = notes.length ? ` # ${notes.join('; ')}` : ''; + if (iv) return `${d.date} ${iv}${note}`; + if (d.total) return `${d.date} ${U.fmtDur(d.total)}${note}`; + return `# ${d.date} nothing${note}`; + }).join('\n'); + } + + async function run(from, to) { + if (window.__abacusCrawling) return { error: 'already running' }; + if (!present()) return { skipped: true }; // other frame + window.__abacusCrawling = true; + const progress = (p) => browser.storage.local.set({ crawl: { ...p, at: new Date().toISOString() } }); + const days = []; + try { + await browser.storage.local.remove('crawlStop'); + let cur = readDate(); + if (!cur) throw new Error('Cannot read the current date in the card'); + const maxSteps = 400; + let n = 0; + // Move to `from` (two one-way loops: never oscillates if a day can't be shown) + for (const dir of [-1, 1]) { + while (dir < 0 ? cur > from : cur < from) { + if (++n > maxSteps) throw new Error('Too many steps'); + await progress({ status: 'running', message: `Navigating to ${from} (at ${cur})` }); + await step(dir); + cur = readDate(); + } + } + // Read each day up to `to` + for (;;) { + const { crawlStop } = await browser.storage.local.get('crawlStop'); + if (crawlStop) throw new Error('Stopped'); + days.push(readDay()); + await progress({ status: 'running', message: `Read ${cur} (${days.length} day(s))` }); + if (cur >= to || ++n > maxSteps) break; + await step(1); + cur = readDate(); + } + const text = toLines(days); + const parsed = AbacusParse.fromText(text).days; + await browser.storage.local.set({ + daysText: text, + extractInfo: { at: new Date().toISOString(), url: location.href, count: parsed.length, raw: days.map((d) => JSON.stringify(d)) }, + }); + await progress({ status: 'done', message: `Done: ${parsed.length} worked day(s) between ${from} and ${to}` }); + return { days }; + } catch (e) { + if (days.length) await browser.storage.local.set({ daysText: toLines(days) + `\n# stopped: ${e.message}` }); + await progress({ status: 'error', message: e.message }); + return { error: e.message }; + } finally { + window.__abacusCrawling = false; + } + } + + return { run, readDay, readDate, present }; +})(); diff --git a/lib/kimai.js b/lib/kimai.js new file mode 100644 index 0000000..ab48e81 --- /dev/null +++ b/lib/kimai.js @@ -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 }; +})(); diff --git a/lib/parse.js b/lib/parse.js new file mode 100644 index 0000000..1903083 --- /dev/null +++ b/lib/parse.js @@ -0,0 +1,167 @@ +// Turn text scraped/pasted from MyAbacus into day records: +// { date: 'YYYY-MM-DD', intervals: [{start, end}], minutes } +// intervals are minutes since midnight (may be empty when Abacus only gives a total). +var AbacusParse = (() => { + const DATE_RES = [ + { re: /\b(\d{4})-(\d{2})-(\d{2})\b/, get: (m) => [m[1], m[2], m[3]] }, + { re: /\b(\d{1,2})[./](\d{1,2})[./](\d{4}|\d{2})\b/, get: (m) => [m[3].length === 2 ? '20' + m[3] : m[3], m[2], m[1]] }, + { re: /\b(\d{1,2})\.(\d{1,2})\.(?!\d)/, get: (m, year) => [year, m[2], m[1]] }, + ]; + + const RANGE_RE = /(\d{1,2})[:.h](\d{2})\s*(?:-|–|—|to|bis|à)\s*(\d{1,2})[:.h](\d{2})/gi; + const TIME_RE = /(? +m[1] * 60 + +m[2] }, + { re: /(? +m[1] * 60 + +m[2] }, + { re: /(? Math.round(parseFloat(`${m[1]}.${m[2]}`) * 60) }, + { re: /(? +m[1] * 60 }, + ]; + + function findDate(text, year) { + for (const { re, get } of DATE_RES) { + const m = re.exec(text); + if (!m) continue; + const [y, mo, d] = get(m, year); + if (+mo < 1 || +mo > 12 || +d < 1 || +d > 31) continue; + return { date: `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`, rest: text.replace(m[0], ' ') }; + } + return null; + } + + const valid = (s, e) => s >= 0 && e <= 24 * 60 && e > s; + + function ranges(text) { + const out = []; + for (const m of text.matchAll(RANGE_RE)) { + const s = +m[1] * 60 + +m[2], e = +m[3] * 60 + +m[4]; + if (valid(s, e)) out.push({ start: s, end: e }); + } + return out; + } + + function pairs(text) { + const times = [...text.matchAll(TIME_RE)].map((m) => +m[1] * 60 + +m[2]); + const out = []; + for (let i = 0; i + 1 < times.length; i += 2) { + if (valid(times[i], times[i + 1])) out.push({ start: times[i], end: times[i + 1] }); + } + return out; + } + + function durations(text) { + const found = []; + let rest = text; + for (const { re, get } of DUR_RES) { + for (const m of rest.matchAll(re)) found.push({ idx: m.index, min: get(m) }); + rest = rest.replace(re, (s) => ' '.repeat(s.length)); // don't double-match + } + return found.filter((f) => f.min > 0 && f.min <= 24 * 60).sort((a, b) => a.idx - b.idx).map((f) => f.min); + } + + // Union of intervals, sorted + function union(intervals) { + const sorted = [...intervals].sort((a, b) => a.start - b.start); + const out = []; + for (const iv of sorted) { + const last = out[out.length - 1]; + if (last && iv.start <= last.end) last.end = Math.max(last.end, iv.end); + else out.push({ ...iv }); + } + return out; + } + + const sumIntervals = (ivs) => ivs.reduce((a, iv) => a + iv.end - iv.start, 0); + + /** + * rows: array of strings (one per table row or text line) + * opts: { timeMode: auto|ranges|pairs|duration, durationIndex, sameDateRows: sum|max|last, + * carryDate, year } + */ + function parseRows(rows, opts = {}) { + const o = { timeMode: 'auto', durationIndex: 0, sameDateRows: 'sum', carryDate: false, year: new Date().getFullYear(), ...opts }; + const byDate = new Map(); + let lastDate = null; + + for (const raw of rows) { + const text = String(raw).replace(/\s+/g, ' '); + const found = findDate(text, o.year); + if (found) lastDate = found.date; + else if (!o.carryDate || !lastDate) continue; + const date = found ? found.date : lastDate; + const rest = found ? found.rest : text; + + let ivs = [], dur = null; + if (o.timeMode === 'ranges' || o.timeMode === 'auto') ivs = ranges(rest); + if (o.timeMode === 'pairs') ivs = pairs(rest); + if (!ivs.length && (o.timeMode === 'duration' || o.timeMode === 'auto')) { + const ds = durations(rest); + const i = o.durationIndex < 0 ? ds.length + +o.durationIndex : +o.durationIndex; + if (ds[i] != null) dur = ds[i]; + } + if (!ivs.length && dur == null) continue; + + const day = byDate.get(date) || { date, intervals: [], durs: [] }; + day.intervals.push(...ivs); + if (dur != null) day.durs.push(dur); + byDate.set(date, day); + } + + return [...byDate.values()] + .map((d) => { + const intervals = union(d.intervals); + let minutes; + if (intervals.length) minutes = sumIntervals(intervals); + else if (o.sameDateRows === 'max') minutes = Math.max(...d.durs); + else if (o.sameDateRows === 'last') minutes = d.durs[d.durs.length - 1]; + else minutes = d.durs.reduce((a, b) => a + b, 0); + return { date: d.date, intervals, minutes }; + }) + .filter((d) => d.minutes > 0) + .sort((a, b) => a.date.localeCompare(b.date)); + } + + // Normalized one-line-per-day format used in the dashboard editor: + // 2026-10-07 08:00-12:00 13:00-17:24 + // 2026-10-08 8:24 + function toText(days) { + return days + .map((d) => d.intervals.length + ? `${d.date} ${d.intervals.map((iv) => `${U.fmtTime(iv.start)}-${U.fmtTime(iv.end)}`).join(' ')}` + : `${d.date} ${U.fmtDur(d.minutes)}`) + .join('\n'); + } + + function fromText(text) { + const days = [], errors = []; + text.split('\n').forEach((line, n) => { + line = line.replace(/#.*/, '').trim(); + if (!line) return; + const m = /^(\d{4}-\d{2}-\d{2})\s+(.+)$/.exec(line); + if (!m) return errors.push(`line ${n + 1}: expected "YYYY-MM-DD ..."`); + const ivs = ranges(m[2]); + if (ivs.length) return days.push({ date: m[1], intervals: union(ivs), minutes: sumIntervals(union(ivs)) }); + const min = U.parseHours(m[2]); + if (min) return days.push({ date: m[1], intervals: [], minutes: min }); + errors.push(`line ${n + 1}: can't read "${m[2]}"`); + }); + // merge duplicates + const merged = parseMerge(days); + return { days: merged, errors }; + } + + function parseMerge(days) { + const map = new Map(); + for (const d of days) { + const cur = map.get(d.date); + if (!cur) { map.set(d.date, { ...d, intervals: [...d.intervals] }); continue; } + cur.intervals = union([...cur.intervals, ...d.intervals]); + cur.minutes = cur.intervals.length ? sumIntervals(cur.intervals) : cur.minutes + d.minutes; + } + return [...map.values()].sort((a, b) => a.date.localeCompare(b.date)); + } + + return { parseRows, toText, fromText, union, sumIntervals }; +})(); + +if (typeof module !== 'undefined') module.exports = AbacusParse; diff --git a/lib/planner.js b/lib/planner.js new file mode 100644 index 0000000..dec9c01 --- /dev/null +++ b/lib/planner.js @@ -0,0 +1,136 @@ +// Compute which Kimai entries to create so that, per day, Kimai total == Abacus total. +// +// For each day: +// need = abacus minutes - minutes already in Kimai +// slots = abacus intervals (or [dayStart, 24:00] if only a total is known) minus existing Kimai entries +// 1. fixed recurring rules (with start time first, then the others) +// 2. the rest goes to the "remainder" rule of that day, or the default project/activity +var Planner = (() => { + function ruleApplies(rule, date) { + if (!rule.enabled) return false; + if (rule.validFrom && date < rule.validFrom) return false; + if (rule.validTo && date > rule.validTo) return false; + if (!(rule.weekdays || []).includes(U.weekday(date))) return false; + const n = +rule.everyNWeeks || 1; + if (n > 1) { + const w = U.weeksBetween(rule.anchorDate || '2026-01-05', date); + if (((w % n) + n) % n !== 0) return false; + } + return true; + } + + // Carve `minutes` out of free slots (mutates slots). Starts at `from` when given. + function take(slots, minutes, from = null) { + const pieces = []; + let left = minutes; + for (let i = 0; i < slots.length && left > 0; i++) { + const s = slots[i]; + const st = from != null ? Math.max(s.start, from) : s.start; + if (st >= s.end) continue; + const en = Math.min(s.end, st + left); + pieces.push({ start: st, end: en }); + left -= en - st; + const repl = []; + if (s.start < st) repl.push({ start: s.start, end: st }); + if (en < s.end) repl.push({ start: en, end: s.end }); + slots.splice(i, 1, ...repl); + i += repl.length - 1; + } + return { pieces, left }; + } + + function subtract(slots, busy) { + let out = slots.map((s) => ({ ...s })); + for (const b of busy) { + out = out.flatMap((s) => { + if (b.end <= s.start || b.start >= s.end) return [s]; + const r = []; + if (s.start < b.start) r.push({ start: s.start, end: b.start }); + if (b.end < s.end) r.push({ start: b.end, end: s.end }); + return r; + }); + } + return out; + } + + /** + * days: [{date, intervals, minutes}] + * existing: Map date -> [{start, end, project, activity}] + * rules: recurring rules + * settings: {defaultProject, defaultActivity, defaultDescription, dayStart, skipIfRulePresent} + */ + function plan(days, existing, rules, settings) { + const dayStart = U.toMin(settings.dayStart) ?? 8 * 60; + + return days.map((day) => { + const ex = existing.get(day.date) || []; + const existingMin = ex.reduce((a, e) => a + (e.end - e.start), 0); + const res = { date: day.date, target: day.minutes, existingMin, existing: ex, entries: [], warnings: [] }; + let need = day.minutes - existingMin; + if (need < 0) res.warnings.push(`Kimai has ${U.fmtDur(-need)} more than Abacus`); + if (need <= 0) return finish(res); + + const base = day.intervals.length ? day.intervals : [{ start: dayStart, end: 24 * 60 }]; + const slots = subtract(base, [...ex].sort((a, b) => a.start - b.start)); + + const applicable = rules.filter((r) => ruleApplies(r, day.date)); + const fixed = applicable + .filter((r) => +r.minutes > 0) + .sort((a, b) => (a.startTime ? 0 : 1) - (b.startTime ? 0 : 1) || (U.toMin(a.startTime) || 0) - (U.toMin(b.startTime) || 0)); + const remainder = applicable.find((r) => !(+r.minutes > 0)); + + const push = (pieces, src) => { + for (const p of pieces) { + res.entries.push({ + start: p.start, end: p.end, + project: src.project, activity: src.activity, + projectLabel: src.projectLabel, activityLabel: src.activityLabel, + description: src.description || '', source: src.name || 'default', + }); + } + }; + + for (const r of fixed) { + if (need <= 0) break; + if (settings.skipIfRulePresent && ex.some((e) => e.project == r.project && e.activity == r.activity)) continue; + const want = Math.min(+r.minutes, need); + let { pieces, left } = take(slots, want, U.toMin(r.startTime)); + if (left > 0 && r.startTime) { + const more = take(slots, left); + pieces = pieces.concat(more.pieces); + left = more.left; + } + push(pieces, r); + need -= want - left; + if (left > 0) res.warnings.push(`no free time left for "${r.name}" (${U.fmtDur(left)} missing)`); + } + + if (need > 0) { + const src = remainder || (settings.defaultProject && settings.defaultActivity + ? { name: 'default', project: settings.defaultProject, activity: settings.defaultActivity, + projectLabel: settings.defaultProjectLabel, activityLabel: settings.defaultActivityLabel, + description: settings.defaultDescription } + : null); + if (!src) { + res.warnings.push(`${U.fmtDur(need)} unassigned: set a default project/activity or a remainder rule`); + } else { + const { pieces, left } = take(slots, need); + push(pieces, src); + if (left > 0) res.warnings.push(`${U.fmtDur(left)} could not be placed (no free slot)`); + } + } + return finish(res); + }); + } + + function finish(res) { + res.entries.sort((a, b) => a.start - b.start); + res.plannedMin = res.entries.reduce((a, e) => a + e.end - e.start, 0); + res.diff = res.target - res.existingMin - res.plannedMin; + return res; + } + + return { plan, ruleApplies, take, subtract }; +})(); + +if (typeof module !== 'undefined') module.exports = Planner; diff --git a/lib/store.js b/lib/store.js new file mode 100644 index 0000000..5196d5e --- /dev/null +++ b/lib/store.js @@ -0,0 +1,39 @@ +// browser.storage.local wrapper with defaults +var Store = (() => { + const DEFAULT_SETTINGS = { + kimaiUrl: '', + authMode: 'bearer', // bearer (Kimai >= 2.x API token) | legacy (X-AUTH-USER / X-AUTH-TOKEN) + kimaiUser: '', + kimaiToken: '', + defaultProject: null, + defaultActivity: null, + defaultProjectLabel: '', + defaultActivityLabel: '', + defaultDescription: '', + tags: 'abacus-sync', + dayStart: '08:00', + skipIfRulePresent: true, + parse: { + rowSelector: 'tr, [role="row"]', + timeMode: 'auto', + durationIndex: 0, + sameDateRows: 'sum', + year: '', + }, + }; + + async function get() { + const s = await browser.storage.local.get(['settings', 'rules', 'daysText', 'extractInfo', 'catalog']); + return { + settings: { ...DEFAULT_SETTINGS, ...(s.settings || {}), parse: { ...DEFAULT_SETTINGS.parse, ...(s.settings?.parse || {}) } }, + rules: s.rules || [], + daysText: s.daysText || '', + extractInfo: s.extractInfo || null, + catalog: s.catalog || { projects: [], activities: [], fetchedAt: null }, + }; + } + + const set = (obj) => browser.storage.local.set(obj); + + return { get, set, DEFAULT_SETTINGS }; +})(); diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 0000000..c2772b0 --- /dev/null +++ b/lib/util.js @@ -0,0 +1,64 @@ +// Small time/date helpers shared by popup, dashboard and planner. +// Times of day are handled as "minutes since midnight", durations as minutes. +var U = (() => { + const pad = (n) => String(n).padStart(2, '0'); + + const toMin = (hhmm) => { + const m = /^(\d{1,2})[:.h](\d{2})$/.exec(String(hhmm || '').trim()); + return m ? +m[1] * 60 + +m[2] : null; + }; + + const fmtTime = (min) => `${pad(Math.floor(min / 60))}:${pad(min % 60)}`; + + const fmtDur = (min) => { + const sign = min < 0 ? '-' : ''; + min = Math.abs(Math.round(min)); + return `${sign}${Math.floor(min / 60)}:${pad(min % 60)}`; + }; + + // "1", "1.5", "1,5", "1:30", "90m", "1h30" -> minutes. Empty -> null. + const parseHours = (s) => { + s = String(s ?? '').trim().toLowerCase(); + if (!s) return null; + let m; + if ((m = /^(\d+)\s*m(in)?$/.exec(s))) return +m[1]; + if ((m = /^(\d+)\s*[:h]\s*(\d{1,2})?\s*m?(in)?$/.exec(s))) return +m[1] * 60 + +(m[2] || 0); + if ((m = /^(\d+(?:[.,]\d+)?)$/.exec(s))) return Math.round(parseFloat(m[1].replace(',', '.')) * 60); + return null; + }; + + const parseDate = (iso) => { + const [y, m, d] = iso.split('-').map(Number); + return new Date(y, m - 1, d); + }; + + const isoDate = (dt) => `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}`; + + const addDays = (iso, n) => { + const dt = parseDate(iso); + dt.setDate(dt.getDate() + n); + return isoDate(dt); + }; + + // ISO weekday: 1 = Monday ... 7 = Sunday + const weekday = (iso) => parseDate(iso).getDay() || 7; + + const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const weekdayName = (iso) => WEEKDAYS[weekday(iso) - 1]; + + // Monday of the week containing iso + const monday = (iso) => addDays(iso, 1 - weekday(iso)); + + const weeksBetween = (a, b) => + Math.round((parseDate(monday(b)) - parseDate(monday(a))) / (7 * 864e5)); + + // Kimai wants HTML5 local datetime: 2026-10-07T08:00:00 + const kimaiDateTime = (iso, min) => `${iso}T${fmtTime(min)}:00`; + + const uid = () => Math.random().toString(36).slice(2, 10); + + return { + pad, toMin, fmtTime, fmtDur, parseHours, parseDate, isoDate, addDays, + weekday, weekdayName, WEEKDAYS, monday, weeksBetween, kimaiDateTime, uid, + }; +})(); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..2d72c9b --- /dev/null +++ b/manifest.json @@ -0,0 +1,28 @@ +{ + "manifest_version": 3, + "name": "Abacus → Kimai", + "version": "0.1.0", + "description": "Extract worked hours from MyAbacus and fill Kimai so both match, with recurring rules.", + "icons": { + "48": "icons/icon.svg", + "96": "icons/icon.svg" + }, + "action": { + "default_title": "Abacus → Kimai", + "default_popup": "popup/popup.html", + "default_icon": "icons/icon.svg" + }, + "options_ui": { + "page": "app/app.html", + "open_in_tab": true + }, + "permissions": ["storage", "activeTab", "scripting"], + "host_permissions": [""], + "browser_specific_settings": { + "gecko": { + "id": "abacus-kimai@arcanite.ch", + "strict_min_version": "115.0", + "data_collection_permissions": { "required": ["none"] } + } + } +} diff --git a/popup/popup.html b/popup/popup.html new file mode 100644 index 0000000..dc76216 --- /dev/null +++ b/popup/popup.html @@ -0,0 +1,33 @@ + + + + + + + + +

Abacus → Kimai

+
+ + +
+ + + +
+ Other pages + +
+
+ + + + + + diff --git a/popup/popup.js b/popup/popup.js new file mode 100644 index 0000000..12bfaf7 --- /dev/null +++ b/popup/popup.js @@ -0,0 +1,101 @@ +const $status = document.getElementById('status'); + +// Runs inside the MyAbacus page (every frame). Returns raw row texts; parsing happens here. +function collectRows(rowSelector) { + const DATE = /\d{1,2}[./]\d{1,2}[./]\d{2,4}|\d{4}-\d{2}-\d{2}/; + const clean = (t) => (t || '').replace(/\s*\n\s*/g, ' \t ').trim(); + let rows = []; + try { + rows = [...document.querySelectorAll(rowSelector || 'tr')].map((el) => clean(el.innerText)).filter(Boolean); + } catch (e) { /* bad selector */ } + if (rows.some((r) => DATE.test(r))) return { mode: 'rows', url: location.href, rows }; + // Fallback: plain text lines (grids rendered with divs) + const lines = (document.body?.innerText || '').split('\n').map((l) => l.trim()).filter(Boolean); + return { mode: 'lines', url: location.href, rows: lines }; +} + +async function extract() { + $status.textContent = 'Extracting…'; + const { settings } = await Store.get(); + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + const results = await browser.scripting.executeScript({ + target: { tabId: tab.id, allFrames: true }, + func: collectRows, + args: [settings.parse.rowSelector], + }); + + const p = settings.parse; + const opts = { timeMode: p.timeMode, durationIndex: +p.durationIndex, sameDateRows: p.sameDateRows, year: +p.year || new Date().getFullYear() }; + let days = []; + const raw = []; + for (const r of results) { + if (!r.result) continue; + raw.push(...r.result.rows); + days = days.concat(AbacusParse.parseRows(r.result.rows, { ...opts, carryDate: r.result.mode === 'lines' })); + } + const { days: merged } = AbacusParse.fromText(AbacusParse.toText(days)); + + await Store.set({ + daysText: AbacusParse.toText(merged), + extractInfo: { at: new Date().toISOString(), url: tab.url, count: merged.length, raw: raw.slice(0, 2000) }, + }); + + if (!merged.length) { + $status.textContent = 'No dated hours found on this page.\nOpen the dashboard → "Raw text" to see what was read, or paste the table manually.'; + return; + } + const total = merged.reduce((a, d) => a + d.minutes, 0); + $status.textContent = `Found ${merged.length} day(s), ${U.fmtDur(total)} total\n${merged[0].date} → ${merged[merged.length - 1].date}`; + browser.runtime.openOptionsPage(); +} + +/* ---------- MyAbacus day-by-day crawl ---------- */ +const $from = document.getElementById('from'); +const $to = document.getElementById('to'); + +function showCrawl(c) { + if (!c) return; + $status.textContent = c.message; + $status.className = c.status === 'error' ? 'err' : c.status === 'done' ? 'ok' : 'muted'; + const running = c.status === 'running'; + document.getElementById('crawl').disabled = running; + document.getElementById('stop').classList.toggle('hidden', !running); +} + +async function crawl() { + const from = $from.value, to = $to.value; + if (!from || !to || from > to) throw new Error('Pick a valid date range'); + await browser.storage.local.set({ crawlRange: { from, to } }); + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + const target = { tabId: tab.id, allFrames: true }; + await browser.scripting.executeScript({ target, files: ['lib/util.js', 'lib/parse.js', 'lib/abacus-crawl.js'] }); + const results = await browser.scripting.executeScript({ + target, + func: (f, t) => AbacusCrawl.run(f, t), + args: [from, to], + }); + const res = results.map((r) => r.result).filter(Boolean); + if (res.every((r) => r.skipped)) throw new Error('No "Gestion des temps" card found on this tab'); + const err = res.find((r) => r.error); + if (!err) browser.runtime.openOptionsPage(); +} + +document.getElementById('crawl').addEventListener('click', () => crawl().catch((e) => { + $status.textContent = 'Error: ' + e.message; + $status.className = 'err'; +})); +document.getElementById('stop').addEventListener('click', () => browser.storage.local.set({ crawlStop: true })); + +browser.storage.onChanged.addListener((ch) => { if (ch.crawl) showCrawl(ch.crawl.newValue); }); + +(async () => { + const { crawlRange, crawl: last } = await browser.storage.local.get(['crawlRange', 'crawl']); + const today = U.isoDate(new Date()); + $from.value = crawlRange?.from || U.monday(today); + $to.value = crawlRange?.to || today; + // Only show a stale "running" if it's recent (page may have been reloaded mid-crawl) + if (last && (last.status !== 'running' || Date.now() - new Date(last.at) < 30000)) showCrawl(last); +})(); + +document.getElementById('extract').addEventListener('click', () => extract().catch((e) => { $status.textContent = 'Error: ' + e.message; })); +document.getElementById('open').addEventListener('click', () => browser.runtime.openOptionsPage());