mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-11 01:02:01 +03:00
engine: configurable date formats, both input and display
Besides ISO, the lexer now accepts D.M.YYYY / M/D/YYYY literals (31.12.2024, 12/31/2024) with a round-trip validity check so out-of-range components (2026-13-45, 32.13.2024) fall back to plain arithmetic instead of silently rolling over to a nearby valid date — that rollover bug affected ISO literals too, not just the new forms. Day/month order for ambiguous literals and display layout both come from a new dateFormat setting (system/iso/dmy/mdy); "system" resolves once via Intl against the OS locale. Intl.DateTimeFormat instances are now cached by options key instead of rebuilt on every render. App-side wiring (settings UI, persistence) comes in a follow-up commit.
This commit is contained in:
parent
3a264c4a3f
commit
0b2293f92f
7 changed files with 132 additions and 19 deletions
|
|
@ -1,4 +1,16 @@
|
||||||
// Date helpers and a city → IANA timezone map for "time in Tokyo".
|
// Date helpers and a city → IANA timezone map for "time in Tokyo".
|
||||||
|
import { DateOrder } from "./types";
|
||||||
|
|
||||||
|
/** Does the OS locale write the day or the month first? (31.12 vs 12/31) */
|
||||||
|
export function detectDateOrder(): DateOrder {
|
||||||
|
const parts = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "numeric", year: "numeric" })
|
||||||
|
.formatToParts(new Date(2000, 11, 31));
|
||||||
|
for (const p of parts) {
|
||||||
|
if (p.type === "day") return "dmy";
|
||||||
|
if (p.type === "month") return "mdy";
|
||||||
|
}
|
||||||
|
return "dmy";
|
||||||
|
}
|
||||||
|
|
||||||
const Z: Record<string, string> = {
|
const Z: Record<string, string> = {
|
||||||
// ---- common abbreviations
|
// ---- common abbreviations
|
||||||
|
|
|
||||||
|
|
@ -244,4 +244,41 @@ describe("review fixes", () => {
|
||||||
expect(r[0].text).toBe("10");
|
expect(r[0].text).toBe("10");
|
||||||
expect(r[0].commentStart).toBe(6);
|
expect(r[0].commentStart).toBe(6);
|
||||||
});
|
});
|
||||||
|
it("invalid ISO date does not roll over — falls back to arithmetic", () => {
|
||||||
|
expect(calc("2026-13-45")).toBe("1,968");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("date literal formats", () => {
|
||||||
|
const dmyEng = new SumEngine({ dateFormat: "dmy" });
|
||||||
|
const mdyEng = new SumEngine({ dateFormat: "mdy" });
|
||||||
|
const isoEng = new SumEngine({ dateFormat: "iso" });
|
||||||
|
const calcWith = (e: SumEngine, expr: string) => {
|
||||||
|
const r = e.evaluateDocument(expr);
|
||||||
|
return r[r.length - 1].text;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("dot-separated date respects the configured day/month order", () => {
|
||||||
|
expect(calcWith(dmyEng, "31.12.2024 - 30.12.2024")).toBe("1 day");
|
||||||
|
});
|
||||||
|
it("slash-separated date respects the configured day/month order", () => {
|
||||||
|
expect(calcWith(mdyEng, "12/31/2024 - 12/30/2024")).toBe("1 day");
|
||||||
|
});
|
||||||
|
it("ambiguous date (both parts <=12) follows the engine's dateFormat", () => {
|
||||||
|
// dmy: 01/02/2024 = 1 Feb; mdy: 01/02/2024 = 2 Jan
|
||||||
|
expect(calcWith(dmyEng, "01/02/2024 - 2024-01-01")).toBe("31 day");
|
||||||
|
expect(calcWith(mdyEng, "01/02/2024 - 2024-01-01")).toBe("1 day");
|
||||||
|
});
|
||||||
|
it("an unambiguous component (>12) wins regardless of dateFormat", () => {
|
||||||
|
expect(calcWith(mdyEng, "31.12.2024 - 30.12.2024")).toBe("1 day");
|
||||||
|
});
|
||||||
|
it("an invalid date (both components out of range) is not parsed as a date", () => {
|
||||||
|
const r = dmyEng.evaluateDocument("32.13.2024");
|
||||||
|
expect(r[0].value?.kind).not.toBe("date");
|
||||||
|
});
|
||||||
|
it("display format overrides the OS locale layout", () => {
|
||||||
|
expect(calcWith(isoEng, "2024-01-15")).toBe("2024-01-15");
|
||||||
|
expect(calcWith(dmyEng, "2024-01-15")).toBe("15.01.2024");
|
||||||
|
expect(calcWith(mdyEng, "2024-01-15")).toBe("01/15/2024");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,31 @@
|
||||||
// Turns values into display strings: unit/currency formats plus
|
// Turns values into display strings: unit/currency formats plus
|
||||||
// precision and grouping settings.
|
// precision and grouping settings.
|
||||||
import { Decimal, EngineSettings, Value } from "./types";
|
import { Decimal, DateVal, EngineSettings, Value } from "./types";
|
||||||
|
|
||||||
|
// Intl.DateTimeFormat construction is relatively expensive and every line with
|
||||||
|
// a date rebuilds one on each render — cache by locale+options instead.
|
||||||
|
const dtfCache = new Map<string, Intl.DateTimeFormat>();
|
||||||
|
function dtf(locale: string | undefined, opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
||||||
|
const key = (locale ?? "") + JSON.stringify(opts);
|
||||||
|
let f = dtfCache.get(key);
|
||||||
|
if (!f) {
|
||||||
|
f = new Intl.DateTimeFormat(locale, opts);
|
||||||
|
dtfCache.set(key, f);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed-layout date display (iso/dmy/mdy), independent of OS locale. */
|
||||||
|
function formatDateFixed(v: DateVal, fmt: "iso" | "dmy" | "mdy"): string {
|
||||||
|
const parts = dtf("en-GB", {
|
||||||
|
timeZone: v.timeZone, year: "numeric", month: "2-digit", day: "2-digit",
|
||||||
|
hour: "2-digit", minute: "2-digit", hourCycle: "h23",
|
||||||
|
}).formatToParts(v.ms);
|
||||||
|
const get = (t: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === t)?.value ?? "";
|
||||||
|
const [y, mo, d] = [get("year"), get("month"), get("day")];
|
||||||
|
const date = fmt === "iso" ? `${y}-${mo}-${d}` : fmt === "dmy" ? `${d}.${mo}.${y}` : `${mo}/${d}/${y}`;
|
||||||
|
return v.hasTime ? `${date} ${get("hour")}:${get("minute")}` : date;
|
||||||
|
}
|
||||||
|
|
||||||
function group(intPart: string, sep: string): string {
|
function group(intPart: string, sep: string): string {
|
||||||
if (!sep) return intPart;
|
if (!sep) return intPart;
|
||||||
|
|
@ -35,15 +60,16 @@ export function formatValue(v: Value, s: EngineSettings): string {
|
||||||
if (v.kind === "date") {
|
if (v.kind === "date") {
|
||||||
const opts: Intl.DateTimeFormatOptions = { timeZone: v.timeZone };
|
const opts: Intl.DateTimeFormatOptions = { timeZone: v.timeZone };
|
||||||
if (v.timeOnly) {
|
if (v.timeOnly) {
|
||||||
return new Intl.DateTimeFormat(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms);
|
return dtf(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms);
|
||||||
}
|
}
|
||||||
|
if (s.dateFormat !== "system") return formatDateFixed(v, s.dateFormat);
|
||||||
if (v.hasTime) {
|
if (v.hasTime) {
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
return dtf(undefined, {
|
||||||
...opts, hour: "2-digit", minute: "2-digit", hour12: false,
|
...opts, hour: "2-digit", minute: "2-digit", hour12: false,
|
||||||
day: "numeric", month: "short", year: "numeric",
|
day: "numeric", month: "short", year: "numeric",
|
||||||
}).format(v.ms);
|
}).format(v.ms);
|
||||||
}
|
}
|
||||||
return new Intl.DateTimeFormat(undefined, { ...opts, day: "numeric", month: "long", year: "numeric" }).format(v.ms);
|
return dtf(undefined, { ...opts, day: "numeric", month: "long", year: "numeric" }).format(v.ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
// quantity
|
// quantity
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
// Public facade: evaluates whole documents line by line and exposes the
|
// Public facade: evaluates whole documents line by line and exposes the
|
||||||
// extension API (numi.addUnit / addFunction / setVariable).
|
// extension API (numi.addUnit / addFunction / setVariable).
|
||||||
import { Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types";
|
import { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types";
|
||||||
import { Registry, buildRegistry, Completion } from "./registry";
|
import { Registry, buildRegistry, Completion } from "./registry";
|
||||||
import { tokenize, Token } from "./tokenizer";
|
import { tokenize, Token } from "./tokenizer";
|
||||||
import { parseLine } from "./parser";
|
import { parseLine } from "./parser";
|
||||||
import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator";
|
import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator";
|
||||||
import { formatValue } from "./formatter";
|
import { formatValue } from "./formatter";
|
||||||
|
import { detectDateOrder } from "./datetime";
|
||||||
import snapshot from "./rates-snapshot.json";
|
import snapshot from "./rates-snapshot.json";
|
||||||
import { CRYPTO } from "./extraunits";
|
import { CRYPTO } from "./extraunits";
|
||||||
|
|
||||||
|
|
@ -20,6 +21,10 @@ function findCommentStart(line: string): number {
|
||||||
return m ? m.index + m[1].length : -1;
|
return m ? m.index + m[1].length : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDateOrder(fmt: EngineSettings["dateFormat"]): DateOrder {
|
||||||
|
return fmt === "dmy" || fmt === "mdy" ? fmt : detectDateOrder();
|
||||||
|
}
|
||||||
|
|
||||||
export interface LineResult {
|
export interface LineResult {
|
||||||
/** formatted result, null when the line has none (text, comment, header, error) */
|
/** formatted result, null when the line has none (text, comment, header, error) */
|
||||||
text: string | null;
|
text: string | null;
|
||||||
|
|
@ -53,9 +58,12 @@ export class SumEngine {
|
||||||
settings: EngineSettings;
|
settings: EngineSettings;
|
||||||
private globals = new Map<string, Value>();
|
private globals = new Map<string, Value>();
|
||||||
private historicalRates = new Map<string, Map<string, number>>();
|
private historicalRates = new Map<string, Map<string, number>>();
|
||||||
|
/** resolved day/month order for ambiguous date literals like "01/02/2024" */
|
||||||
|
private dateOrder: DateOrder;
|
||||||
|
|
||||||
constructor(settings: Partial<EngineSettings> = {}) {
|
constructor(settings: Partial<EngineSettings> = {}) {
|
||||||
this.settings = { ...defaultSettings, ...settings };
|
this.settings = { ...defaultSettings, ...settings };
|
||||||
|
this.dateOrder = resolveDateOrder(this.settings.dateFormat);
|
||||||
this.reg = buildRegistry();
|
this.reg = buildRegistry();
|
||||||
this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot });
|
this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot });
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +84,7 @@ export class SumEngine {
|
||||||
|
|
||||||
updateSettings(patch: Partial<EngineSettings>): void {
|
updateSettings(patch: Partial<EngineSettings>): void {
|
||||||
this.settings = { ...this.settings, ...patch };
|
this.settings = { ...this.settings, ...patch };
|
||||||
|
if (patch.dateFormat) this.dateOrder = resolveDateOrder(this.settings.dateFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
completions(): Completion[] {
|
completions(): Completion[] {
|
||||||
|
|
@ -109,7 +118,7 @@ export class SumEngine {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = tokenize(line, this.reg);
|
const tokens = tokenize(line, this.reg, this.dateOrder);
|
||||||
const knownVars = new Set(vars.keys());
|
const knownVars = new Set(vars.keys());
|
||||||
const parsed = parseLine(tokens, knownVars, line);
|
const parsed = parseLine(tokens, knownVars, line);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Splits a line into raw lexemes (numbers, words, symbols). Shared by the
|
// Splits a line into raw lexemes (numbers, words, symbols). Shared by the
|
||||||
// tokenizer and the phrase registry so both segment text the same way.
|
// tokenizer and the phrase registry so both segment text the same way.
|
||||||
import { Decimal, NumeralRepr } from "./types";
|
import { DateOrder, Decimal, NumeralRepr } from "./types";
|
||||||
|
|
||||||
export interface Lex {
|
export interface Lex {
|
||||||
type: "num" | "word" | "sym" | "date" | "xref";
|
type: "num" | "word" | "sym" | "date" | "xref";
|
||||||
|
|
@ -23,8 +23,18 @@ const NUM_SPACE_GROUPED_RE = /^\d{1,3}(?:[ ]\d{3})+(?:[.,]\d+)?(?!\d)/;
|
||||||
const NUM_COMMA_GROUPED_RE = /^\d{1,3}(?:,\d{3})+(?:\.\d+)?(?!\d)/;
|
const NUM_COMMA_GROUPED_RE = /^\d{1,3}(?:,\d{3})+(?:\.\d+)?(?!\d)/;
|
||||||
const NUM_DECIMAL_COMMA_RE = /^\d+,\d+/;
|
const NUM_DECIMAL_COMMA_RE = /^\d+,\d+/;
|
||||||
const NUM_PLAIN_RE = /^\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
|
const NUM_PLAIN_RE = /^\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
|
||||||
|
// 31.12.2024 / 12/31/2024 — same separator on both sides, 4-digit year
|
||||||
|
const SLASH_DOT_DATE_RE = /^(\d{1,2})([./])(\d{1,2})\2(\d{4})(?!\d)/;
|
||||||
|
|
||||||
export function lexLine(text: string): Lex[] {
|
/** ms for a real calendar date; null when the components roll over (2026-13-45) */
|
||||||
|
function validDateMs(y: number, mo: number, d: number): number | null {
|
||||||
|
const dt = new Date(y, mo - 1, d);
|
||||||
|
return dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d
|
||||||
|
? dt.getTime()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lexLine(text: string, dateOrder: DateOrder = "dmy"): Lex[] {
|
||||||
const out: Lex[] = [];
|
const out: Lex[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < text.length) {
|
while (i < text.length) {
|
||||||
|
|
@ -36,16 +46,29 @@ export function lexLine(text: string): Lex[] {
|
||||||
const rest = text.slice(i);
|
const rest = text.slice(i);
|
||||||
|
|
||||||
if (ch >= "0" && ch <= "9") {
|
if (ch >= "0" && ch <= "9") {
|
||||||
const iso = /^\d{4}-\d{2}-\d{2}(?!\d)/.exec(rest);
|
const iso = /^(\d{4})-(\d{2})-(\d{2})(?!\d)/.exec(rest);
|
||||||
if (iso) {
|
if (iso) {
|
||||||
const [y, mo, d] = iso[0].split("-").map(Number);
|
const ms = validDateMs(Number(iso[1]), Number(iso[2]), Number(iso[3]));
|
||||||
const dt = new Date(y, mo - 1, d);
|
if (ms !== null) {
|
||||||
if (!isNaN(dt.getTime())) {
|
out.push({ type: "date", raw: iso[0], start: i, end: i + iso[0].length, dateMs: ms });
|
||||||
out.push({ type: "date", raw: iso[0], start: i, end: i + iso[0].length, dateMs: dt.getTime() });
|
|
||||||
i += iso[0].length;
|
i += iso[0].length;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const dm = SLASH_DOT_DATE_RE.exec(rest);
|
||||||
|
if (dm) {
|
||||||
|
const a = Number(dm[1]);
|
||||||
|
const b = Number(dm[3]);
|
||||||
|
// a>12 must be the day, b>12 must be the month; both ≤12 → configured order
|
||||||
|
const dayFirst = a > 12 ? true : b > 12 ? false : dateOrder === "dmy";
|
||||||
|
const y = Number(dm[4]);
|
||||||
|
const ms = dayFirst ? validDateMs(y, b, a) : validDateMs(y, a, b);
|
||||||
|
if (ms !== null) {
|
||||||
|
out.push({ type: "date", raw: dm[0], start: i, end: i + dm[0].length, dateMs: ms });
|
||||||
|
i += dm[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (ch >= "0" && ch <= "9") {
|
if (ch >= "0" && ch <= "9") {
|
||||||
const hex = /^0[xX][0-9a-fA-F]+/.exec(rest);
|
const hex = /^0[xX][0-9a-fA-F]+/.exec(rest);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Lexes a line and resolves phrases through the registry into semantic tokens.
|
// Lexes a line and resolves phrases through the registry into semantic tokens.
|
||||||
import { Decimal, NumeralRepr, Unit } from "./types";
|
import { DateOrder, Decimal, NumeralRepr, Unit } from "./types";
|
||||||
import { lexLine } from "./lexer";
|
import { lexLine } from "./lexer";
|
||||||
import { Registry, PctOp, DateWord, BitOp } from "./registry";
|
import { Registry, PctOp, DateWord, BitOp } from "./registry";
|
||||||
|
|
||||||
|
|
@ -30,11 +30,8 @@ export type Token =
|
||||||
| { t: "unknown"; start: number; end: number }
|
| { t: "unknown"; start: number; end: number }
|
||||||
| { t: "junk"; raw: string; start: number; end: number };
|
| { t: "junk"; raw: string; start: number; end: number };
|
||||||
|
|
||||||
const SYM_OPS: Record<string, Token["t"] | "plus" | "minus" | "mul" | "div" | "pow"> = {};
|
export function tokenize(line: string, reg: Registry, dateOrder: DateOrder = "dmy"): Token[] {
|
||||||
|
const lexes = lexLine(line, dateOrder);
|
||||||
export function tokenize(line: string, reg: Registry): Token[] {
|
|
||||||
void SYM_OPS;
|
|
||||||
const lexes = lexLine(line);
|
|
||||||
const lowers = lexes.map((l) => l.raw.toLowerCase());
|
const lowers = lexes.map((l) => l.raw.toLowerCase());
|
||||||
const tokens: Token[] = [];
|
const tokens: Token[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,12 @@ export type Dimension =
|
||||||
|
|
||||||
export type NumeralRepr = "decimal" | "hex" | "binary" | "octal" | "scientific" | "fraction" | "roman" | "plain";
|
export type NumeralRepr = "decimal" | "hex" | "binary" | "octal" | "scientific" | "fraction" | "roman" | "plain";
|
||||||
|
|
||||||
|
/** day-first (31.12.2024) or month-first (12/31/2024) for ambiguous date literals */
|
||||||
|
export type DateOrder = "dmy" | "mdy";
|
||||||
|
|
||||||
|
/** "system" renders via the OS locale; the rest force a fixed layout */
|
||||||
|
export type DateFormat = "system" | "iso" | "dmy" | "mdy";
|
||||||
|
|
||||||
/** A resolved unit instance attached to a value. */
|
/** A resolved unit instance attached to a value. */
|
||||||
export interface Unit {
|
export interface Unit {
|
||||||
/** canonical id, e.g. "meter", "kilo:meter", "USD", "sq:foot" */
|
/** canonical id, e.g. "meter", "kilo:meter", "USD", "sq:foot" */
|
||||||
|
|
@ -88,10 +94,13 @@ export interface EngineSettings {
|
||||||
groupSeparator: string;
|
groupSeparator: string;
|
||||||
/** decimal separator shown in output */
|
/** decimal separator shown in output */
|
||||||
decimalSeparator: string;
|
decimalSeparator: string;
|
||||||
|
/** date display layout; also disambiguates 01/02/2024 on input */
|
||||||
|
dateFormat: DateFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultSettings: EngineSettings = {
|
export const defaultSettings: EngineSettings = {
|
||||||
precision: 2,
|
precision: 2,
|
||||||
groupSeparator: ",",
|
groupSeparator: ",",
|
||||||
decimalSeparator: ".",
|
decimalSeparator: ".",
|
||||||
|
dateFormat: "system",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue