first version
This commit is contained in:
+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');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user