first version
This commit is contained in:
@@ -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)
|
||||||
+38
@@ -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; }
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Abacus → Kimai</title>
|
||||||
|
<link rel="icon" href="../icons/icon.svg">
|
||||||
|
<link rel="stylesheet" href="app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Abacus → Kimai</h1>
|
||||||
|
<nav>
|
||||||
|
<button data-tab="sync" class="active">Sync</button>
|
||||||
|
<button data-tab="rules">Recurring rules</button>
|
||||||
|
<button data-tab="settings">Settings</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- SYNC -->
|
||||||
|
<section id="tab-sync">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>1. Abacus hours</h2>
|
||||||
|
<p class="muted" id="extract-info">Open MyAbacus, then click the toolbar icon → “Extract hours from this tab”. Or paste below.</p>
|
||||||
|
<textarea id="days" rows="10" placeholder="2026-10-05 08:00-12:00 13:00-17:24 2026-10-06 8:24"></textarea>
|
||||||
|
<div class="row" style="margin-top:8px">
|
||||||
|
<span id="days-summary" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
<details style="margin-top:8px">
|
||||||
|
<summary>Paste raw text from Abacus</summary>
|
||||||
|
<p class="muted">Select the table in MyAbacus, copy, paste here. Parsed with the options from Settings.</p>
|
||||||
|
<textarea id="raw" rows="6"></textarea>
|
||||||
|
<button id="parse-raw" style="margin-top:6px">Convert → days</button>
|
||||||
|
</details>
|
||||||
|
<details style="margin-top:8px">
|
||||||
|
<summary>Raw text read during last extraction</summary>
|
||||||
|
<pre id="raw-last" class="muted"></pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>2. Plan</h2>
|
||||||
|
<div class="row">
|
||||||
|
<label>From <input type="date" id="from"></label>
|
||||||
|
<label>To <input type="date" id="to"></label>
|
||||||
|
<button id="build" class="primary">Compare with Kimai & plan</button>
|
||||||
|
<span id="plan-status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel hidden" id="plan-panel">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th><input type="checkbox" id="check-all" checked></th><th>Day</th><th>Abacus</th><th>Kimai now</th><th>To create</th><th>Entries</th><th>Status</th></tr></thead>
|
||||||
|
<tbody id="plan-body"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="row" style="margin-top:12px">
|
||||||
|
<button id="create" class="primary">Create entries in Kimai</button>
|
||||||
|
<span id="create-status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- RULES -->
|
||||||
|
<section id="tab-rules" class="hidden">
|
||||||
|
<div class="panel">
|
||||||
|
<h2 id="rule-form-title">New rule</h2>
|
||||||
|
<p class="muted">Leave <b>hours</b> empty to make it the “remainder” rule for those days (instead of the default project).</p>
|
||||||
|
<div class="row">
|
||||||
|
<label>Name <input id="r-name" placeholder="Weekly meeting"></label>
|
||||||
|
<label>Hours <input id="r-hours" size="6" placeholder="1 / 1:30"></label>
|
||||||
|
<label>Start time <input id="r-start" type="time"></label>
|
||||||
|
<label class="inline"><input type="checkbox" id="r-enabled" checked> enabled</label>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:8px">
|
||||||
|
<div class="weekdays" id="r-weekdays"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:8px">
|
||||||
|
<label>Project <select id="r-project"></select></label>
|
||||||
|
<label>Activity <select id="r-activity"></select></label>
|
||||||
|
<label style="flex:1">Description <input id="r-desc"></label>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:8px">
|
||||||
|
<label>Every N weeks <input id="r-every" type="number" min="1" value="1" style="width:70px"></label>
|
||||||
|
<label>Week anchor <input id="r-anchor" type="date"></label>
|
||||||
|
<label>Valid from <input id="r-from" type="date"></label>
|
||||||
|
<label>Valid to <input id="r-to" type="date"></label>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:12px">
|
||||||
|
<button id="r-save" class="primary">Save rule</button>
|
||||||
|
<button id="r-cancel">Clear</button>
|
||||||
|
<span id="r-status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>On</th><th>Name</th><th>When</th><th>Hours</th><th>Project / Activity</th><th></th></tr></thead>
|
||||||
|
<tbody id="rules-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SETTINGS -->
|
||||||
|
<section id="tab-settings" class="hidden">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Kimai</h2>
|
||||||
|
<div class="row">
|
||||||
|
<label style="flex:1">URL <input id="s-url" placeholder="https://kimai.example.com"></label>
|
||||||
|
<label>Auth
|
||||||
|
<select id="s-auth">
|
||||||
|
<option value="bearer">API token (Kimai 2)</option>
|
||||||
|
<option value="legacy">Username + API password (legacy)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label id="s-user-wrap">Username <input id="s-user"></label>
|
||||||
|
<label>Token <input id="s-token" type="password"></label>
|
||||||
|
<button id="s-test">Test</button>
|
||||||
|
</div>
|
||||||
|
<p id="s-test-status" class="muted"></p>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Defaults</h2>
|
||||||
|
<p class="muted">Whatever is not covered by a rule goes here.</p>
|
||||||
|
<div class="row">
|
||||||
|
<label>Project <select id="s-project"></select></label>
|
||||||
|
<label>Activity <select id="s-activity"></select></label>
|
||||||
|
<label style="flex:1">Description <input id="s-desc"></label>
|
||||||
|
<button id="s-reload">Reload projects</button>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:8px">
|
||||||
|
<label>Tags <input id="s-tags"></label>
|
||||||
|
<label>Day start (when Abacus has only totals) <input id="s-daystart" type="time"></label>
|
||||||
|
<label class="inline"><input type="checkbox" id="s-skip"> skip a rule if an entry with the same project/activity already exists that day</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Abacus extraction</h2>
|
||||||
|
<div class="row">
|
||||||
|
<label style="flex:1">Row CSS selector <input id="p-selector"></label>
|
||||||
|
<label>Time mode
|
||||||
|
<select id="p-mode">
|
||||||
|
<option value="auto">auto (ranges, else duration)</option>
|
||||||
|
<option value="ranges">ranges “08:00 - 12:00”</option>
|
||||||
|
<option value="pairs">pairs “08:00 12:00” (in/out columns)</option>
|
||||||
|
<option value="duration">duration only</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Duration column (0 = first, -1 = last) <input id="p-index" type="number" style="width:70px"></label>
|
||||||
|
<label>Same day on several rows
|
||||||
|
<select id="p-same">
|
||||||
|
<option value="sum">sum</option>
|
||||||
|
<option value="max">max</option>
|
||||||
|
<option value="last">last</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Year for “dd.mm.” dates <input id="p-year" placeholder="current" style="width:80px"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="s-save" class="primary">Save settings</button>
|
||||||
|
<button id="s-export">Export config</button>
|
||||||
|
<label class="inline"><button id="s-import-btn">Import config</button><input type="file" id="s-import" accept=".json" class="hidden"></label>
|
||||||
|
<span id="s-status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="../lib/util.js"></script>
|
||||||
|
<script src="../lib/parse.js"></script>
|
||||||
|
<script src="../lib/planner.js"></script>
|
||||||
|
<script src="../lib/kimai.js"></script>
|
||||||
|
<script src="../lib/store.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+442
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect x="2" y="2" width="60" height="60" rx="14" fill="#2563eb"/>
|
||||||
|
<circle cx="26" cy="32" r="15" fill="none" stroke="#fff" stroke-width="5"/>
|
||||||
|
<path d="M26 23v10l6 4" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M44 24l10 8-10 8" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 453 B |
@@ -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 <input slot="input"> 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 };
|
||||||
|
})();
|
||||||
@@ -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 };
|
||||||
|
})();
|
||||||
+167
@@ -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 = /(?<![\d.,:])(\d{1,2}):(\d{2})(?![\d:])/g;
|
||||||
|
// Duration tokens, in the order they appear in the row
|
||||||
|
const DUR_RES = [
|
||||||
|
{ re: /(?<![\d.,:+-])(\d{1,2})\s*h\s*(\d{1,2})\s*m(?:in)?\b/gi, get: (m) => +m[1] * 60 + +m[2] },
|
||||||
|
{ re: /(?<![\d.,:+-])(\d{1,2}):(\d{2})(?![\d:])/g, get: (m) => +m[1] * 60 + +m[2] },
|
||||||
|
{ re: /(?<![\d.,:+-])(\d{1,2})[.,](\d{1,2})(?![\d.,:])/g, get: (m) => Math.round(parseFloat(`${m[1]}.${m[2]}`) * 60) },
|
||||||
|
{ re: /(?<![\d.,:+-])(\d{1,2})\s*(?:h|std|hrs?)\b/gi, get: (m) => +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;
|
||||||
+136
@@ -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;
|
||||||
@@ -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 };
|
||||||
|
})();
|
||||||
+64
@@ -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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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": ["<all_urls>"],
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "abacus-kimai@arcanite.ch",
|
||||||
|
"strict_min_version": "115.0",
|
||||||
|
"data_collection_permissions": { "required": ["none"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="../app/app.css">
|
||||||
|
<style>
|
||||||
|
body { width: 340px; padding: 12px; }
|
||||||
|
button { width: 100%; margin-bottom: 8px; }
|
||||||
|
.row { margin-bottom: 8px; }
|
||||||
|
.row label { flex: 1; }
|
||||||
|
#status { font-size: 12px; white-space: pre-wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h3 style="margin-top:0">Abacus → Kimai</h3>
|
||||||
|
<div class="row">
|
||||||
|
<label>From <input type="date" id="from"></label>
|
||||||
|
<label>To <input type="date" id="to"></label>
|
||||||
|
</div>
|
||||||
|
<button id="crawl" class="primary">Read days from MyAbacus</button>
|
||||||
|
<button id="stop" class="hidden">Stop</button>
|
||||||
|
<button id="open">Open dashboard</button>
|
||||||
|
<details>
|
||||||
|
<summary class="muted">Other pages</summary>
|
||||||
|
<button id="extract" style="margin-top:6px">Generic extraction (tables / text)</button>
|
||||||
|
</details>
|
||||||
|
<div id="status" class="muted"></div>
|
||||||
|
<script src="../lib/util.js"></script>
|
||||||
|
<script src="../lib/parse.js"></script>
|
||||||
|
<script src="../lib/store.js"></script>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+101
@@ -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());
|
||||||
Reference in New Issue
Block a user