klog.js
Version:
A JavaScript implementation of the Klog time tracking file format
275 lines (274 loc) • 8.81 kB
JavaScript
import { toAST } from "ohm-js/extras";
import grammar from "./grammar.ohm-bundle.js";
import { isValid, parseISO } from "date-fns";
import { DayShift, TimeFormat } from "./time.js";
import { RangeDashFormat } from "./range.js";
import { Record } from "./record.js";
// TODO: enforcing consistent indents & newlines
const mapping = {
file(_, __, record1, ___, ____, records, _____, ______) {
return {
type: "file",
records: [record1.toAST(mapping)].concat(records.toAST(mapping)),
};
},
record: (value) => value.toAST(mapping),
record_summaryAndEntries(recordHead, _, summary, __, entry1, ___, entries) {
return {
type: "record",
...recordHead.toAST(mapping),
summary: summary.toAST(mapping),
entries: [entry1.toAST(mapping)]
.concat(entries.toAST(mapping))
// Remove `null` (incase no entries are defined)
.filter((x) => x),
};
},
record_entries(recordHead, _, entry1, __, entries) {
return {
type: "record",
...recordHead.toAST(mapping),
summary: null,
entries: [entry1.toAST(mapping)]
.concat(entries.toAST(mapping))
// Remove `null` (incase no entries are defined)
.filter((x) => x),
};
},
record_summary(recordHead, _, summary) {
return {
type: "record",
...recordHead.toAST(mapping),
summary: summary.toAST(mapping),
entries: [],
};
},
record_empty(recordHead) {
return {
type: "record",
...recordHead.toAST(mapping),
summary: null,
entries: [],
};
},
recordHead(date, _, shouldTotal) {
return {
date: date.toAST(mapping),
shouldTotal: shouldTotal.toAST(mapping),
};
},
shouldTotal: (_, duration, __) => duration.toAST(mapping).value,
entry: (_, value, summary) => ({
type: "entry",
value: value.toAST(mapping),
summary: summary.toAST(mapping),
}),
recordSummary: (value) => value.toAST(mapping).trim(),
entrySummary: (value) => value.toAST(mapping).trim(),
timeRange: (range) => range.toAST(mapping),
timeRange_open: (start, spaceLeft, __, spaceRight, placeholder) => {
const hasSpaces = !![spaceLeft, spaceRight].flatMap((n) => n.toAST(mapping)).length;
return {
type: "timeRange",
open: true,
placeholderCount: placeholder.toAST(mapping).length,
format: hasSpaces ? RangeDashFormat.Spaces : RangeDashFormat.NoSpaces,
start: start.toAST(mapping),
};
},
timeRange_closed: (start, spaceLeft, __, spaceRight, end) => {
const hasSpaces = !![spaceLeft, spaceRight].flatMap((n) => n.toAST(mapping)).length;
return {
type: "timeRange",
open: false,
format: hasSpaces ? RangeDashFormat.Spaces : RangeDashFormat.NoSpaces,
start: start.toAST(mapping),
end: end.toAST(mapping),
};
},
duration: (value) => value.toAST(mapping),
// TODO: how does klog do negatives
duration_hour($sign, $value, _) {
const sign = $sign.toAST(mapping) || "";
const mul = sign === "-" ? -1 : 1;
const value = parseInt($value.toAST(mapping).join(""), 10) * 60 * mul;
return {
type: "duration",
value,
sign,
};
},
duration_minute($sign, $value, _) {
const sign = $sign.toAST(mapping) || "";
const mul = sign === "-" ? -1 : 1;
const value = parseInt($value.toAST(mapping).join(""), 10) * mul;
return {
type: "duration",
value,
sign,
};
},
duration_hourMinute($sign, $hours, _, minute1, minute2, __) {
const sign = $sign.toAST(mapping) || "";
const mul = sign === "-" ? -1 : 1;
const hours = parseInt($hours.toAST(mapping).join(""), 10);
const minutes = parseInt(minute1.toAST(mapping) + minute2.toAST(mapping), 10);
const value = (hours * 60 + minutes) * mul;
return {
type: "duration",
value,
sign,
};
},
time: (value) => value.toAST(mapping),
backwardsShiftedTime: (_, $time) => {
return {
...$time.toAST(mapping),
shift: DayShift.Yesterday,
};
},
forwardsShiftedTime: ($time, _) => {
return {
...$time.toAST(mapping),
shift: DayShift.Tomorrow,
};
},
date(y1, y2, y3, y4, sep1, m1, m2, sep2, d1, d2) {
const dateString = [y1, y2, y3, y4, sep1, m1, m2, sep2, d1, d2]
.map((x) => x.toAST(mapping))
.join("")
.replaceAll("/", "-");
const date = parseISO(dateString);
if (!isValid(date))
throw new Error(`Invalid date ${dateString}`);
return date;
},
time_twelveHour(h1, h2, _, m1, m2, $period) {
const period = $period.toAST(mapping)?.toLowerCase() || "am";
let hour = parseInt([h1, h2].map((x) => x.toAST(mapping)).join(""), 10);
// Convert to 24 hour
if (period === "am" && hour === 12)
hour = 0;
else if (period === "pm" && hour !== 12)
hour += 12;
const minute = parseInt(m1.toAST(mapping) + m2.toAST(mapping), 10);
return {
type: "time",
hour,
minute,
shift: DayShift.Today,
format: TimeFormat.TwelveHour,
};
},
time_twentyFourHour(hr, _, m1, m2) {
const hour = parseInt(hr.toAST(mapping), 10);
const minute = parseInt(m1.toAST(mapping) + m2.toAST(mapping), 10);
return {
type: "time",
hour,
minute,
shift: DayShift.Today,
format: TimeFormat.TwentyFourHour,
};
},
};
/**
* Parses a Klog source string into an AST.
* @param source - The Klog source string to parse.
* @param rule - The grammar rule to apply. If not provided, defaults to "file". Provided mostly for testing.
* @throws {Error} The parsing failed.
* @example
* ```
* const source = `
* 2021-06-20
* 08:00 - 15:00 Work
* -1h Lunch
* `;
* const ast = parseAST(source);
* console.log(JSON.stringify(ast, null, 2));
* // {
* // "type": "file",
* // "records": [
* // {
* // "type": "record",
* // "date": "2021-06-19T14:00:00.000Z",
* // "shouldTotal": null,
* // "summary": null,
* // "entries": [
* // {
* // "type": "entry",
* // "value": {
* // "type": "timeRange",
* // "open": false,
* // "format": 0,
* // "start": {
* // "type": "time",
* // "hour": 8,
* // "minute": 0,
* // "shift": 0,
* // "format": "24h"
* // },
* // "end": {
* // "type": "time",
* // "hour": 15,
* // "minute": 0,
* // "shift": 0,
* // "format": "24h"
* // }
* // },
* // "summary": "Work"
* // },
* // {
* // "type": "entry",
* // "value": {
* // "type": "duration",
* // "value": -60,
* // "sign": "-"
* // },
* // "summary": "Lunch"
* // }
* // ]
* // }
* // ]
* // }
* ```
*/
export const parseAST = ((source, rule) => {
if ((!source.trim() && !rule) || rule === "file")
return { type: "file", records: [] };
const match = grammar.match(source, rule);
if (match.succeeded())
return toAST(match, mapping);
else
throw new Error(match.message);
});
/**
* Parses a Klog source string into an array of `Record` classes.
* @param source - The Klog source string to parse.
* @example
* ```
* const source = `
* 2021-06-20
* 08:00 - 15:00 Work
* -1h Lunch
* `;
* const records = parse(source);
* console.log(records);
* // [
* // Record {
* // date: new Date('2021-06-20'),
* // entries: [
* // new Entry(new Range(new Time(8, 0), new Time(15, 0)), new Summary("Work")),
* // new Entry(new Duration(-1, 0), new Summary("Lunch"))
* // ],
* // summary: null,
* // shouldTotal: null,
* // dateFormat: RecordDateFormat.Dashes
* // }
* // ]
* ```
*/
export const parse = (source) => {
const nodes = parseAST(source);
return nodes.records.map(Record.fromAST);
};