mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 22:41:58 +03:00
add ISO date literals and days until/since
This commit is contained in:
parent
eff90d3219
commit
f8f9095fca
10 changed files with 70 additions and 8 deletions
|
|
@ -282,6 +282,24 @@ describe("dates", () => {
|
||||||
const t = calc("time in Tokyo");
|
const t = calc("time in Tokyo");
|
||||||
expect(t).toMatch(/^\d{2}:\d{2}$/);
|
expect(t).toMatch(/^\d{2}:\d{2}$/);
|
||||||
});
|
});
|
||||||
|
it("ISO date literal produces a formatted date", () => {
|
||||||
|
const r = calc("2024-01-01");
|
||||||
|
expect(r).not.toBeNull();
|
||||||
|
expect(typeof r).toBe("string");
|
||||||
|
});
|
||||||
|
it("ISO date subtraction: 2026-01-01 - 2024-01-01 = 731 days", () => {
|
||||||
|
expect(calc("2026-01-01 - 2024-01-01")).toBe("731 day");
|
||||||
|
});
|
||||||
|
it("days until future date returns positive days", () => {
|
||||||
|
const r = calc("days until 2099-01-01");
|
||||||
|
expect(r).not.toBeNull();
|
||||||
|
expect(Number(r!.replace(/[^\d.-]/g, ""))).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
it("days since past date returns positive days", () => {
|
||||||
|
const r = calc("days since 2000-01-01");
|
||||||
|
expect(r).not.toBeNull();
|
||||||
|
expect(Number(r!.replace(/[^\d.-]/g, ""))).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extension API", () => {
|
describe("extension API", () => {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
|
||||||
}
|
}
|
||||||
case "date":
|
case "date":
|
||||||
return evalDateWord(node.word);
|
return evalDateWord(node.word);
|
||||||
|
case "datelit":
|
||||||
|
return { kind: "date", ms: node.ms, hasTime: false };
|
||||||
case "agg":
|
case "agg":
|
||||||
return evalAgg(node.name, ctx);
|
return evalAgg(node.name, ctx);
|
||||||
case "neg": {
|
case "neg": {
|
||||||
|
|
@ -192,7 +194,7 @@ function evalBin(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const q = (l.kind === "quantity" ? l : r) as Quantity;
|
const q = (l.kind === "quantity" ? l : r) as Quantity;
|
||||||
const p = (l.kind === "percent" ? l : r).value;
|
const p = (l.kind === "percent" ? l : r as { kind: "percent"; value: Decimal }).value;
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "plus": return qty(q.value.mul(new Decimal(1).add(p.div(100))), q.unit);
|
case "plus": return qty(q.value.mul(new Decimal(1).add(p.div(100))), q.unit);
|
||||||
case "minus":
|
case "minus":
|
||||||
|
|
@ -513,6 +515,14 @@ function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
|
||||||
const raw = isInt ? Math.floor(rand * (hi - lo + 1)) + lo : rand * (hi - lo) + lo;
|
const raw = isInt ? Math.floor(rand * (hi - lo + 1)) + lo : rand * (hi - lo) + lo;
|
||||||
return qty(new Decimal(raw));
|
return qty(new Decimal(raw));
|
||||||
}
|
}
|
||||||
|
case "until": {
|
||||||
|
if (x.kind !== "date") throw new EvalError("until needs a date");
|
||||||
|
return evalDateArith("minus", x, { kind: "date", ms: startOfToday(), hasTime: false });
|
||||||
|
}
|
||||||
|
case "since": {
|
||||||
|
if (x.kind !== "date") throw new EvalError("since needs a date");
|
||||||
|
return evalDateArith("minus", { kind: "date", ms: startOfToday(), hasTime: false }, x);
|
||||||
|
}
|
||||||
default: throw new EvalError(`unknown function ${name}`);
|
default: throw new EvalError(`unknown function ${name}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ export function formatNumber(v: Decimal, s: EngineSettings): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatValue(v: Value, s: EngineSettings): string {
|
export function formatValue(v: Value, s: EngineSettings): string {
|
||||||
|
if (v.kind === "chart") return "";
|
||||||
|
|
||||||
if (v.kind === "percent") {
|
if (v.kind === "percent") {
|
||||||
return formatNumber(v.value, s) + "%";
|
return formatNumber(v.value, s) + "%";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,8 @@ export class SumEngine {
|
||||||
return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) };
|
return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) };
|
||||||
}
|
}
|
||||||
if (v.kind === "percent") return { double: v.value.div(100).toNumber() };
|
if (v.kind === "percent") return { double: v.value.div(100).toNumber() };
|
||||||
return { double: v.ms };
|
if (v.kind === "date") return { double: v.ms };
|
||||||
|
return { double: 0 }; // chart — not representable as ExtensionValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@
|
||||||
import { Decimal, NumeralRepr } from "./types";
|
import { Decimal, NumeralRepr } from "./types";
|
||||||
|
|
||||||
export interface Lex {
|
export interface Lex {
|
||||||
type: "num" | "word" | "sym";
|
type: "num" | "word" | "sym" | "date";
|
||||||
raw: string;
|
raw: string;
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
value?: Decimal;
|
value?: Decimal;
|
||||||
repr?: NumeralRepr;
|
repr?: NumeralRepr;
|
||||||
|
dateMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORD_RE = /^[\p{L}_]+/u;
|
const WORD_RE = /^[\p{L}_]+/u;
|
||||||
|
|
@ -32,6 +33,18 @@ export function lexLine(text: string): Lex[] {
|
||||||
}
|
}
|
||||||
const rest = text.slice(i);
|
const rest = text.slice(i);
|
||||||
|
|
||||||
|
if (ch >= "0" && ch <= "9") {
|
||||||
|
const iso = /^\d{4}-\d{2}-\d{2}(?!\d)/.exec(rest);
|
||||||
|
if (iso) {
|
||||||
|
const [y, mo, d] = iso[0].split("-").map(Number);
|
||||||
|
const dt = new Date(y, mo - 1, d);
|
||||||
|
if (!isNaN(dt.getTime())) {
|
||||||
|
out.push({ type: "date", raw: iso[0], start: i, end: i + iso[0].length, dateMs: dt.getTime() });
|
||||||
|
i += iso[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);
|
||||||
if (hex) {
|
if (hex) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export type Node =
|
||||||
| { k: "const"; name: "pi" | "e" | "half" | "onehalf" }
|
| { k: "const"; name: "pi" | "e" | "half" | "onehalf" }
|
||||||
| { k: "var"; name: string }
|
| { k: "var"; name: string }
|
||||||
| { k: "date"; word: DateWord }
|
| { k: "date"; word: DateWord }
|
||||||
|
| { k: "datelit"; ms: number }
|
||||||
| { k: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart" }
|
| { k: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart" }
|
||||||
| { k: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node }
|
| { k: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node }
|
||||||
| { k: "bit"; op: BitOp; l: Node; r: Node }
|
| { k: "bit"; op: BitOp; l: Node; r: Node }
|
||||||
|
|
@ -360,6 +361,9 @@ class Parser {
|
||||||
case "date":
|
case "date":
|
||||||
this.i++;
|
this.i++;
|
||||||
return { k: "date", word: tk.word };
|
return { k: "date", word: tk.word };
|
||||||
|
case "datelit":
|
||||||
|
this.i++;
|
||||||
|
return { k: "datelit", ms: tk.ms };
|
||||||
case "agg":
|
case "agg":
|
||||||
this.i++;
|
this.i++;
|
||||||
return { k: "agg", name: tk.name };
|
return { k: "agg", name: tk.name };
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ const BUILTIN_FUNCS = [
|
||||||
"sqrt", "cbrt", "sin", "cos", "tan", "cot", "asin", "acos", "atan",
|
"sqrt", "cbrt", "sin", "cos", "tan", "cot", "asin", "acos", "atan",
|
||||||
"arcsin", "arccos", "arctan", "sinh", "cosh", "tanh",
|
"arcsin", "arccos", "arctan", "sinh", "cosh", "tanh",
|
||||||
"ln", "lg", "log", "log2", "exp", "abs", "round", "ceil", "floor",
|
"ln", "lg", "log", "log2", "exp", "abs", "round", "ceil", "floor",
|
||||||
"fact", "factorial", "random",
|
"fact", "factorial", "random", "until", "since",
|
||||||
];
|
];
|
||||||
|
|
||||||
export class Registry {
|
export class Registry {
|
||||||
|
|
@ -181,6 +181,9 @@ export function buildRegistry(): Registry {
|
||||||
for (const v of vocab.variants("Operations", "ln.variants")) reg.addPhrase(v, { t: "func", name: "ln" }, { caseSensitive: false });
|
for (const v of vocab.variants("Operations", "ln.variants")) reg.addPhrase(v, { t: "func", name: "ln" }, { caseSensitive: false });
|
||||||
for (const v of vocab.variants("Operations", "log.variants")) reg.addPhrase(v, { t: "func", name: "log" }, { caseSensitive: false });
|
for (const v of vocab.variants("Operations", "log.variants")) reg.addPhrase(v, { t: "func", name: "log" }, { caseSensitive: false });
|
||||||
for (const v of vocab.variants("Operations", "round.variants")) reg.addPhrase(v, { t: "func", name: "round" }, { caseSensitive: false });
|
for (const v of vocab.variants("Operations", "round.variants")) reg.addPhrase(v, { t: "func", name: "round" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "random.variants")) reg.addPhrase(v, { t: "func", name: "random" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "until.variants")) reg.addPhrase(v, { t: "func", name: "until" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "since.variants")) reg.addPhrase(v, { t: "func", name: "since" }, { caseSensitive: false });
|
||||||
reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false });
|
reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false });
|
||||||
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });
|
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,11 @@ export type Token =
|
||||||
| { t: "pctop"; op: PctOp; start: number; end: number }
|
| { t: "pctop"; op: PctOp; start: number; end: number }
|
||||||
| { t: "percent"; start: number; end: number }
|
| { t: "percent"; start: number; end: number }
|
||||||
| { t: "func"; name: string; start: number; end: number }
|
| { t: "func"; name: string; start: number; end: number }
|
||||||
| { t: "agg"; name: "sum" | "avg" | "prev"; start: number; end: number }
|
| { t: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart"; start: number; end: number }
|
||||||
| { t: "scale"; mult: Decimal; start: number; end: number }
|
| { t: "scale"; mult: Decimal; start: number; end: number }
|
||||||
| { t: "repr"; repr: NumeralRepr; start: number; end: number }
|
| { t: "repr"; repr: NumeralRepr; start: number; end: number }
|
||||||
| { t: "date"; word: DateWord; start: number; end: number }
|
| { t: "date"; word: DateWord; start: number; end: number }
|
||||||
|
| { t: "datelit"; ms: number; start: number; end: number }
|
||||||
| { t: "const"; name: "pi" | "e" | "half" | "onehalf"; start: number; end: number }
|
| { t: "const"; name: "pi" | "e" | "half" | "onehalf"; start: number; end: number }
|
||||||
| { t: "lparen"; start: number; end: number }
|
| { t: "lparen"; start: number; end: number }
|
||||||
| { t: "rparen"; start: number; end: number }
|
| { t: "rparen"; start: number; end: number }
|
||||||
|
|
@ -45,6 +46,12 @@ export function tokenize(line: string, reg: Registry): Token[] {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lx.type === "date") {
|
||||||
|
tokens.push({ t: "datelit", ms: lx.dateMs!, ...span });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const m = reg.match(lexes, lowers, i);
|
const m = reg.match(lexes, lowers, i);
|
||||||
if (m) {
|
if (m) {
|
||||||
const end = lexes[i + m.length - 1].end;
|
const end = lexes[i + m.length - 1].end;
|
||||||
|
|
|
||||||
|
|
@ -433,7 +433,9 @@ export const EN: VocabLocale = {
|
||||||
"max.variants": "max,maximum,largest",
|
"max.variants": "max,maximum,largest",
|
||||||
"product.variants": "product",
|
"product.variants": "product",
|
||||||
"random.variants": "random,rand",
|
"random.variants": "random,rand",
|
||||||
"chart.variants": "chart,spark,sparkline,graph"
|
"chart.variants": "chart,spark,sparkline,graph",
|
||||||
|
"until.variants": "until,days until,day until",
|
||||||
|
"since.variants": "since,days since,day since"
|
||||||
},
|
},
|
||||||
"Percent": {
|
"Percent": {
|
||||||
"as_a_percent_of.variants": "as a % of,as a percent of",
|
"as_a_percent_of.variants": "as a % of,as a percent of",
|
||||||
|
|
@ -911,7 +913,9 @@ export const RU: VocabLocale = {
|
||||||
"max.variants": "максимум,макс,наибольшее",
|
"max.variants": "максимум,макс,наибольшее",
|
||||||
"product.variants": "произведение,произв",
|
"product.variants": "произведение,произв",
|
||||||
"random.variants": "случайное,случайно,рандом",
|
"random.variants": "случайное,случайно,рандом",
|
||||||
"chart.variants": "график,спарклайн,диаграмма"
|
"chart.variants": "график,спарклайн,диаграмма",
|
||||||
|
"until.variants": "осталось до,дней до,дней до даты",
|
||||||
|
"since.variants": "прошло с,дней с,дней прошло с"
|
||||||
},
|
},
|
||||||
"Percent": {
|
"Percent": {
|
||||||
"as_a_percent_of.variants": "как процент от,как процент",
|
"as_a_percent_of.variants": "как процент от,как процент",
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ function buildDecorations(state: EditorState): DecorationSet {
|
||||||
let cls: string | null = null;
|
let cls: string | null = null;
|
||||||
switch (tk.t) {
|
switch (tk.t) {
|
||||||
case "num": case "const": case "scale": cls = "tok-number"; break;
|
case "num": case "const": case "scale": cls = "tok-number"; break;
|
||||||
case "unit": case "currency": case "repr": case "date": cls = "tok-unit"; break;
|
case "unit": case "currency": case "repr": case "date": case "datelit": cls = "tok-unit"; break;
|
||||||
case "op": case "conv": case "assign": case "pctop": case "percent": case "bang": cls = "tok-op"; break;
|
case "op": case "conv": case "assign": case "pctop": case "percent": case "bang": cls = "tok-op"; break;
|
||||||
case "func": case "agg": cls = "tok-var"; break;
|
case "func": case "agg": cls = "tok-var"; break;
|
||||||
default: cls = null;
|
default: cls = null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue