// Turn text scraped/pasted from MyAbacus into day records: // { date: 'YYYY-MM-DD', intervals: [{start, end}], minutes } // intervals are minutes since midnight (may be empty when Abacus only gives a total). var AbacusParse = (() => { const DATE_RES = [ { re: /\b(\d{4})-(\d{2})-(\d{2})\b/, get: (m) => [m[1], m[2], m[3]] }, { re: /\b(\d{1,2})[./](\d{1,2})[./](\d{4}|\d{2})\b/, get: (m) => [m[3].length === 2 ? '20' + m[3] : m[3], m[2], m[1]] }, { re: /\b(\d{1,2})\.(\d{1,2})\.(?!\d)/, get: (m, year) => [year, m[2], m[1]] }, ]; const RANGE_RE = /(\d{1,2})[:.h](\d{2})\s*(?:-|–|—|to|bis|à)\s*(\d{1,2})[:.h](\d{2})/gi; const TIME_RE = /(? +m[1] * 60 + +m[2] }, { re: /(? +m[1] * 60 + +m[2] }, { re: /(? Math.round(parseFloat(`${m[1]}.${m[2]}`) * 60) }, { re: /(? +m[1] * 60 }, ]; function findDate(text, year) { for (const { re, get } of DATE_RES) { const m = re.exec(text); if (!m) continue; const [y, mo, d] = get(m, year); if (+mo < 1 || +mo > 12 || +d < 1 || +d > 31) continue; return { date: `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`, rest: text.replace(m[0], ' ') }; } return null; } const valid = (s, e) => s >= 0 && e <= 24 * 60 && e > s; function ranges(text) { const out = []; for (const m of text.matchAll(RANGE_RE)) { const s = +m[1] * 60 + +m[2], e = +m[3] * 60 + +m[4]; if (valid(s, e)) out.push({ start: s, end: e }); } return out; } function pairs(text) { const times = [...text.matchAll(TIME_RE)].map((m) => +m[1] * 60 + +m[2]); const out = []; for (let i = 0; i + 1 < times.length; i += 2) { if (valid(times[i], times[i + 1])) out.push({ start: times[i], end: times[i + 1] }); } return out; } function durations(text) { const found = []; let rest = text; for (const { re, get } of DUR_RES) { for (const m of rest.matchAll(re)) found.push({ idx: m.index, min: get(m) }); rest = rest.replace(re, (s) => ' '.repeat(s.length)); // don't double-match } return found.filter((f) => f.min > 0 && f.min <= 24 * 60).sort((a, b) => a.idx - b.idx).map((f) => f.min); } // Union of intervals, sorted function union(intervals) { const sorted = [...intervals].sort((a, b) => a.start - b.start); const out = []; for (const iv of sorted) { const last = out[out.length - 1]; if (last && iv.start <= last.end) last.end = Math.max(last.end, iv.end); else out.push({ ...iv }); } return out; } const sumIntervals = (ivs) => ivs.reduce((a, iv) => a + iv.end - iv.start, 0); /** * rows: array of strings (one per table row or text line) * opts: { timeMode: auto|ranges|pairs|duration, durationIndex, sameDateRows: sum|max|last, * carryDate, year } */ function parseRows(rows, opts = {}) { const o = { timeMode: 'auto', durationIndex: 0, sameDateRows: 'sum', carryDate: false, year: new Date().getFullYear(), ...opts }; const byDate = new Map(); let lastDate = null; for (const raw of rows) { const text = String(raw).replace(/\s+/g, ' '); const found = findDate(text, o.year); if (found) lastDate = found.date; else if (!o.carryDate || !lastDate) continue; const date = found ? found.date : lastDate; const rest = found ? found.rest : text; let ivs = [], dur = null; if (o.timeMode === 'ranges' || o.timeMode === 'auto') ivs = ranges(rest); if (o.timeMode === 'pairs') ivs = pairs(rest); if (!ivs.length && (o.timeMode === 'duration' || o.timeMode === 'auto')) { const ds = durations(rest); const i = o.durationIndex < 0 ? ds.length + +o.durationIndex : +o.durationIndex; if (ds[i] != null) dur = ds[i]; } if (!ivs.length && dur == null) continue; const day = byDate.get(date) || { date, intervals: [], durs: [] }; day.intervals.push(...ivs); if (dur != null) day.durs.push(dur); byDate.set(date, day); } return [...byDate.values()] .map((d) => { const intervals = union(d.intervals); let minutes; if (intervals.length) minutes = sumIntervals(intervals); else if (o.sameDateRows === 'max') minutes = Math.max(...d.durs); else if (o.sameDateRows === 'last') minutes = d.durs[d.durs.length - 1]; else minutes = d.durs.reduce((a, b) => a + b, 0); return { date: d.date, intervals, minutes }; }) .filter((d) => d.minutes > 0) .sort((a, b) => a.date.localeCompare(b.date)); } // Normalized one-line-per-day format used in the dashboard editor: // 07/10/2026 08:00-12:00 13:00-17:24 // 08/10/2026 8:24 function toText(days) { return days .map((d) => d.intervals.length ? `${U.fmtDate(d.date)} ${d.intervals.map((iv) => `${U.fmtTime(iv.start)}-${U.fmtTime(iv.end)}`).join(' ')}` : `${U.fmtDate(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 = /^(\S+)\s+(.+)$/.exec(line); 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]); 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;