first version

This commit is contained in:
2026-10-07 15:53:12 +02:00
commit 9d8c2a0488
14 changed files with 1481 additions and 0 deletions
+167
View File
@@ -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;