fix crawling + correct date/time format
This commit is contained in:
@@ -23,8 +23,8 @@ Or `npx web-ext run` from this folder.
|
|||||||
*Generic extraction* (under "Other pages") is the old table/text scraper, for other layouts.
|
*Generic extraction* (under "Other pages") is the old table/text scraper, for other layouts.
|
||||||
2. Dashboard opens with one line per day (editable):
|
2. Dashboard opens with one line per day (editable):
|
||||||
```
|
```
|
||||||
2026-10-05 08:00-12:00 13:00-17:24 # stamped intervals
|
05/10/2026 08:00-12:00 13:00-17:24 # stamped intervals
|
||||||
2026-10-06 8:24 # total only
|
06/10/2026 8:24 # total only
|
||||||
```
|
```
|
||||||
If extraction fails, select the table in Abacus, copy, and use *Paste raw text*.
|
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:
|
3. **Compare with Kimai & plan**: for each day it computes `Abacus - existing Kimai`, then fills the gap:
|
||||||
|
|||||||
@@ -36,3 +36,4 @@ td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|||||||
.weekdays { display: flex; gap: 6px; }
|
.weekdays { display: flex; gap: 6px; }
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
pre { white-space: pre-wrap; font-size: 12px; max-height: 300px; overflow: auto; }
|
pre { white-space: pre-wrap; font-size: 12px; max-height: 300px; overflow: auto; }
|
||||||
|
.invalid { border-color: var(--err); }
|
||||||
|
|||||||
+8
-8
@@ -20,7 +20,7 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>1. Abacus hours</h2>
|
<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>
|
<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>
|
<textarea id="days" rows="10" placeholder="05/10/2026 08:00-12:00 13:00-17:24 06/10/2026 8:24"></textarea>
|
||||||
<div class="row" style="margin-top:8px">
|
<div class="row" style="margin-top:8px">
|
||||||
<span id="days-summary" class="muted"></span>
|
<span id="days-summary" class="muted"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>2. Plan</h2>
|
<h2>2. Plan</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>From <input type="date" id="from"></label>
|
<label>From <input data-kind="date" id="from"></label>
|
||||||
<label>To <input type="date" id="to"></label>
|
<label>To <input data-kind="date" id="to"></label>
|
||||||
<button id="build" class="primary">Compare with Kimai & plan</button>
|
<button id="build" class="primary">Compare with Kimai & plan</button>
|
||||||
<span id="plan-status" class="muted"></span>
|
<span id="plan-status" class="muted"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<label>Name <input id="r-name" placeholder="Weekly meeting"></label>
|
<label>Name <input id="r-name" placeholder="Weekly meeting"></label>
|
||||||
<label>Hours <input id="r-hours" size="6" placeholder="1 / 1:30"></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>Start time <input data-kind="time" id="r-start"></label>
|
||||||
<label class="inline"><input type="checkbox" id="r-enabled" checked> enabled</label>
|
<label class="inline"><input type="checkbox" id="r-enabled" checked> enabled</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top:8px">
|
<div class="row" style="margin-top:8px">
|
||||||
@@ -79,9 +79,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top:8px">
|
<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>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>Week anchor <input data-kind="date" id="r-anchor"></label>
|
||||||
<label>Valid from <input id="r-from" type="date"></label>
|
<label>Valid from <input data-kind="date" id="r-from"></label>
|
||||||
<label>Valid to <input id="r-to" type="date"></label>
|
<label>Valid to <input data-kind="date" id="r-to"></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top:12px">
|
<div class="row" style="margin-top:12px">
|
||||||
<button id="r-save" class="primary">Save rule</button>
|
<button id="r-save" class="primary">Save rule</button>
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top:8px">
|
<div class="row" style="margin-top:8px">
|
||||||
<label>Tags <input id="s-tags"></label>
|
<label>Tags <input id="s-tags"></label>
|
||||||
<label>Day start (when Abacus has only totals) <input id="s-daystart" type="time"></label>
|
<label>Day start (when Abacus has only totals) <input data-kind="time" id="s-daystart"></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>
|
<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>
|
</div>
|
||||||
|
|||||||
+20
-17
@@ -70,8 +70,8 @@ function updateDaysSummary() {
|
|||||||
? `${days.length} day(s), ${U.fmtDur(total)} total` + (errors.length ? ` — ${errors.join('; ')}` : '')
|
? `${days.length} day(s), ${U.fmtDur(total)} total` + (errors.length ? ` — ${errors.join('; ')}` : '')
|
||||||
: errors.join('; ');
|
: errors.join('; ');
|
||||||
$('days-summary').className = errors.length ? 'warn' : 'muted';
|
$('days-summary').className = errors.length ? 'warn' : 'muted';
|
||||||
if (days.length && !$('from').value) $('from').value = days[0].date;
|
if (days.length && !$('from').value) U.setField($('from'), days[0].date);
|
||||||
if (days.length && !$('to').value) $('to').value = days[days.length - 1].date;
|
if (days.length && !$('to').value) U.setField($('to'), days[days.length - 1].date);
|
||||||
}
|
}
|
||||||
|
|
||||||
$('days').addEventListener('input', () => {
|
$('days').addEventListener('input', () => {
|
||||||
@@ -95,7 +95,7 @@ $('parse-raw').addEventListener('click', () => {
|
|||||||
async function buildPlan() {
|
async function buildPlan() {
|
||||||
$('plan-status').textContent = 'Loading Kimai entries…';
|
$('plan-status').textContent = 'Loading Kimai entries…';
|
||||||
$('plan-status').className = 'muted';
|
$('plan-status').className = 'muted';
|
||||||
const from = $('from').value, to = $('to').value;
|
const from = U.getField($('from')), to = U.getField($('to'));
|
||||||
const days = currentDays().days.filter((d) => (!from || d.date >= from) && (!to || d.date <= to));
|
const days = currentDays().days.filter((d) => (!from || d.date >= from) && (!to || d.date <= to));
|
||||||
if (!days.length) throw new Error('No Abacus days in range');
|
if (!days.length) throw new Error('No Abacus days in range');
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ function renderPlan() {
|
|||||||
type: 'checkbox', checked: p.include, disabled: !p.entries.length,
|
type: 'checkbox', checked: p.include, disabled: !p.entries.length,
|
||||||
onchange: (ev) => { p.include = ev.target.checked; },
|
onchange: (ev) => { p.include = ev.target.checked; },
|
||||||
})),
|
})),
|
||||||
h('td', { class: 'num' }, `${U.weekdayName(p.date)} ${p.date}`),
|
h('td', { class: 'num' }, `${U.weekdayName(p.date)} ${U.fmtDate(p.date)}`),
|
||||||
h('td', { class: 'num' }, U.fmtDur(p.target)),
|
h('td', { class: 'num' }, U.fmtDur(p.target)),
|
||||||
h('td', { class: 'num' }, U.fmtDur(p.existingMin)),
|
h('td', { class: 'num' }, U.fmtDur(p.existingMin)),
|
||||||
h('td', { class: 'num' }, p.plannedMin ? U.fmtDur(p.plannedMin) : '—'),
|
h('td', { class: 'num' }, p.plannedMin ? U.fmtDur(p.plannedMin) : '—'),
|
||||||
@@ -205,13 +205,13 @@ function resetRuleForm(rule = null) {
|
|||||||
$('rule-form-title').textContent = rule ? `Edit rule “${rule.name}”` : 'New rule';
|
$('rule-form-title').textContent = rule ? `Edit rule “${rule.name}”` : 'New rule';
|
||||||
$('r-name').value = rule?.name || '';
|
$('r-name').value = rule?.name || '';
|
||||||
$('r-hours').value = rule && +rule.minutes ? U.fmtDur(rule.minutes) : '';
|
$('r-hours').value = rule && +rule.minutes ? U.fmtDur(rule.minutes) : '';
|
||||||
$('r-start').value = rule?.startTime || '';
|
U.setField($('r-start'), rule?.startTime);
|
||||||
$('r-enabled').checked = rule ? rule.enabled : true;
|
$('r-enabled').checked = rule ? rule.enabled : true;
|
||||||
$('r-desc').value = rule?.description || '';
|
$('r-desc').value = rule?.description || '';
|
||||||
$('r-every').value = rule?.everyNWeeks || 1;
|
$('r-every').value = rule?.everyNWeeks || 1;
|
||||||
$('r-anchor').value = rule?.anchorDate || '';
|
U.setField($('r-anchor'), rule?.anchorDate);
|
||||||
$('r-from').value = rule?.validFrom || '';
|
U.setField($('r-from'), rule?.validFrom);
|
||||||
$('r-to').value = rule?.validTo || '';
|
U.setField($('r-to'), rule?.validTo);
|
||||||
const wd = rule?.weekdays || [1];
|
const wd = rule?.weekdays || [1];
|
||||||
$('r-weekdays').querySelectorAll('input').forEach((i) => { i.checked = wd.includes(+i.value); });
|
$('r-weekdays').querySelectorAll('input').forEach((i) => { i.checked = wd.includes(+i.value); });
|
||||||
fillProjectSelect($('r-project'), rule?.project);
|
fillProjectSelect($('r-project'), rule?.project);
|
||||||
@@ -227,6 +227,8 @@ $('r-save').addEventListener('click', async () => {
|
|||||||
const weekdays = [...$('r-weekdays').querySelectorAll('input:checked')].map((i) => +i.value);
|
const weekdays = [...$('r-weekdays').querySelectorAll('input:checked')].map((i) => +i.value);
|
||||||
const err = (m) => { $('r-status').textContent = m; $('r-status').className = 'err'; };
|
const err = (m) => { $('r-status').textContent = m; $('r-status').className = 'err'; };
|
||||||
if (hoursRaw && !minutes) return err('Invalid hours');
|
if (hoursRaw && !minutes) return err('Invalid hours');
|
||||||
|
const bad = ['r-start', 'r-anchor', 'r-from', 'r-to'].find((id) => $(id).value.trim() && !U.getField($(id)));
|
||||||
|
if (bad) return err(`Invalid ${$(bad).dataset.kind === 'date' ? 'date (dd/mm/yyyy)' : 'time (hh:mm)'}`);
|
||||||
if (!weekdays.length) return err('Pick at least one weekday');
|
if (!weekdays.length) return err('Pick at least one weekday');
|
||||||
if (!$('r-project').value || !$('r-activity').value) return err('Pick project and activity');
|
if (!$('r-project').value || !$('r-activity').value) return err('Pick project and activity');
|
||||||
|
|
||||||
@@ -235,14 +237,14 @@ $('r-save').addEventListener('click', async () => {
|
|||||||
name: $('r-name').value.trim() || 'Rule',
|
name: $('r-name').value.trim() || 'Rule',
|
||||||
enabled: $('r-enabled').checked,
|
enabled: $('r-enabled').checked,
|
||||||
weekdays, minutes,
|
weekdays, minutes,
|
||||||
startTime: $('r-start').value,
|
startTime: U.getField($('r-start')),
|
||||||
project: +$('r-project').value,
|
project: +$('r-project').value,
|
||||||
activity: +$('r-activity').value,
|
activity: +$('r-activity').value,
|
||||||
description: $('r-desc').value.trim(),
|
description: $('r-desc').value.trim(),
|
||||||
everyNWeeks: Math.max(1, +$('r-every').value || 1),
|
everyNWeeks: Math.max(1, +$('r-every').value || 1),
|
||||||
anchorDate: $('r-anchor').value,
|
anchorDate: U.getField($('r-anchor')),
|
||||||
validFrom: $('r-from').value,
|
validFrom: U.getField($('r-from')),
|
||||||
validTo: $('r-to').value,
|
validTo: U.getField($('r-to')),
|
||||||
};
|
};
|
||||||
const i = state.rules.findIndex((r) => r.id === rule.id);
|
const i = state.rules.findIndex((r) => r.id === rule.id);
|
||||||
if (i >= 0) state.rules[i] = rule; else state.rules.push(rule);
|
if (i >= 0) state.rules[i] = rule; else state.rules.push(rule);
|
||||||
@@ -256,7 +258,7 @@ function describeWhen(r) {
|
|||||||
let s = r.weekdays.map((d) => U.WEEKDAYS[d - 1]).join(', ');
|
let s = r.weekdays.map((d) => U.WEEKDAYS[d - 1]).join(', ');
|
||||||
if (+r.everyNWeeks > 1) s += ` · every ${r.everyNWeeks} weeks`;
|
if (+r.everyNWeeks > 1) s += ` · every ${r.everyNWeeks} weeks`;
|
||||||
if (r.startTime) s += ` @ ${r.startTime}`;
|
if (r.startTime) s += ` @ ${r.startTime}`;
|
||||||
if (r.validFrom || r.validTo) s += ` · ${r.validFrom || '…'} → ${r.validTo || '…'}`;
|
if (r.validFrom || r.validTo) s += ` · ${U.fmtDate(r.validFrom) || '…'} → ${U.fmtDate(r.validTo) || '…'}`;
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +298,7 @@ function loadSettingsForm() {
|
|||||||
$('s-token').value = s.kimaiToken;
|
$('s-token').value = s.kimaiToken;
|
||||||
$('s-desc').value = s.defaultDescription;
|
$('s-desc').value = s.defaultDescription;
|
||||||
$('s-tags').value = s.tags;
|
$('s-tags').value = s.tags;
|
||||||
$('s-daystart').value = s.dayStart;
|
U.setField($('s-daystart'), s.dayStart);
|
||||||
$('s-skip').checked = s.skipIfRulePresent;
|
$('s-skip').checked = s.skipIfRulePresent;
|
||||||
$('p-selector').value = s.parse.rowSelector;
|
$('p-selector').value = s.parse.rowSelector;
|
||||||
$('p-mode').value = s.parse.timeMode;
|
$('p-mode').value = s.parse.timeMode;
|
||||||
@@ -319,7 +321,7 @@ function readSettingsForm() {
|
|||||||
defaultActivity: +$('s-activity').value || null,
|
defaultActivity: +$('s-activity').value || null,
|
||||||
defaultDescription: $('s-desc').value.trim(),
|
defaultDescription: $('s-desc').value.trim(),
|
||||||
tags: $('s-tags').value.trim(),
|
tags: $('s-tags').value.trim(),
|
||||||
dayStart: $('s-daystart').value || '08:00',
|
dayStart: U.getField($('s-daystart')) || '08:00',
|
||||||
skipIfRulePresent: $('s-skip').checked,
|
skipIfRulePresent: $('s-skip').checked,
|
||||||
parse: {
|
parse: {
|
||||||
rowSelector: $('p-selector').value.trim(),
|
rowSelector: $('p-selector').value.trim(),
|
||||||
@@ -410,13 +412,14 @@ $('s-import').addEventListener('change', async (ev) => {
|
|||||||
|
|
||||||
/* ---------- init ---------- */
|
/* ---------- init ---------- */
|
||||||
(async () => {
|
(async () => {
|
||||||
|
U.setupFields();
|
||||||
const s = await Store.get();
|
const s = await Store.get();
|
||||||
state.settings = s.settings;
|
state.settings = s.settings;
|
||||||
state.rules = s.rules;
|
state.rules = s.rules;
|
||||||
state.catalog = s.catalog;
|
state.catalog = s.catalog;
|
||||||
$('days').value = s.daysText;
|
$('days').value = s.daysText;
|
||||||
if (s.extractInfo) {
|
if (s.extractInfo) {
|
||||||
$('extract-info').textContent = `Last extraction: ${s.extractInfo.count} day(s) from ${s.extractInfo.url} at ${new Date(s.extractInfo.at).toLocaleString()}`;
|
$('extract-info').textContent = `Last extraction: ${s.extractInfo.count} day(s) from ${s.extractInfo.url} at ${U.fmtDateTime(new Date(s.extractInfo.at))}`;
|
||||||
$('raw-last').textContent = (s.extractInfo.raw || []).join('\n');
|
$('raw-last').textContent = (s.extractInfo.raw || []).join('\n');
|
||||||
}
|
}
|
||||||
loadSettingsForm();
|
loadSettingsForm();
|
||||||
@@ -436,7 +439,7 @@ browser.storage.onChanged.addListener((changes) => {
|
|||||||
}
|
}
|
||||||
if (changes.extractInfo?.newValue) {
|
if (changes.extractInfo?.newValue) {
|
||||||
const i = 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()}`;
|
$('extract-info').textContent = `Last extraction: ${i.count} day(s) from ${i.url} at ${U.fmtDateTime(new Date(i.at))}`;
|
||||||
$('raw-last').textContent = (i.raw || []).join('\n');
|
$('raw-last').textContent = (i.raw || []).join('\n');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Last file of the injection (same call as util/parse/abacus-crawl, so globals are shared).
|
||||||
|
// Range comes from storage since files can't take arguments. The returned promise is the injection result.
|
||||||
|
browser.storage.local.get('crawlRequest').then(({ crawlRequest: r }) => AbacusCrawl.run(r.from, r.to));
|
||||||
+71
-21
@@ -3,20 +3,58 @@
|
|||||||
// in browser.storage.local as it goes, so the popup can be closed meanwhile.
|
// in browser.storage.local as it goes, so the popup can be closed meanwhile.
|
||||||
var AbacusCrawl = (() => {
|
var AbacusCrawl = (() => {
|
||||||
const SEL = {
|
const SEL = {
|
||||||
left: 'vaadin-button[movie-id="leftButton"]',
|
left: '[movie-id="leftButton"]',
|
||||||
right: 'vaadin-button[movie-id="rightButton"]',
|
right: '[movie-id="rightButton"]',
|
||||||
nav: '.proj-date-navigation',
|
nav: '.proj-date-navigation',
|
||||||
from: 'vaadin-text-field[id^="id_history_from_"]',
|
from: '[id^="id_history_from_"]',
|
||||||
to: 'vaadin-text-field[id^="id_history_to_"]',
|
to: '[id^="id_history_to_"]',
|
||||||
total: '#inOut .total-label .va-label-text',
|
total: '.total-label .va-label-text',
|
||||||
};
|
};
|
||||||
const DATE_RE = /(\d{1,2})\.(\d{1,2})\.(\d{4})/;
|
const DATE_RE = /(\d{1,2})\.(\d{1,2})\.(\d{4})/;
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
const present = () => !!document.querySelector(SEL.right);
|
// Vaadin renders inside shadow roots: plain document.querySelector can't see them.
|
||||||
|
// Firefox content scripts also get closed roots through openOrClosedShadowRoot.
|
||||||
|
const shadowOf = (el) => el.openOrClosedShadowRoot || el.shadowRoot || null;
|
||||||
|
|
||||||
|
function allRoots(root = document, out = []) {
|
||||||
|
out.push(root);
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||||
|
for (let n = walker.currentNode; n; n = walker.nextNode()) {
|
||||||
|
const sr = n.nodeType === 1 && shadowOf(n);
|
||||||
|
if (sr) allRoots(sr, out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepFind(sel) {
|
||||||
|
for (const r of allRoots()) {
|
||||||
|
const el = r.querySelector(sel);
|
||||||
|
if (el) return el;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRightButton() {
|
||||||
|
return deepFind(SEL.right) || deepFind('.fa-angle-right')?.closest('vaadin-button, button') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root node (document or shadow root) holding the card; re-located if Vaadin re-renders it
|
||||||
|
let card = null;
|
||||||
|
function root() {
|
||||||
|
if (!card || !card.btn.isConnected) {
|
||||||
|
const btn = findRightButton();
|
||||||
|
card = btn ? { btn, node: btn.getRootNode() } : null;
|
||||||
|
}
|
||||||
|
return card?.node || null;
|
||||||
|
}
|
||||||
|
const q = (sel) => root()?.querySelector(sel) || null;
|
||||||
|
const qa = (sel) => [...(root()?.querySelectorAll(sel) || [])];
|
||||||
|
|
||||||
|
const present = () => !!root();
|
||||||
|
|
||||||
function readDate() {
|
function readDate() {
|
||||||
const nav = document.querySelector(SEL.nav);
|
const nav = q(SEL.nav) || card?.btn.parentElement;
|
||||||
if (!nav) return null;
|
if (!nav) return null;
|
||||||
// sr-only "Date actuelle 02.10.2026", else the date picker input
|
// 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 || '');
|
const m = DATE_RE.exec(nav.textContent) || DATE_RE.exec(nav.querySelector('input')?.value || '');
|
||||||
@@ -24,27 +62,38 @@ var AbacusCrawl = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Native <input slot="input"> inside the vaadin-text-field (custom element props aren't visible from here)
|
// 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 fieldValue = (tf) => ((tf.tagName === 'INPUT' ? tf : tf.querySelector('input'))?.value || '').trim();
|
||||||
const suffix = (tf) => tf.id.replace(/^id_history_(from|to)_/, '');
|
const suffix = (tf) => tf.id.replace(/^id_history_(from|to)_/, '');
|
||||||
|
|
||||||
function readDay() {
|
function readDay() {
|
||||||
const tos = new Map([...document.querySelectorAll(SEL.to)].map((tf) => [suffix(tf), fieldValue(tf)]));
|
const tos = new Map(qa(SEL.to).map((tf) => [suffix(tf), fieldValue(tf)]));
|
||||||
const intervals = [], open = [];
|
const intervals = [], open = [];
|
||||||
for (const tf of document.querySelectorAll(SEL.from)) {
|
for (const tf of qa(SEL.from)) {
|
||||||
const s = U.toMin(fieldValue(tf)), e = U.toMin(tos.get(suffix(tf)) || '');
|
const s = U.toMin(fieldValue(tf)), e = U.toMin(tos.get(suffix(tf)) || '');
|
||||||
if (s == null) continue;
|
if (s == null) continue;
|
||||||
if (e == null) open.push(U.fmtTime(s));
|
if (e == null) open.push(U.fmtTime(s));
|
||||||
else if (e > s) intervals.push({ start: s, end: e });
|
else if (e > s) intervals.push({ start: s, end: e });
|
||||||
}
|
}
|
||||||
const totalText = document.querySelector(SEL.total)?.textContent.trim() || '';
|
const totalText = q(SEL.total)?.textContent.trim() || '';
|
||||||
return { date: readDate(), intervals: AbacusParse.union(intervals), open, total: U.toMin(totalText) };
|
return { date: readDate(), intervals: AbacusParse.union(intervals), open, total: U.toMin(totalText) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What this frame sees, to explain a "not found"
|
||||||
|
function diagnose() {
|
||||||
|
const roots = allRoots();
|
||||||
|
return {
|
||||||
|
url: location.href,
|
||||||
|
shadowRoots: roots.length - 1,
|
||||||
|
angleRight: roots.some((r) => r.querySelector('.fa-angle-right')),
|
||||||
|
title: roots.some((r) => /Gestion des temps/.test(r.textContent || '')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Wait until the date changed and the card content stopped moving
|
// Wait until the date changed and the card content stopped moving
|
||||||
async function waitLoaded(prevDate, timeout = 15000) {
|
async function waitLoaded(prevDate, timeout = 15000) {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
while (readDate() === prevDate) {
|
while (readDate() === prevDate) {
|
||||||
if (Date.now() - t0 > timeout) throw new Error(`MyAbacus did not move away from ${prevDate}`);
|
if (Date.now() - t0 > timeout) throw new Error(`MyAbacus did not move away from ${U.fmtDate(prevDate)}`);
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
}
|
}
|
||||||
let last = '', stable = 0;
|
let last = '', stable = 0;
|
||||||
@@ -58,7 +107,8 @@ var AbacusCrawl = (() => {
|
|||||||
|
|
||||||
async function step(dir) {
|
async function step(dir) {
|
||||||
const prev = readDate();
|
const prev = readDate();
|
||||||
document.querySelector(dir < 0 ? SEL.left : SEL.right).click();
|
const btn = dir < 0 ? (q(SEL.left) || deepFind('.fa-angle-left')?.closest('vaadin-button, button')) : findRightButton();
|
||||||
|
btn.click();
|
||||||
await waitLoaded(prev);
|
await waitLoaded(prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,15 +120,15 @@ var AbacusCrawl = (() => {
|
|||||||
if (d.open.length) notes.push(`clocked in at ${d.open.join(', ')} without leaving`);
|
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)}`);
|
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('; ')}` : '';
|
const note = notes.length ? ` # ${notes.join('; ')}` : '';
|
||||||
if (iv) return `${d.date} ${iv}${note}`;
|
if (iv) return `${U.fmtDate(d.date)} ${iv}${note}`;
|
||||||
if (d.total) return `${d.date} ${U.fmtDur(d.total)}${note}`;
|
if (d.total) return `${U.fmtDate(d.date)} ${U.fmtDur(d.total)}${note}`;
|
||||||
return `# ${d.date} nothing${note}`;
|
return `# ${U.fmtDate(d.date)} nothing${note}`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run(from, to) {
|
async function run(from, to) {
|
||||||
if (window.__abacusCrawling) return { error: 'already running' };
|
if (window.__abacusCrawling) return { error: 'already running' };
|
||||||
if (!present()) return { skipped: true }; // other frame
|
if (!present()) return { skipped: true, ...diagnose() }; // other frame
|
||||||
window.__abacusCrawling = true;
|
window.__abacusCrawling = true;
|
||||||
const progress = (p) => browser.storage.local.set({ crawl: { ...p, at: new Date().toISOString() } });
|
const progress = (p) => browser.storage.local.set({ crawl: { ...p, at: new Date().toISOString() } });
|
||||||
const days = [];
|
const days = [];
|
||||||
@@ -92,7 +142,7 @@ var AbacusCrawl = (() => {
|
|||||||
for (const dir of [-1, 1]) {
|
for (const dir of [-1, 1]) {
|
||||||
while (dir < 0 ? cur > from : cur < from) {
|
while (dir < 0 ? cur > from : cur < from) {
|
||||||
if (++n > maxSteps) throw new Error('Too many steps');
|
if (++n > maxSteps) throw new Error('Too many steps');
|
||||||
await progress({ status: 'running', message: `Navigating to ${from} (at ${cur})` });
|
await progress({ status: 'running', message: `Navigating to ${U.fmtDate(from)} (at ${U.fmtDate(cur)})` });
|
||||||
await step(dir);
|
await step(dir);
|
||||||
cur = readDate();
|
cur = readDate();
|
||||||
}
|
}
|
||||||
@@ -102,7 +152,7 @@ var AbacusCrawl = (() => {
|
|||||||
const { crawlStop } = await browser.storage.local.get('crawlStop');
|
const { crawlStop } = await browser.storage.local.get('crawlStop');
|
||||||
if (crawlStop) throw new Error('Stopped');
|
if (crawlStop) throw new Error('Stopped');
|
||||||
days.push(readDay());
|
days.push(readDay());
|
||||||
await progress({ status: 'running', message: `Read ${cur} (${days.length} day(s))` });
|
await progress({ status: 'running', message: `Read ${U.fmtDate(cur)} (${days.length} day(s))` });
|
||||||
if (cur >= to || ++n > maxSteps) break;
|
if (cur >= to || ++n > maxSteps) break;
|
||||||
await step(1);
|
await step(1);
|
||||||
cur = readDate();
|
cur = readDate();
|
||||||
@@ -113,7 +163,7 @@ var AbacusCrawl = (() => {
|
|||||||
daysText: text,
|
daysText: text,
|
||||||
extractInfo: { at: new Date().toISOString(), url: location.href, count: parsed.length, raw: days.map((d) => JSON.stringify(d)) },
|
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}` });
|
await progress({ status: 'done', message: `Done: ${parsed.length} worked day(s) between ${U.fmtDate(from)} and ${U.fmtDate(to)}` });
|
||||||
return { days };
|
return { days };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (days.length) await browser.storage.local.set({ daysText: toLines(days) + `\n# stopped: ${e.message}` });
|
if (days.length) await browser.storage.local.set({ daysText: toLines(days) + `\n# stopped: ${e.message}` });
|
||||||
@@ -124,5 +174,5 @@ var AbacusCrawl = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { run, readDay, readDate, present };
|
return { run, readDay, readDate, present, diagnose };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+8
-6
@@ -122,13 +122,13 @@ var AbacusParse = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Normalized one-line-per-day format used in the dashboard editor:
|
// Normalized one-line-per-day format used in the dashboard editor:
|
||||||
// 2026-10-07 08:00-12:00 13:00-17:24
|
// 07/10/2026 08:00-12:00 13:00-17:24
|
||||||
// 2026-10-08 8:24
|
// 08/10/2026 8:24
|
||||||
function toText(days) {
|
function toText(days) {
|
||||||
return days
|
return days
|
||||||
.map((d) => d.intervals.length
|
.map((d) => d.intervals.length
|
||||||
? `${d.date} ${d.intervals.map((iv) => `${U.fmtTime(iv.start)}-${U.fmtTime(iv.end)}`).join(' ')}`
|
? `${U.fmtDate(d.date)} ${d.intervals.map((iv) => `${U.fmtTime(iv.start)}-${U.fmtTime(iv.end)}`).join(' ')}`
|
||||||
: `${d.date} ${U.fmtDur(d.minutes)}`)
|
: `${U.fmtDate(d.date)} ${U.fmtDur(d.minutes)}`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,8 +137,10 @@ var AbacusParse = (() => {
|
|||||||
text.split('\n').forEach((line, n) => {
|
text.split('\n').forEach((line, n) => {
|
||||||
line = line.replace(/#.*/, '').trim();
|
line = line.replace(/#.*/, '').trim();
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
const m = /^(\d{4}-\d{2}-\d{2})\s+(.+)$/.exec(line);
|
const m = /^(\S+)\s+(.+)$/.exec(line);
|
||||||
if (!m) return errors.push(`line ${n + 1}: expected "YYYY-MM-DD ..."`);
|
const date = m && U.parseUserDate(m[1]);
|
||||||
|
if (!date) return errors.push(`line ${n + 1}: expected "dd/mm/yyyy ..."`);
|
||||||
|
m[1] = date;
|
||||||
const ivs = ranges(m[2]);
|
const ivs = ranges(m[2]);
|
||||||
if (ivs.length) return days.push({ date: m[1], intervals: union(ivs), minutes: sumIntervals(union(ivs)) });
|
if (ivs.length) return days.push({ date: m[1], intervals: union(ivs), minutes: sumIntervals(union(ivs)) });
|
||||||
const min = U.parseHours(m[2]);
|
const min = U.parseHours(m[2]);
|
||||||
|
|||||||
+43
@@ -57,8 +57,51 @@ var U = (() => {
|
|||||||
|
|
||||||
const uid = () => Math.random().toString(36).slice(2, 10);
|
const uid = () => Math.random().toString(36).slice(2, 10);
|
||||||
|
|
||||||
|
/* Swiss display: 01/09/2026, 13:30. Internally always ISO dates + HH:MM. */
|
||||||
|
const fmtDate = (iso) => (iso ? `${iso.slice(8, 10)}/${iso.slice(5, 7)}/${iso.slice(0, 4)}` : '');
|
||||||
|
|
||||||
|
// "1/9/2026", "01.09.2026", "01-09-26", "2026-09-01" -> "2026-09-01" (null if invalid)
|
||||||
|
const parseUserDate = (s) => {
|
||||||
|
s = String(s || '').trim();
|
||||||
|
let m, y, mo, d;
|
||||||
|
if ((m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(s))) [, y, mo, d] = m;
|
||||||
|
else if ((m = /^(\d{1,2})[./-](\d{1,2})[./-](\d{4}|\d{2})$/.exec(s))) [, d, mo, y] = m;
|
||||||
|
else return null;
|
||||||
|
if (y.length === 2) y = '20' + y;
|
||||||
|
const dt = new Date(+y, +mo - 1, +d);
|
||||||
|
return dt.getMonth() === +mo - 1 && dt.getDate() === +d ? isoDate(dt) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDateTime = (dt) => `${fmtDate(isoDate(dt))} ${fmtTime(dt.getHours() * 60 + dt.getMinutes())}`;
|
||||||
|
|
||||||
|
// "930", "9:30", "9.30", "9h30", "9" -> "09:30" (null if invalid)
|
||||||
|
const parseUserTime = (s) => {
|
||||||
|
s = String(s || '').trim();
|
||||||
|
let m;
|
||||||
|
if ((m = /^(\d{1,2})$/.exec(s))) m = [s, m[1], '0'];
|
||||||
|
else if (!(m = /^(\d{1,2})[:.h]?(\d{2})$/.exec(s))) return null;
|
||||||
|
const min = +m[1] * 60 + +m[2];
|
||||||
|
return +m[1] < 24 && +m[2] < 60 ? fmtTime(min) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Text inputs with data-kind="date" | "time": read/write ISO / HH:MM, show Swiss format
|
||||||
|
const getField = (el) => (el.dataset.kind === 'date' ? parseUserDate(el.value) : parseUserTime(el.value)) || '';
|
||||||
|
const setField = (el, v) => { el.value = el.dataset.kind === 'date' ? fmtDate(v) : v || ''; };
|
||||||
|
const setupFields = (root = document) => {
|
||||||
|
root.querySelectorAll('input[data-kind]').forEach((el) => {
|
||||||
|
el.placeholder ||= el.dataset.kind === 'date' ? 'dd/mm/yyyy' : 'hh:mm';
|
||||||
|
el.size = el.dataset.kind === 'date' ? 10 : 5;
|
||||||
|
el.addEventListener('blur', () => {
|
||||||
|
const v = getField(el);
|
||||||
|
el.classList.toggle('invalid', !!el.value.trim() && !v);
|
||||||
|
if (v) setField(el, v);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pad, toMin, fmtTime, fmtDur, parseHours, parseDate, isoDate, addDays,
|
pad, toMin, fmtTime, fmtDur, parseHours, parseDate, isoDate, addDays,
|
||||||
weekday, weekdayName, WEEKDAYS, monday, weeksBetween, kimaiDateTime, uid,
|
weekday, weekdayName, WEEKDAYS, monday, weeksBetween, kimaiDateTime, uid,
|
||||||
|
fmtDate, parseUserDate, fmtDateTime, parseUserTime, getField, setField, setupFields,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@
|
|||||||
<body>
|
<body>
|
||||||
<h3 style="margin-top:0">Abacus → Kimai</h3>
|
<h3 style="margin-top:0">Abacus → Kimai</h3>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>From <input type="date" id="from"></label>
|
<label>From <input data-kind="date" id="from"></label>
|
||||||
<label>To <input type="date" id="to"></label>
|
<label>To <input data-kind="date" id="to"></label>
|
||||||
</div>
|
</div>
|
||||||
<button id="crawl" class="primary">Read days from MyAbacus</button>
|
<button id="crawl" class="primary">Read days from MyAbacus</button>
|
||||||
<button id="stop" class="hidden">Stop</button>
|
<button id="stop" class="hidden">Stop</button>
|
||||||
|
|||||||
+25
-9
@@ -45,7 +45,7 @@ async function extract() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const total = merged.reduce((a, d) => a + d.minutes, 0);
|
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}`;
|
$status.textContent = `Found ${merged.length} day(s), ${U.fmtDur(total)} total\n${U.fmtDate(merged[0].date)} → ${U.fmtDate(merged[merged.length - 1].date)}`;
|
||||||
browser.runtime.openOptionsPage();
|
browser.runtime.openOptionsPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,19 +63,34 @@ function showCrawl(c) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function crawl() {
|
async function crawl() {
|
||||||
const from = $from.value, to = $to.value;
|
// Must be the first await (still inside the click gesture). Needed for MyAbacus iframes:
|
||||||
|
// activeTab alone only covers the top frame, and MV3 host permissions aren't always granted at install.
|
||||||
|
const ALL = { origins: ['<all_urls>'] };
|
||||||
|
if (!(await browser.permissions.contains(ALL))) {
|
||||||
|
$status.textContent = 'Firefox will ask for site access (needed to read MyAbacus iframes). Then click again.';
|
||||||
|
if (await browser.permissions.request(ALL)) $status.textContent = 'Access granted: click “Read days” again.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const granted = true;
|
||||||
|
const from = U.getField($from), to = U.getField($to);
|
||||||
if (!from || !to || from > to) throw new Error('Pick a valid date range');
|
if (!from || !to || from > to) throw new Error('Pick a valid date range');
|
||||||
await browser.storage.local.set({ crawlRange: { from, to } });
|
await browser.storage.local.set({ crawlRange: { from, to } });
|
||||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
|
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
|
||||||
const target = { tabId: tab.id, allFrames: true };
|
const target = { tabId: tab.id, allFrames: true };
|
||||||
await browser.scripting.executeScript({ target, files: ['lib/util.js', 'lib/parse.js', 'lib/abacus-crawl.js'] });
|
$status.textContent = 'Looking for the card…';
|
||||||
|
await browser.storage.local.set({ crawlRequest: { from, to } });
|
||||||
|
// One single call: Firefox doesn't reliably share globals between separate executeScript calls
|
||||||
const results = await browser.scripting.executeScript({
|
const results = await browser.scripting.executeScript({
|
||||||
target,
|
target,
|
||||||
func: (f, t) => AbacusCrawl.run(f, t),
|
files: ['/lib/util.js', '/lib/parse.js', '/lib/abacus-crawl.js', '/lib/abacus-crawl-start.js'],
|
||||||
args: [from, to],
|
|
||||||
});
|
});
|
||||||
const res = results.map((r) => r.result).filter(Boolean);
|
const res = results.map((r) => r.result || { skipped: true, url: `frame ${r.frameId}`, injectError: r.error?.message || String(r.error || 'no result') });
|
||||||
if (res.every((r) => r.skipped)) throw new Error('No "Gestion des temps" card found on this tab');
|
if (res.every((r) => r.skipped)) {
|
||||||
|
const frames = res.map((r) => r.injectError
|
||||||
|
? `• ${r.url}: injection failed (${r.injectError})`
|
||||||
|
: `• ${r.url}: ${r.shadowRoots} shadow roots, angle-right icon: ${r.angleRight ? 'yes' : 'no'}, title: ${r.title ? 'yes' : 'no'}`);
|
||||||
|
throw new Error(`No "Gestion des temps" card found.${granted ? '' : ' (site access was refused: iframes can\'t be read)'}\n${frames.join('\n')}`);
|
||||||
|
}
|
||||||
const err = res.find((r) => r.error);
|
const err = res.find((r) => r.error);
|
||||||
if (!err) browser.runtime.openOptionsPage();
|
if (!err) browser.runtime.openOptionsPage();
|
||||||
}
|
}
|
||||||
@@ -91,8 +106,9 @@ browser.storage.onChanged.addListener((ch) => { if (ch.crawl) showCrawl(ch.crawl
|
|||||||
(async () => {
|
(async () => {
|
||||||
const { crawlRange, crawl: last } = await browser.storage.local.get(['crawlRange', 'crawl']);
|
const { crawlRange, crawl: last } = await browser.storage.local.get(['crawlRange', 'crawl']);
|
||||||
const today = U.isoDate(new Date());
|
const today = U.isoDate(new Date());
|
||||||
$from.value = crawlRange?.from || U.monday(today);
|
U.setupFields();
|
||||||
$to.value = crawlRange?.to || today;
|
U.setField($from, crawlRange?.from || U.monday(today));
|
||||||
|
U.setField($to, crawlRange?.to || today);
|
||||||
// Only show a stale "running" if it's recent (page may have been reloaded mid-crawl)
|
// 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);
|
if (last && (last.status !== 'running' || Date.now() - new Date(last.at) < 30000)) showCrawl(last);
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user