Files
abacus-to-kimai-filler/lib/parse.js
T
2026-10-07 15:53:12 +02:00

168 lines
6.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;