170 lines
6.6 KiB
JavaScript
170 lines
6.6 KiB
JavaScript
// 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:
|
||
// 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;
|