From 0b2c04de34a2aaa54d255a33a5adbd1348219ccd Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sat, 28 Mar 2026 19:42:13 +0300 Subject: [PATCH 01/15] feat(math): add operator phrases, rounding, percentages, strip units, math functions - Operator phrases: `to the power of`, `remainder of X divided by Y`, `divided by` - Rounding directives: `to N dp`, `rounded`, `rounded up/down`, `to nearest X` - Percentage extensions: `X to Y is what %`, `X is what % off Y`, `0.35 as %`, `X/Y as %` - Strip units: `$100 as number`, `20% as dec`, `50% to decimal` - Math functions: exp(), log2(), log10() via native math.js - Scientific notation input: 1e5, 2.5e3 - Fix implicit multiplication breaking function names with digits (log2, log10) --- .../__tests__/useMathEngine.test.ts | 59 +++++ .../math-notebook/math-engine/preprocess.ts | 133 +++++++++--- .../math-notebook/useMathEngine.ts | 202 +++++++++++++++++- 3 files changed, 356 insertions(+), 38 deletions(-) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 3907b66e..bf51f3a0 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -57,6 +57,22 @@ describe('arithmetic', () => { it('grouped thousands', () => expectValue('5 300', '5,300')) }) +describe('rounding', () => { + it('to 2 dp', () => expectValue('1/3 to 2 dp', '0.33')) + it('to 5 digits', () => expectValue('pi to 5 digits', '3.14159')) + it('rounded', () => expectValue('5.5 rounded', '6')) + it('rounded down', () => expectValue('5.5 rounded down', '5')) + it('rounded up', () => expectValue('5.5 rounded up', '6')) + it('to nearest 10', () => expectValue('37 to nearest 10', '40')) + it('rounded to nearest thousand', () => + expectValue('2100 to nearest thousand', '2,000')) + it('rounded up to nearest 5', () => + expectValue('21 rounded up to nearest 5', '25')) + it('rounded down to nearest 3', () => + expectValue('17 rounded down to nearest 3', '15')) + it('to nearest hundred', () => expectValue('490 to nearest hundred', '500')) +}) + describe('math aliases', () => { it('fact', () => expectValue('fact(5)', '120')) it('arcsin', () => expectNumericClose('arcsin(1)', Math.PI / 2, 4)) @@ -64,6 +80,9 @@ describe('math aliases', () => { it('arctan', () => expectNumericClose('arctan(1)', Math.PI / 4, 4)) it('root 2 (8)', () => expectNumericClose('root 2 (8)', Math.sqrt(8), 4)) it('log 2 (8)', () => expectNumericClose('log 2 (8)', 3, 4)) + it('exp(1)', () => expectNumericClose('exp(1)', Math.E, 4)) + it('log2(8)', () => expectNumericClose('log2(8)', 3, 4)) + it('log10(1000)', () => expectNumericClose('log10(1000)', 3, 4)) }) describe('word operators', () => { @@ -79,6 +98,10 @@ describe('word operators', () => { it('divide by', () => expectValue('100 divide by 4', '25')) it('mul', () => expectValue('5 mul 6', '30')) it('mod', () => expectValue('17 mod 5', '2')) + it('to the power of', () => expectValue('3 to the power of 2', '9')) + it('remainder of X divided by Y', () => + expectValue('remainder of 21 divided by 5', '1')) + it('divided by', () => expectValue('1000 divided by 200', '5')) it('does not replace words inside variable names', () => { const results = evalLines('width = 100\nwidth * 2') @@ -154,6 +177,25 @@ describe('percentage advanced', () => { it('Y% of what is X', () => expectNumericClose('5% of what is 6', 120)) it('Y% on what is X', () => expectNumericClose('5% on what is 6', 5.71)) it('Y% off what is X', () => expectNumericClose('5% off what is 6', 6.32)) + + // Percentage change + it('X to Y is what %', () => expectNumericClose('50 to 75 is what %', 50)) + it('X to Y as %', () => expectNumericClose('40 to 90 as %', 125)) + it('X is what % off Y', () => + expectNumericClose('180 is what % off 200', 10)) + it('X is what % on Y', () => expectNumericClose('180 is what % on 150', 20)) + it('X is what % of Y', () => expectNumericClose('20 is what % of 200', 10)) + it('X as a % of Y (alt)', () => expectNumericClose('20 as a % of 200', 10)) + + // Reverse lookup: X is Y% of/on/off what + it('X is Y% of what', () => expectNumericClose('20 is 10% of what', 200)) + it('X is Y% on what', () => expectNumericClose('220 is 10% on what', 200)) + it('X is Y% off what', () => expectNumericClose('180 is 10% off what', 200)) + + // Decimal/fraction to percentage + it('0.35 as %', () => expectNumericClose('0.35 as %', 35)) + it('X/Y as %', () => expectNumericClose('20/200 as %', 10)) + it('X/Y %', () => expectNumericClose('20/200 %', 10)) }) describe('scales', () => { @@ -456,11 +498,28 @@ describe('number format', () => { it('hex input', () => expectValue('0xFF', '255')) it('binary input', () => expectValue('0b1010', '10')) it('octal input', () => expectValue('0o377', '255')) + it('scientific notation input 1e5', () => expectValue('1e5', '100,000')) + it('scientific notation input 2.5e3', () => expectValue('2.5e3', '2,500')) it('0xFF in hex roundtrip', () => { const result = evalLine('0xFF in hex') expect(result.value).toBe('0xFF') }) + + it('$100 as number', () => { + const result = evalLine('$100 as number') + expect(result.numericValue).toBe(100) + }) + + it('20% as dec', () => { + const result = evalLine('20% as dec') + expect(result.value).toBe('0.2') + }) + + it('50% to decimal', () => { + const result = evalLine('50% to decimal') + expect(result.value).toBe('0.5') + }) }) describe('numericValue for total', () => { diff --git a/src/renderer/composables/math-notebook/math-engine/preprocess.ts b/src/renderer/composables/math-notebook/math-engine/preprocess.ts index c302cd40..859baf9a 100644 --- a/src/renderer/composables/math-notebook/math-engine/preprocess.ts +++ b/src/renderer/composables/math-notebook/math-engine/preprocess.ts @@ -415,12 +415,15 @@ function preprocessStackedUnits(line: string): string { } function preprocessImplicitMultiplication(line: string): string { - return line.replace(/(\d)\s*\(/g, '$1 * (') + return line.replace(/\b(\d+)\s*\(/g, '$1 * (') } function preprocessWordOperators(line: string): string { return line + .replace(/\bremainder\s+of\s+(\S+)\s+divided\s+by\s+(\S+)/gi, '$1 % $2') + .replace(/\bto\s+the\s+power\s+of\b/gi, '^') .replace(/\bmultiplied\s+by\b/gi, '*') + .replace(/\bdivided\s+by\b/gi, '/') .replace(/\bdivide\s+by\b/gi, '/') .replace(/(\S+)\s+xor\s+(\S+)/gi, 'bitXor($1, $2)') .replace(/\btimes\b/gi, '*') @@ -436,40 +439,100 @@ function preprocessWordOperators(line: string): string { } function preprocessPercentages(line: string): string { - return line - .replace( - /(\d+(?:\.\d+)?)%\s+of\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, - '$2 / ($1 / 100)', - ) - .replace( - /(\d+(?:\.\d+)?)%\s+on\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, - '$2 / (1 + $1 / 100)', - ) - .replace( - /(\d+(?:\.\d+)?)%\s+off\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, - '$2 / (1 - $1 / 100)', - ) - .replace( - /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+of\s+(\d+(?:\.\d+)?)/gi, - '($1 / $2) * 100', - ) - .replace( - /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+on\s+(\d+(?:\.\d+)?)/gi, - '(($1 - $2) / $2) * 100', - ) - .replace( - /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+off\s+(\d+(?:\.\d+)?)/gi, - '(($2 - $1) / $2) * 100', - ) - .replace(/(\d+(?:\.\d+)?)%\s+on\s+(\d+(?:\.\d+)?)/gi, '$2 * (1 + $1 / 100)') - .replace( - /(\d+(?:\.\d+)?)%\s+off\s+(\d+(?:\.\d+)?)/gi, - '$2 * (1 - $1 / 100)', - ) - .replace(/(\d+(?:\.\d+)?)%\s+of\s+(\d+(?:\.\d+)?)/gi, '$1 / 100 * $2') - .replace(/(\d+(?:\.\d+)?)\s*\+\s*(\d+(?:\.\d+)?)%/g, '$1 * (1 + $2 / 100)') - .replace(/(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)%/g, '$1 * (1 - $2 / 100)') - .replace(/(\d+(?:\.\d+)?)%(?!\s*\w)/g, '$1 / 100') + return ( + line + .replace( + /(\d+(?:\.\d+)?)%\s+of\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, + '$2 / ($1 / 100)', + ) + .replace( + /(\d+(?:\.\d+)?)%\s+on\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, + '$2 / (1 + $1 / 100)', + ) + .replace( + /(\d+(?:\.\d+)?)%\s+off\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, + '$2 / (1 - $1 / 100)', + ) + // X is Y% of what → X / (Y / 100) + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)%\s+of\s+what\b/gi, + '$1 / ($2 / 100)', + ) + // X is Y% on what → X / (1 + Y/100) + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)%\s+on\s+what\b/gi, + '$1 / (1 + $2 / 100)', + ) + // X is Y% off what → X / (1 - Y/100) + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)%\s+off\s+what\b/gi, + '$1 / (1 - $2 / 100)', + ) + // X to Y is what % → ((Y - X) / X) * 100 + .replace( + /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+is\s+what\s*%/gi, + '(($2 - $1) / $1) * 100', + ) + // X to Y as % → ((Y - X) / X) * 100 + .replace( + /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+as\s*%/gi, + '(($2 - $1) / $1) * 100', + ) + // X is what % off Y → ((Y - X) / Y) * 100 + .replace( + /(\d+(?:\.\d+)?)\s+is\s+what\s*%\s+off\s+(\d+(?:\.\d+)?)/gi, + '(($2 - $1) / $2) * 100', + ) + // X is what % on Y → ((X - Y) / Y) * 100 + .replace( + /(\d+(?:\.\d+)?)\s+is\s+what\s*%\s+on\s+(\d+(?:\.\d+)?)/gi, + '(($1 - $2) / $2) * 100', + ) + // X is what % of Y → (X / Y) * 100 + .replace( + /(\d+(?:\.\d+)?)\s+is\s+what\s*%\s+of\s+(\d+(?:\.\d+)?)/gi, + '($1 / $2) * 100', + ) + .replace( + /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+of\s+(\d+(?:\.\d+)?)/gi, + '($1 / $2) * 100', + ) + .replace( + /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+on\s+(\d+(?:\.\d+)?)/gi, + '(($1 - $2) / $2) * 100', + ) + .replace( + /(\d+(?:\.\d+)?)\s+as\s+a\s+%\s+off\s+(\d+(?:\.\d+)?)/gi, + '(($2 - $1) / $2) * 100', + ) + // X/Y as % → (X / Y) * 100 + .replace( + /(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s+as\s*%/gi, + '($1 / $2) * 100', + ) + // X/Y % → (X / Y) * 100 + .replace( + /(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s*%(?!\s*\w)/g, + '($1 / $2) * 100', + ) + // 0.35 as % → 0.35 * 100 + .replace(/(\d+(?:\.\d+)?)\s+as\s*%/gi, '$1 * 100') + .replace( + /(\d+(?:\.\d+)?)%\s+on\s+(\d+(?:\.\d+)?)/gi, + '$2 * (1 + $1 / 100)', + ) + .replace( + /(\d+(?:\.\d+)?)%\s+off\s+(\d+(?:\.\d+)?)/gi, + '$2 * (1 - $1 / 100)', + ) + .replace(/(\d+(?:\.\d+)?)%\s+of\s+(\d+(?:\.\d+)?)/gi, '$1 / 100 * $2') + .replace( + /(\d+(?:\.\d+)?)\s*\+\s*(\d+(?:\.\d+)?)%/g, + '$1 * (1 + $2 / 100)', + ) + .replace(/(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)%/g, '$1 * (1 - $2 / 100)') + .replace(/(\d+(?:\.\d+)?)%(?!\s*\w)/g, '$1 / 100') + ) } function preprocessConversions(line: string): string { diff --git a/src/renderer/composables/math-notebook/useMathEngine.ts b/src/renderer/composables/math-notebook/useMathEngine.ts index 9321913d..c4461044 100644 --- a/src/renderer/composables/math-notebook/useMathEngine.ts +++ b/src/renderer/composables/math-notebook/useMathEngine.ts @@ -25,10 +25,117 @@ import { export type { LineResult } from './math-engine/types' interface FormatDirective { - format: 'hex' | 'bin' | 'oct' | 'sci' | null + format: 'hex' | 'bin' | 'oct' | 'sci' | 'number' | 'dec' | null expression: string } +interface RoundingDirective { + type: + | 'dp' + | 'round' + | 'ceil' + | 'floor' + | 'nearest' + | 'nearestCeil' + | 'nearestFloor' + | null + param: number + expression: string +} + +const NEAREST_WORDS: Record = { + ten: 10, + hundred: 100, + thousand: 1000, + million: 1000000, +} + +function detectRoundingDirective(line: string): RoundingDirective { + const lower = line.toLowerCase() + let m: RegExpMatchArray | null + + // "to N dp" / "to N digits" + m = lower.match(/\s+to\s+(\d+)\s+(?:dp|digits?)$/) + if (m) { + return { + type: 'dp', + param: Number(m[1]), + expression: line.slice(0, m.index!).trim(), + } + } + + // "rounded up to nearest X" / "rounded down to nearest X" + m = lower.match(/\s+rounded\s+(up|down)\s+to\s+nearest\s+(\w+)$/) + if (m) { + const n = NEAREST_WORDS[m[2]] || Number(m[2]) + if (n > 0) { + return { + type: m[1] === 'up' ? 'nearestCeil' : 'nearestFloor', + param: n, + expression: line.slice(0, m.index!).trim(), + } + } + } + + // "rounded to nearest X" / "to nearest X" + m = lower.match(/\s+(?:rounded\s+)?to\s+nearest\s+(\w+)$/) + if (m) { + const n = NEAREST_WORDS[m[1]] || Number(m[1]) + if (n > 0) { + return { + type: 'nearest', + param: n, + expression: line.slice(0, m.index!).trim(), + } + } + } + + // "rounded up" / "rounded down" + m = lower.match(/\s+rounded\s+(up|down)$/) + if (m) { + return { + type: m[1] === 'up' ? 'ceil' : 'floor', + param: 0, + expression: line.slice(0, m.index!).trim(), + } + } + + // "rounded" + m = lower.match(/\s+rounded$/) + if (m) { + return { + type: 'round', + param: 0, + expression: line.slice(0, m.index!).trim(), + } + } + + return { type: null, param: 0, expression: line } +} + +function applyRounding(value: number, directive: RoundingDirective): number { + switch (directive.type) { + case 'dp': { + const factor = 10 ** directive.param + return Math.round(value * factor) / factor + } + case 'round': + return Math.round(value) + case 'ceil': + return Math.ceil(value) + case 'floor': + return Math.floor(value) + case 'nearest': + return Math.round(value / directive.param) * directive.param + case 'nearestCeil': + return Math.ceil(value / directive.param) * directive.param + case 'nearestFloor': + return Math.floor(value / directive.param) * directive.param + default: + return value + } +} + let activeCurrencyRates: Record = {} let currencyServiceState: CurrencyServiceState = 'loading' let currencyUnavailableMessage = '' @@ -37,6 +144,28 @@ let math = createMathInstance(activeCurrencyRates) let activeLocale = 'en-US' let activeDecimalPlaces = 6 +const STRIP_UNIT_SUFFIXES: Record = { + 'as number': 'number', + 'to number': 'number', + 'as decimal': 'dec', + 'to decimal': 'dec', + 'as dec': 'dec', + 'to dec': 'dec', +} + +function detectStripUnitDirective(line: string): FormatDirective { + const lower = line.toLowerCase() + for (const [suffix, format] of Object.entries(STRIP_UNIT_SUFFIXES)) { + if (lower.endsWith(suffix)) { + return { + format, + expression: line.slice(0, line.length - suffix.length).trim(), + } + } + } + return { format: null, expression: line } +} + function detectFormatDirective(line: string): FormatDirective { const formatMap: Record = { 'in hex': 'hex', @@ -63,6 +192,39 @@ function applyFormat( result: any, format: NonNullable, ): LineResult { + if (format === 'number' || format === 'dec') { + let num: number + if (typeof result === 'number') { + num = result + } + else if ( + result + && typeof result === 'object' + && typeof result.toNumber === 'function' + ) { + try { + num = result.toNumber() + } + catch { + num = Number.NaN + } + } + else { + num = Number(result) + } + + if (Number.isNaN(num)) { + return { value: String(result), error: null, type: 'number' } + } + + return { + value: formatMathNumber(num, activeLocale, activeDecimalPlaces), + error: null, + type: 'number', + numericValue: num, + } + } + const num = typeof result === 'number' ? result @@ -496,6 +658,39 @@ export function useMathEngine() { } try { + const roundingDirective = detectRoundingDirective(trimmed) + if (roundingDirective.type) { + const roundProcessed = preprocessMathExpression( + roundingDirective.expression, + ) + const roundRaw = math.evaluate(roundProcessed, scope) + const num = getNumericValue(roundRaw) + if (num !== null) { + const rounded = applyRounding(num, roundingDirective) + const formatted = formatResult(rounded) + results.push(formatted) + prevResult = rounded + numericBlock.push(rounded) + continue + } + } + + const stripDirective = detectStripUnitDirective(trimmed) + if (stripDirective.format) { + const stripProcessed = preprocessMathExpression( + stripDirective.expression, + ) + const stripResult = math.evaluate(stripProcessed, scope) + const formatted = applyFormat(stripResult, stripDirective.format) + results.push(formatted) + prevResult = stripResult + const numericValue = getNumericValue(stripResult) + if (numericValue !== null) { + numericBlock.push(numericValue) + } + continue + } + if ( currencyServiceState !== 'ready' && hasCurrencyExpression(trimmed) @@ -587,8 +782,9 @@ export function useMathEngine() { continue } - const { format, expression } = detectFormatDirective(processed) - const toEvaluate = format ? expression : processed + const { format, expression: formatExpression } + = detectFormatDirective(processed) + const toEvaluate = format ? formatExpression : processed const result = math.evaluate(toEvaluate, scope) if (result === undefined) { From 730dd6667d8a45f68b383793513f4c952ce1670e Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sat, 28 Mar 2026 19:44:03 +0300 Subject: [PATCH 02/15] feat(math): add median, count aggregates and inline aggregate syntax - Block aggregates: `median` and `count` keywords (like sum/average) - Inline aggregates: `total of 3, 4, 7 and 9`, `average of X, Y and Z`, `count of X, Y, Z`, `median of X, Y and Z` --- .../__tests__/useMathEngine.test.ts | 40 +++++++++++ .../math-notebook/useMathEngine.ts | 72 +++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index bf51f3a0..1d24031c 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -841,6 +841,46 @@ describe('average and avg', () => { }) }) +describe('median', () => { + it('median of odd count', () => { + const results = evalLines('10\n20\n30\nmedian') + expect(results[3].value).toBe('20') + expect(results[3].type).toBe('aggregate') + }) + + it('median of even count', () => { + const results = evalLines('10\n20\n30\n40\nmedian') + expect(results[4].value).toBe('25') + }) + + it('median resets after empty line', () => { + const results = evalLines('10\n20\n\n100\nmedian') + expect(results[4].value).toBe('100') + }) +}) + +describe('count', () => { + it('count of lines above', () => { + const results = evalLines('10\n20\n30\ncount') + expect(results[3].value).toBe('3') + expect(results[3].type).toBe('aggregate') + }) + + it('count resets after empty line', () => { + const results = evalLines('10\n20\n\n5\ncount') + expect(results[4].value).toBe('1') + }) +}) + +describe('inline aggregates', () => { + it('total of list', () => expectValue('total of 3, 4, 7 and 9', '23')) + it('average of list', () => + expectNumericClose('average of 36, 42, 19 and 81', 44.5)) + it('count of list', () => expectValue('count of 1, 2, 3, 4, 5', '5')) + it('median of list', () => expectValue('median of 10, 20 and 30', '20')) + it('sum of list', () => expectValue('sum of 10, 20, 30', '60')) +}) + describe('comments', () => { it('// comment', () => { const result = evalLine('// This is a comment') diff --git a/src/renderer/composables/math-notebook/useMathEngine.ts b/src/renderer/composables/math-notebook/useMathEngine.ts index c4461044..8e6a3b14 100644 --- a/src/renderer/composables/math-notebook/useMathEngine.ts +++ b/src/renderer/composables/math-notebook/useMathEngine.ts @@ -657,6 +657,78 @@ export function useMathEngine() { continue } + if (lowerTrimmed === 'median') { + if (numericBlock.length > 0) { + const sorted = [...numericBlock].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + const median + = sorted.length % 2 !== 0 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2 + const formatted = formatResult(median) + formatted.type = 'aggregate' + results.push(formatted) + prevResult = median + numericBlock.push(median) + } + else { + results.push(formatResult(0)) + } + continue + } + + if (lowerTrimmed === 'count') { + const count = numericBlock.length + const formatted = formatResult(count) + formatted.type = 'aggregate' + results.push(formatted) + prevResult = count + numericBlock.push(count) + continue + } + + const inlineAggregateMatch = lowerTrimmed.match( + /^(total|sum|average|avg|median|count)\s+of\s+/i, + ) + if (inlineAggregateMatch) { + const fn = inlineAggregateMatch[1].toLowerCase() + const listStr = trimmed.slice(inlineAggregateMatch[0].length) + const items = listStr + .replace(/\band\b/gi, ',') + .split(',') + .map(s => s.trim()) + .filter(Boolean) + .map(Number) + .filter(n => !Number.isNaN(n)) + + if (items.length > 0) { + let value: number + if (fn === 'total' || fn === 'sum') { + value = items.reduce((a, b) => a + b, 0) + } + else if (fn === 'average' || fn === 'avg') { + value = items.reduce((a, b) => a + b, 0) / items.length + } + else if (fn === 'median') { + const sorted = [...items].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + value + = sorted.length % 2 !== 0 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2 + } + else { + value = items.length + } + const formatted = formatResult(value) + formatted.type = 'aggregate' + results.push(formatted) + prevResult = value + numericBlock.push(value) + continue + } + } + try { const roundingDirective = detectRoundingDirective(trimmed) if (roundingDirective.type) { From 09ca70d683ed172699d526ad51783ff7f1d798da Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sat, 28 Mar 2026 19:45:51 +0300 Subject: [PATCH 03/15] feat(math): add phrase syntax for roots and logarithms - `square root of X`, `cube root of X`, `root N of X` - `log X base Y` - `X is Y to what power` (reverse logarithm) --- .../composables/__tests__/useMathEngine.test.ts | 10 ++++++++++ .../math-notebook/math-engine/preprocess.ts | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 1d24031c..44631c77 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -83,6 +83,16 @@ describe('math aliases', () => { it('exp(1)', () => expectNumericClose('exp(1)', Math.E, 4)) it('log2(8)', () => expectNumericClose('log2(8)', 3, 4)) it('log10(1000)', () => expectNumericClose('log10(1000)', 3, 4)) + + // Phrase syntax + it('square root of 81', () => expectValue('square root of 81', '9')) + it('cube root of 27', () => expectValue('cube root of 27', '3')) + it('root 5 of 100', () => expectNumericClose('root 5 of 100', 2.5119, 3)) + it('log 20 base 4', () => expectNumericClose('log 20 base 4', 2.161, 2)) + it('81 is 9 to what power', () => + expectNumericClose('81 is 9 to what power', 2, 2)) + it('27 is 3 to the what', () => + expectNumericClose('27 is 3 to the what', 3, 2)) }) describe('word operators', () => { diff --git a/src/renderer/composables/math-notebook/math-engine/preprocess.ts b/src/renderer/composables/math-notebook/math-engine/preprocess.ts index 859baf9a..8e9be11c 100644 --- a/src/renderer/composables/math-notebook/math-engine/preprocess.ts +++ b/src/renderer/composables/math-notebook/math-engine/preprocess.ts @@ -135,6 +135,18 @@ function preprocessAreaVolumeAliases(line: string): string { return line } +function preprocessPhraseFunctions(line: string): string { + return line + .replace(/\bsquare\s+root\s+of\s+(\S+)/gi, 'sqrt($1)') + .replace(/\bcube\s+root\s+of\s+(\S+)/gi, 'cbrt($1)') + .replace(/\broot\s+(\d+)\s+of\s+(\S+)/gi, 'root($1, $2)') + .replace(/\blog\s+(\S+)\s+base\s+(\S+)/gi, 'log($1, $2)') + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)\s+to\s+(?:what|the\s+what)\s*(?:power)?/gi, + 'log($1) / log($2)', + ) +} + function preprocessFunctionExpression(expression: string): string { const trimmed = expression.trim() const openIndex = trimmed.indexOf('(') @@ -604,6 +616,7 @@ export function preprocessMathExpression(line: string) { processed = preprocessScales(processed) processed = preprocessAreaVolumeAliases(processed) processed = preprocessStackedUnits(processed) + processed = preprocessPhraseFunctions(processed) processed = preprocessFunctionSyntax(processed) processed = preprocessFunctionConversions(processed) processed = preprocessImplicitMultiplication(processed) From 21c7421ddd90cbd25acb6e67496686294342004c Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 05:33:52 +0300 Subject: [PATCH 04/15] feat(math): add extended trigonometry functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Degree trig: sind, cosd, tand, asind, acosd, atand - Degrees as parameter: sin(90 degrees) → sin(90 deg) - Hyperbolic inverse: asinh, acosh, atanh (native math.js) --- .../composables/__tests__/useMathEngine.test.ts | 14 ++++++++++++++ .../math-notebook/math-engine/constants.ts | 9 +++++++++ .../math-notebook/math-engine/mathInstance.ts | 6 ++++++ .../math-notebook/math-engine/preprocess.ts | 1 + 4 files changed, 30 insertions(+) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 44631c77..62b756aa 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -93,6 +93,20 @@ describe('math aliases', () => { expectNumericClose('81 is 9 to what power', 2, 2)) it('27 is 3 to the what', () => expectNumericClose('27 is 3 to the what', 3, 2)) + + // Degree trig functions + it('sind(90)', () => expectNumericClose('sind(90)', 1, 4)) + it('cosd(0)', () => expectNumericClose('cosd(0)', 1, 4)) + it('tand(45)', () => expectNumericClose('tand(45)', 1, 4)) + it('asind(0.5)', () => expectNumericClose('asind(0.5)', 30, 4)) + it('acosd(0.5)', () => expectNumericClose('acosd(0.5)', 60, 4)) + it('atand(1)', () => expectNumericClose('atand(1)', 45, 4)) + it('sin(90 degrees)', () => expectNumericClose('sin(90 degrees)', 1, 4)) + + // Hyperbolic inverse + it('asinh(1)', () => expectNumericClose('asinh(1)', 0.8814, 3)) + it('acosh(2)', () => expectNumericClose('acosh(2)', 1.317, 2)) + it('atanh(0.5)', () => expectNumericClose('atanh(0.5)', 0.5493, 3)) }) describe('word operators', () => { diff --git a/src/renderer/composables/math-notebook/math-engine/constants.ts b/src/renderer/composables/math-notebook/math-engine/constants.ts index 20dc8e80..5dce01ac 100644 --- a/src/renderer/composables/math-notebook/math-engine/constants.ts +++ b/src/renderer/composables/math-notebook/math-engine/constants.ts @@ -241,6 +241,15 @@ export const MATH_UNARY_FUNCTIONS = [ 'sinh', 'cosh', 'tanh', + 'asinh', + 'acosh', + 'atanh', + 'sind', + 'cosd', + 'tand', + 'asind', + 'acosd', + 'atand', ] as const export const TIME_UNIT_TOKEN_MAP: Record = { diff --git a/src/renderer/composables/math-notebook/math-engine/mathInstance.ts b/src/renderer/composables/math-notebook/math-engine/mathInstance.ts index a0fb16cf..d2289e6f 100644 --- a/src/renderer/composables/math-notebook/math-engine/mathInstance.ts +++ b/src/renderer/composables/math-notebook/math-engine/mathInstance.ts @@ -29,6 +29,12 @@ export function createMathInstance(currencyRates: Record) { arcsin: (x: number) => mathInstance.asin(x), arccos: (x: number) => mathInstance.acos(x), arctan: (x: number) => mathInstance.atan(x), + sind: (x: number) => Math.sin((x * Math.PI) / 180), + cosd: (x: number) => Math.cos((x * Math.PI) / 180), + tand: (x: number) => Math.tan((x * Math.PI) / 180), + asind: (x: number) => (Math.asin(x) * 180) / Math.PI, + acosd: (x: number) => (Math.acos(x) * 180) / Math.PI, + atand: (x: number) => (Math.atan(x) * 180) / Math.PI, unitValue: (value: any) => value && typeof value.toNumber === 'function' ? value.toNumber() diff --git a/src/renderer/composables/math-notebook/math-engine/preprocess.ts b/src/renderer/composables/math-notebook/math-engine/preprocess.ts index 8e9be11c..d44f89ff 100644 --- a/src/renderer/composables/math-notebook/math-engine/preprocess.ts +++ b/src/renderer/composables/math-notebook/math-engine/preprocess.ts @@ -145,6 +145,7 @@ function preprocessPhraseFunctions(line: string): string { /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)\s+to\s+(?:what|the\s+what)\s*(?:power)?/gi, 'log($1) / log($2)', ) + .replace(/\b(sin|cos|tan)\((\d+(?:\.\d+)?)\s+degrees\)/gi, '$1($2 deg)') } function preprocessFunctionExpression(expression: string): string { From 527f0f2ec4498cf86e77370f364be1446c7b4cfe Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 05:34:54 +0300 Subject: [PATCH 05/15] feat(math): add tau and phi constants Both already supported natively by math.js, just adding tests. --- src/renderer/composables/__tests__/useMathEngine.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 62b756aa..5c466f76 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -1093,6 +1093,8 @@ describe('time zones', () => { describe('constants', () => { it('pi', () => expectNumericClose('pi', 3.14159, 4)) it('e', () => expectNumericClose('e', 2.71828, 4)) + it('tau', () => expectNumericClose('tau', 6.28318, 4)) + it('phi', () => expectNumericClose('phi', 1.61803, 4)) }) describe('bitwise operations', () => { From 7231f02a66233733baf91c7568acdb2ad7be0c85 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 05:38:30 +0300 Subject: [PATCH 06/15] feat(math): add conditional logic and boolean support - if-then-else: `if 5 > 3 then 10 else 20` - Postfix if/unless: `42 if 5 > 3`, `42 unless 5 < 3` - Comparison operators: ==, !=, >, <, >=, <= - Boolean values: true, false - Logical operators: and, or, &&, || - Boolean assignment and compound conditions --- .../__tests__/useMathEngine.test.ts | 28 ++++++++++++ .../math-notebook/math-engine/preprocess.ts | 45 ++++++++++++++++++- .../math-notebook/useMathEngine.ts | 4 ++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 5c466f76..1e483930 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -1097,6 +1097,34 @@ describe('constants', () => { it('phi', () => expectNumericClose('phi', 1.61803, 4)) }) +describe('conditional logic', () => { + it('if-then-else (true branch)', () => + expectValue('if 5 > 3 then 10 else 20', '10')) + it('if-then-else (false branch)', () => + expectValue('if 5 < 3 then 10 else 20', '20')) + it('comparison ==', () => expectValue('5 == 5', 'true')) + it('comparison !=', () => expectValue('5 != 3', 'true')) + it('comparison >', () => expectValue('5 > 3', 'true')) + it('comparison <', () => expectValue('5 < 3', 'false')) + it('comparison >=', () => expectValue('5 >= 5', 'true')) + it('comparison <=', () => expectValue('3 <= 5', 'true')) + it('boolean true', () => expectValue('true', 'true')) + it('boolean false', () => expectValue('false', 'false')) + it('and operator', () => expectValue('true and false', 'false')) + it('or operator', () => expectValue('true or false', 'true')) + it('&& operator', () => expectValue('true && false', 'false')) + it('|| operator', () => expectValue('true || false', 'true')) + it('compound condition', () => expectValue('5 > 3 and 10 > 7', 'true')) + it('boolean assignment', () => { + const results = evalLines('x = true\nx and false') + expect(results[1].value).toBe('false') + }) + it('postfix if', () => expectValue('42 if 5 > 3', '42')) + it('postfix if (false)', () => expectValue('42 if 5 < 3', '0')) + it('postfix unless', () => expectValue('42 unless 5 > 3', '0')) + it('postfix unless (false)', () => expectValue('42 unless 5 < 3', '42')) +}) + describe('bitwise operations', () => { it('AND', () => expectValue('5 & 3', '1')) it('OR', () => expectValue('5 | 3', '7')) diff --git a/src/renderer/composables/math-notebook/math-engine/preprocess.ts b/src/renderer/composables/math-notebook/math-engine/preprocess.ts index d44f89ff..6c5f154a 100644 --- a/src/renderer/composables/math-notebook/math-engine/preprocess.ts +++ b/src/renderer/composables/math-notebook/math-engine/preprocess.ts @@ -431,8 +431,43 @@ function preprocessImplicitMultiplication(line: string): string { return line.replace(/\b(\d+)\s*\(/g, '$1 * (') } +function preprocessConditionals(line: string): string { + const lower = line.toLowerCase() + + // if COND then EXPR else EXPR → (COND) ? (EXPR) : (EXPR) + const thenIdx = lower.indexOf(' then ') + const elseIdx = lower.indexOf(' else ') + if (/^if\s/i.test(line) && thenIdx > 2 && elseIdx > thenIdx) { + const cond = line.slice(3, thenIdx).trim() + const thenExpr = line.slice(thenIdx + 6, elseIdx).trim() + const elseExpr = line.slice(elseIdx + 6).trim() + return `(${cond}) ? (${thenExpr}) : (${elseExpr})` + } + + // EXPR unless COND → (COND) ? 0 : (EXPR) (check before postfix if) + const unlessIdx = lower.indexOf(' unless ') + if (unlessIdx > 0) { + const expr = line.slice(0, unlessIdx).trim() + const cond = line.slice(unlessIdx + 8).trim() + return `(${cond}) ? 0 : (${expr})` + } + + // EXPR if COND → (COND) ? (EXPR) : 0 + const ifIdx = lower.indexOf(' if ') + if (ifIdx > 0) { + const expr = line.slice(0, ifIdx).trim() + const cond = line.slice(ifIdx + 4).trim() + return `(${cond}) ? (${expr}) : 0` + } + + // && → and, || → or + return line.replace(/&&/g, ' and ').replace(/\|\|/g, ' or ') +} + function preprocessWordOperators(line: string): string { - return line + const hasConditional = /\?|[><=!]=?|\btrue\b|\bfalse\b/.test(line) + + let result = line .replace(/\bremainder\s+of\s+(\S+)\s+divided\s+by\s+(\S+)/gi, '$1 % $2') .replace(/\bto\s+the\s+power\s+of\b/gi, '^') .replace(/\bmultiplied\s+by\b/gi, '*') @@ -442,13 +477,18 @@ function preprocessWordOperators(line: string): string { .replace(/\btimes\b/gi, '*') .replace(/\bdivide\b/gi, '/') .replace(/\bplus\b/gi, '+') - .replace(/\band\b/gi, '+') .replace(/\bwith\b/gi, '+') .replace(/\bminus\b/gi, '-') .replace(/\bsubtract\b/gi, '-') .replace(/\bwithout\b/gi, '-') .replace(/\bmul\b/gi, '*') .replace(/\bmod\b/gi, '%') + + if (!hasConditional) { + result = result.replace(/\band\b/gi, '+') + } + + return result } function preprocessPercentages(line: string): string { @@ -621,6 +661,7 @@ export function preprocessMathExpression(line: string) { processed = preprocessFunctionSyntax(processed) processed = preprocessFunctionConversions(processed) processed = preprocessImplicitMultiplication(processed) + processed = preprocessConditionals(processed) processed = preprocessWordOperators(processed) processed = inferAdditiveUnits(processed) processed = preprocessPercentages(processed) diff --git a/src/renderer/composables/math-notebook/useMathEngine.ts b/src/renderer/composables/math-notebook/useMathEngine.ts index 8e6a3b14..03e7d4fd 100644 --- a/src/renderer/composables/math-notebook/useMathEngine.ts +++ b/src/renderer/composables/math-notebook/useMathEngine.ts @@ -304,6 +304,10 @@ function formatResult(result: any): LineResult { return { value: null, error: null, type: 'empty' } } + if (typeof result === 'boolean') { + return { value: String(result), error: null, type: 'number' } + } + if (result instanceof Date) { return { value: formatMathDate(result, activeLocale), From fcb8c8cfa2baee9df739952898ef44507b2e02e5 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 05:41:03 +0300 Subject: [PATCH 07/15] feat(math): add fraction support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `X as fraction`: convert decimal to simplified fraction (2/10 → 1/5) - `X% as fraction`: convert percentage to fraction (50% → 1/2) - `X/Y of Z`: fraction of value (2/3 of 600 → 400) - `Z is X/Y of what`: reverse fraction (50 is 1/5 of what → 250) --- .../__tests__/useMathEngine.test.ts | 13 ++++ .../math-notebook/math-engine/preprocess.ts | 10 +++ .../math-notebook/useMathEngine.ts | 62 ++++++++++++++++++- 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 1e483930..288ae793 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -222,6 +222,19 @@ describe('percentage advanced', () => { it('X/Y %', () => expectNumericClose('20/200 %', 10)) }) +describe('fractions', () => { + it('2/10 as fraction', () => { + const result = evalLine('2/10 as fraction') + expect(result.value).toBe('1/5') + }) + it('50% as fraction', () => { + const result = evalLine('50% as fraction') + expect(result.value).toBe('1/2') + }) + it('2/3 of 600', () => expectNumericClose('2/3 of 600', 400)) + it('50 is 1/5 of what', () => expectNumericClose('50 is 1/5 of what', 250)) +}) + describe('scales', () => { it('2k', () => { const result = evalLine('2k') diff --git a/src/renderer/composables/math-notebook/math-engine/preprocess.ts b/src/renderer/composables/math-notebook/math-engine/preprocess.ts index 6c5f154a..981c6e8d 100644 --- a/src/renderer/composables/math-notebook/math-engine/preprocess.ts +++ b/src/renderer/composables/math-notebook/math-engine/preprocess.ts @@ -494,6 +494,16 @@ function preprocessWordOperators(line: string): string { function preprocessPercentages(line: string): string { return ( line + // Z is X/Y of what → Z / (X/Y) + .replace( + /(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)\s+of\s+what\b/gi, + '$1 / ($2 / $3)', + ) + // X/Y of Z → (X/Y) * Z (fraction of value) + .replace( + /(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)\s+of\s+(\d+(?:\.\d+)?)\b/gi, + '($1 / $2) * $3', + ) .replace( /(\d+(?:\.\d+)?)%\s+of\s+what\s+is\s+(\d+(?:\.\d+)?)/gi, '$2 / ($1 / 100)', diff --git a/src/renderer/composables/math-notebook/useMathEngine.ts b/src/renderer/composables/math-notebook/useMathEngine.ts index 03e7d4fd..648aa952 100644 --- a/src/renderer/composables/math-notebook/useMathEngine.ts +++ b/src/renderer/composables/math-notebook/useMathEngine.ts @@ -25,7 +25,7 @@ import { export type { LineResult } from './math-engine/types' interface FormatDirective { - format: 'hex' | 'bin' | 'oct' | 'sci' | 'number' | 'dec' | null + format: 'hex' | 'bin' | 'oct' | 'sci' | 'number' | 'dec' | 'fraction' | null expression: string } @@ -144,13 +144,38 @@ let math = createMathInstance(activeCurrencyRates) let activeLocale = 'en-US' let activeDecimalPlaces = 6 -const STRIP_UNIT_SUFFIXES: Record = { +const STRIP_UNIT_SUFFIXES: Record = { 'as number': 'number', 'to number': 'number', 'as decimal': 'dec', 'to decimal': 'dec', 'as dec': 'dec', 'to dec': 'dec', + 'as fraction': 'fraction', + 'to fraction': 'fraction', +} + +function gcd(a: number, b: number): number { + a = Math.abs(Math.round(a)) + b = Math.abs(Math.round(b)) + while (b) { + const t = b + b = a % b + a = t + } + return a +} + +function toFraction(decimal: number): string { + if (Number.isInteger(decimal)) + return `${decimal}/1` + const sign = decimal < 0 ? '-' : '' + const abs = Math.abs(decimal) + const precision = 1e10 + const numerator = Math.round(abs * precision) + const denominator = precision + const divisor = gcd(numerator, denominator) + return `${sign}${numerator / divisor}/${denominator / divisor}` } function detectStripUnitDirective(line: string): FormatDirective { @@ -192,6 +217,39 @@ function applyFormat( result: any, format: NonNullable, ): LineResult { + if (format === 'fraction') { + let num: number + if (typeof result === 'number') { + num = result + } + else if ( + result + && typeof result === 'object' + && typeof result.toNumber === 'function' + ) { + try { + num = result.toNumber() + } + catch { + num = Number.NaN + } + } + else { + num = Number(result) + } + + if (Number.isNaN(num)) { + return { value: String(result), error: null, type: 'number' } + } + + return { + value: toFraction(num), + error: null, + type: 'number', + numericValue: num, + } + } + if (format === 'number' || format === 'dec') { let num: number if (typeof result === 'number') { From 262d41088242c49c2d08d82e81e3968c0858d38e Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 05:44:29 +0300 Subject: [PATCH 08/15] feat(math): add multiplier calculations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `20/5 as multiplier` → 4x - `50 as x of 5` → 10x - `2 as multiplier on 1` → 1x (markup) - `1 as x off 2` → 0.5x (discount) - `50 to 75 is what x` → 1.5x (change) - `20 to 40 as x` → 2x --- .../__tests__/useMathEngine.test.ts | 31 ++++++++++ .../math-notebook/math-engine/preprocess.ts | 37 ++++++++++++ .../math-notebook/useMathEngine.ts | 57 ++++++++++++++++++- 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 288ae793..96ce20d3 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -235,6 +235,37 @@ describe('fractions', () => { it('50 is 1/5 of what', () => expectNumericClose('50 is 1/5 of what', 250)) }) +describe('multipliers', () => { + it('20/5 as multiplier', () => { + const result = evalLine('20/5 as multiplier') + expect(result.value).toBe('4x') + }) + it('50 as x of 5', () => { + const result = evalLine('50 as x of 5') + expect(result.value).toBe('10x') + }) + it('2 as multiplier of 1', () => { + const result = evalLine('2 as multiplier of 1') + expect(result.value).toBe('2x') + }) + it('2 as multiplier on 1', () => { + const result = evalLine('2 as multiplier on 1') + expect(result.value).toBe('1x') + }) + it('1 as x off 2', () => { + const result = evalLine('1 as x off 2') + expect(result.value).toBe('0.5x') + }) + it('50 to 75 is what x', () => { + const result = evalLine('50 to 75 is what x') + expect(result.value).toBe('1.5x') + }) + it('20 to 40 as x', () => { + const result = evalLine('20 to 40 as x') + expect(result.value).toBe('2x') + }) +}) + describe('scales', () => { it('2k', () => { const result = evalLine('2k') diff --git a/src/renderer/composables/math-notebook/math-engine/preprocess.ts b/src/renderer/composables/math-notebook/math-engine/preprocess.ts index 981c6e8d..4ddec806 100644 --- a/src/renderer/composables/math-notebook/math-engine/preprocess.ts +++ b/src/renderer/composables/math-notebook/math-engine/preprocess.ts @@ -135,6 +135,42 @@ function preprocessAreaVolumeAliases(line: string): string { return line } +function preprocessMultipliers(line: string): string { + return ( + line + // X to Y is what x → Y / X as multiplier + .replace( + /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+is\s+what\s+x\b/gi, + '$2 / $1 as multiplier', + ) + // X to Y as x → Y / X as multiplier + .replace( + /(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s+as\s+x\b/gi, + '$2 / $1 as multiplier', + ) + // X as x of Y → X / Y as multiplier + .replace( + /(\d+(?:\.\d+)?)\s+as\s+x\s+of\s+(\d+(?:\.\d+)?)/gi, + '$1 / $2 as multiplier', + ) + // X as multiplier of Y → X / Y as multiplier + .replace( + /(\d+(?:\.\d+)?)\s+as\s+multiplier\s+of\s+(\d+(?:\.\d+)?)/gi, + '$1 / $2 as multiplier', + ) + // X as multiplier on Y → (X - Y) / Y as multiplier + .replace( + /(\d+(?:\.\d+)?)\s+as\s+multiplier\s+on\s+(\d+(?:\.\d+)?)/gi, + '($1 - $2) / $2 as multiplier', + ) + // X as x off Y → (Y - X) / Y as multiplier + .replace( + /(\d+(?:\.\d+)?)\s+as\s+x\s+off\s+(\d+(?:\.\d+)?)/gi, + '($2 - $1) / $2 as multiplier', + ) + ) +} + function preprocessPhraseFunctions(line: string): string { return line .replace(/\bsquare\s+root\s+of\s+(\S+)/gi, 'sqrt($1)') @@ -667,6 +703,7 @@ export function preprocessMathExpression(line: string) { processed = preprocessScales(processed) processed = preprocessAreaVolumeAliases(processed) processed = preprocessStackedUnits(processed) + processed = preprocessMultipliers(processed) processed = preprocessPhraseFunctions(processed) processed = preprocessFunctionSyntax(processed) processed = preprocessFunctionConversions(processed) diff --git a/src/renderer/composables/math-notebook/useMathEngine.ts b/src/renderer/composables/math-notebook/useMathEngine.ts index 648aa952..a958c171 100644 --- a/src/renderer/composables/math-notebook/useMathEngine.ts +++ b/src/renderer/composables/math-notebook/useMathEngine.ts @@ -25,7 +25,16 @@ import { export type { LineResult } from './math-engine/types' interface FormatDirective { - format: 'hex' | 'bin' | 'oct' | 'sci' | 'number' | 'dec' | 'fraction' | null + format: + | 'hex' + | 'bin' + | 'oct' + | 'sci' + | 'number' + | 'dec' + | 'fraction' + | 'multiplier' + | null expression: string } @@ -144,7 +153,10 @@ let math = createMathInstance(activeCurrencyRates) let activeLocale = 'en-US' let activeDecimalPlaces = 6 -const STRIP_UNIT_SUFFIXES: Record = { +const STRIP_UNIT_SUFFIXES: Record< + string, + 'number' | 'dec' | 'fraction' | 'multiplier' +> = { 'as number': 'number', 'to number': 'number', 'as decimal': 'dec', @@ -153,6 +165,8 @@ const STRIP_UNIT_SUFFIXES: Record = { 'to dec': 'dec', 'as fraction': 'fraction', 'to fraction': 'fraction', + 'as multiplier': 'multiplier', + 'to multiplier': 'multiplier', } function gcd(a: number, b: number): number { @@ -192,12 +206,16 @@ function detectStripUnitDirective(line: string): FormatDirective { } function detectFormatDirective(line: string): FormatDirective { - const formatMap: Record = { + const formatMap: Record< + string, + 'hex' | 'bin' | 'oct' | 'sci' | 'multiplier' + > = { 'in hex': 'hex', 'in bin': 'bin', 'in oct': 'oct', 'in sci': 'sci', 'in scientific': 'sci', + 'to multiplier': 'multiplier', } const lower = line.toLowerCase() @@ -217,6 +235,39 @@ function applyFormat( result: any, format: NonNullable, ): LineResult { + if (format === 'multiplier') { + let num: number + if (typeof result === 'number') { + num = result + } + else if ( + result + && typeof result === 'object' + && typeof result.toNumber === 'function' + ) { + try { + num = result.toNumber() + } + catch { + num = Number.NaN + } + } + else { + num = Number(result) + } + + if (Number.isNaN(num)) { + return { value: String(result), error: null, type: 'number' } + } + + return { + value: `${formatMathNumber(num, activeLocale, activeDecimalPlaces)}x`, + error: null, + type: 'number', + numericValue: num, + } + } + if (format === 'fraction') { let num: number if (typeof result === 'number') { From 7c153d0af9384b72866d1c1c69972590da58926d Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 05:55:33 +0300 Subject: [PATCH 09/15] feat(math): add speed, energy, frequency, power and other unit extensions New custom units: mph, km/h, knot, calorie, kcal, fathom, light year, microgram, bushel. Native math.js units verified: Hz/kHz/MHz/GHz, W/kW/hp, J/kWh, GiB/MiB/KiB, deg/rad, ns/ms, nm/um/dm. --- .../__tests__/useMathEngine.test.ts | 62 +++++++++++++++++++ .../math-notebook/math-engine/constants.ts | 43 +++++++++++++ .../math-notebook/math-engine/mathInstance.ts | 46 ++++++++++++++ .../math-notebook/math-engine/preprocess.ts | 4 +- 4 files changed, 154 insertions(+), 1 deletion(-) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 96ce20d3..362076d3 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -479,6 +479,68 @@ describe('unit conversion', () => { expect(result.value).toContain('gram') expectNumericClose('1 carat to gram', 0.2, 2) }) + + // Speed + it('60 mph to m/s', () => { + const result = evalLine('60 mph to m/s') + expect(result.type).toBe('unit') + expectNumericClose('60 mph to m/s', 26.82, 1) + }) + it('100 km/h to mph', () => { + const result = evalLine('100 km/h to mph') + expect(result.type).toBe('unit') + expectNumericClose('100 km/h to mph', 62.14, 1) + }) + it('10 knots to km/h', () => { + expectNumericClose('10 knots to kmh', 18.52, 1) + }) + + // Energy + it('1000 cal to J', () => { + expectNumericClose('1000 calorie to J', 4184, 0) + }) + it('1 kcal to calorie', () => { + expectNumericClose('1 kcal to calorie', 1000, 0) + }) + + // Maritime & Astro + it('1 fathom to m', () => { + expectNumericClose('1 fathom to m', 1.8288, 3) + }) + it('1 light year to km', () => { + const result = evalLine('1 light year to km') + expect(result.type).toBe('unit') + }) + + // Mass + it('1000 mcg to gram', () => { + expectNumericClose('1000 mcg to gram', 0.001, 4) + }) + + // Volume + it('1 bushel to liter', () => { + expectNumericClose('1 bushel to liter', 35.24, 1) + }) + + // Frequency (native math.js) + it('1 kHz to Hz', () => { + expectNumericClose('1 kHz to Hz', 1000, 0) + }) + + // Power (native math.js) + it('1 hp to W', () => { + expectNumericClose('1 hp to W', 745.7, 0) + }) + + // Data IEC (native math.js) + it('1 GiB to MiB', () => { + expectNumericClose('1 GiB to MiB', 1024, 0) + }) + + // Angle + it('90 deg to rad', () => { + expectNumericClose('90 deg to rad', 1.5708, 3) + }) }) describe('css units', () => { diff --git a/src/renderer/composables/math-notebook/math-engine/constants.ts b/src/renderer/composables/math-notebook/math-engine/constants.ts index 5dce01ac..27e73823 100644 --- a/src/renderer/composables/math-notebook/math-engine/constants.ts +++ b/src/renderer/composables/math-notebook/math-engine/constants.ts @@ -182,6 +182,40 @@ export const knownUnitTokens = new Set([ 'point', 'points', 'em', + 'mph', + 'kmh', + 'knot', + 'knots', + 'kn', + 'hz', + 'khz', + 'mhz', + 'ghz', + 'thz', + 'joule', + 'joules', + 'calorie', + 'calories', + 'cal', + 'kcal', + 'watt', + 'watts', + 'kilowatt', + 'kilowatts', + 'horsepower', + 'hp', + 'fathom', + 'fathoms', + 'ftm', + 'lightyear', + 'lightyears', + 'ly', + 'microgram', + 'micrograms', + 'mcg', + 'bushel', + 'bushels', + 'bsh', ...Object.keys(currencyWordNames), ]) @@ -290,6 +324,15 @@ export const HUMANIZED_UNIT_NAMES: Record< league: { singular: 'league', plural: 'leagues' }, are: { singular: 'are', plural: 'ares' }, carat: { singular: 'carat', plural: 'carats' }, + mph: { singular: 'mph', plural: 'mph' }, + kmh: { singular: 'km/h', plural: 'km/h' }, + knot: { singular: 'knot', plural: 'knots' }, + calorie: { singular: 'calorie', plural: 'calories' }, + kilocalorie: { singular: 'kilocalorie', plural: 'kilocalories' }, + fathom: { singular: 'fathom', plural: 'fathoms' }, + lightyear: { singular: 'light year', plural: 'light years' }, + microgram: { singular: 'microgram', plural: 'micrograms' }, + bushel: { singular: 'bushel', plural: 'bushels' }, } export const MONTH_NAME_TO_INDEX: Record = { diff --git a/src/renderer/composables/math-notebook/math-engine/mathInstance.ts b/src/renderer/composables/math-notebook/math-engine/mathInstance.ts index d2289e6f..5f51dfc9 100644 --- a/src/renderer/composables/math-notebook/math-engine/mathInstance.ts +++ b/src/renderer/composables/math-notebook/math-engine/mathInstance.ts @@ -117,6 +117,52 @@ export function createMathInstance(currencyRates: Record) { aliases: ['leagues'], }) + // Speed + createUnitSafe(mathInstance, 'mph', { + definition: '1 mile/hour', + aliases: [], + }) + createUnitSafe(mathInstance, 'kmh', { + definition: '1 km/hour', + aliases: [], + }) + createUnitSafe(mathInstance, 'knot', { + definition: '0.514444 m/s', + aliases: ['knots', 'kn'], + }) + + // Energy + createUnitSafe(mathInstance, 'calorie', { + definition: '4.184 J', + aliases: ['cal', 'calories'], + }) + createUnitSafe(mathInstance, 'kilocalorie', { + definition: '4184 J', + aliases: ['kcal', 'kCal', 'kilocalories'], + }) + + // Maritime & Astro + createUnitSafe(mathInstance, 'fathom', { + definition: '1.8288 m', + aliases: ['fathoms', 'ftm'], + }) + createUnitSafe(mathInstance, 'lightyear', { + definition: '9.461e15 m', + aliases: ['lightyears', 'ly'], + }) + + // Mass + createUnitSafe(mathInstance, 'microgram', { + definition: '1e-6 gram', + aliases: ['micrograms', 'mcg'], + }) + + // Volume + createUnitSafe(mathInstance, 'bushel', { + definition: '35.2391 liter', + aliases: ['bushels', 'bsh'], + }) + for (const [code, rate] of Object.entries(currencyRates)) { if (code === 'USD') continue diff --git a/src/renderer/composables/math-notebook/math-engine/preprocess.ts b/src/renderer/composables/math-notebook/math-engine/preprocess.ts index 4ddec806..19be179c 100644 --- a/src/renderer/composables/math-notebook/math-engine/preprocess.ts +++ b/src/renderer/composables/math-notebook/math-engine/preprocess.ts @@ -105,6 +105,8 @@ function preprocessUnitAliases(line: string): string { .replace(/\btea\s+spoons?\b/gi, 'teaspoon') .replace(/\btable\s+spoons?\b/gi, 'tablespoon') .replace(/\bnautical\s+miles?\b/gi, 'nauticalmile') + .replace(/\blight\s+years?\b/gi, 'lightyear') + .replace(/\bkm\/h\b/gi, 'kmh') } function preprocessAreaVolumeAliases(line: string): string { @@ -696,8 +698,8 @@ export function preprocessMathExpression(line: string) { processed = preprocessQuotedText(processed) processed = preprocessGroupedNumbers(processed) processed = preprocessDegreeSigns(processed) - processed = preprocessTimeUnits(processed) processed = preprocessUnitAliases(processed) + processed = preprocessTimeUnits(processed) processed = preprocessCurrencySymbols(processed) processed = preprocessCurrencyWords(processed) processed = preprocessScales(processed) From 8ca19d3e6b09003167f7ab70918795b7441292b3 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 05:58:12 +0300 Subject: [PATCH 10/15] feat(math): add reverse conversion syntax and shorthand unit conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reverse: `meters in 10 km` → 10,000 m, `days in 3 weeks` → 21 days - Shorthand: `km m` → 1,000 m (two unit names show conversion factor) - Verified: larger unit wins (1km + 1000m = 2km), area from mult (10m * 10m) --- .../__tests__/useMathEngine.test.ts | 25 +++++++++++++ .../math-notebook/math-engine/preprocess.ts | 35 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 362076d3..56dfa468 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -541,6 +541,31 @@ describe('unit conversion', () => { it('90 deg to rad', () => { expectNumericClose('90 deg to rad', 1.5708, 3) }) + + // Reverse conversion syntax + it('meters in 10 km', () => { + expectNumericClose('meters in 10 km', 10000, 0) + }) + it('days in 3 weeks', () => { + expectNumericClose('days in 3 weeks', 21, 0) + }) + + // Shorthand conversion + it('km m (shorthand)', () => { + expectNumericClose('km m', 1000, 0) + }) + + // Larger unit wins (native math.js) + it('1km + 1000m = 2 km', () => { + const result = evalLine('1 km + 1000 m') + expect(result.value).toContain('km') + }) + + // Area from multiplication (native math.js) + it('10m * 10m = area', () => { + const result = evalLine('10 m * 10 m') + expect(result.type).toBe('unit') + }) }) describe('css units', () => { diff --git a/src/renderer/composables/math-notebook/math-engine/preprocess.ts b/src/renderer/composables/math-notebook/math-engine/preprocess.ts index 19be179c..571f9130 100644 --- a/src/renderer/composables/math-notebook/math-engine/preprocess.ts +++ b/src/renderer/composables/math-notebook/math-engine/preprocess.ts @@ -100,6 +100,39 @@ function normalizePowerUnit(unit: string) { } } +function preprocessReverseConversion(line: string): string { + // "meters in 10 km" → "10 km to meters" + // "days in 3 weeks" → "3 weeks to days" + return line.replace( + /^([a-z]+)\s+in\s+(\d+(?:\.\d+)?)\s+([a-z]+)$/i, + (_, targetUnit: string, value: string, sourceUnit: string) => { + if ( + knownUnitTokens.has(targetUnit.toLowerCase()) + && knownUnitTokens.has(sourceUnit.toLowerCase()) + ) { + return `${value} ${sourceUnit} to ${targetUnit}` + } + return _ + }, + ) +} + +function preprocessShorthandConversion(line: string): string { + // "km m" → "1 km to m" (two unit names = show conversion factor) + return line.replace( + /^([a-z]+)\s+([a-z]+)$/i, + (_, unit1: string, unit2: string) => { + if ( + knownUnitTokens.has(unit1.toLowerCase()) + && knownUnitTokens.has(unit2.toLowerCase()) + ) { + return `1 ${unit1} to ${unit2}` + } + return _ + }, + ) +} + function preprocessUnitAliases(line: string): string { return line .replace(/\btea\s+spoons?\b/gi, 'teaspoon') @@ -696,6 +729,8 @@ export function hasCurrencyExpression(line: string) { export function preprocessMathExpression(line: string) { let processed = preprocessLabels(line) processed = preprocessQuotedText(processed) + processed = preprocessReverseConversion(processed) + processed = preprocessShorthandConversion(processed) processed = preprocessGroupedNumbers(processed) processed = preprocessDegreeSigns(processed) processed = preprocessUnitAliases(processed) From cfd16dd4908be0b56b168be826c40d773ed614b6 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 06:12:02 +0300 Subject: [PATCH 11/15] feat(math): add rate calculations with natural language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `per` keyword: `$99 per week` → $99/week - `at` phrase: `30 hours at $30/hour` → rate multiplication - `a day for a year` phrase: `$24 a day for a year` - Math.js compound units handle rate arithmetic natively --- .../__tests__/useMathEngine.test.ts | 15 ++++++++++++++ .../math-notebook/math-engine/preprocess.ts | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 56dfa468..4bd919a8 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -568,6 +568,21 @@ describe('unit conversion', () => { }) }) +describe('rates', () => { + it('$50/week * 12 weeks', () => { + const result = evalLine('$50/week * 12 weeks') + expect(result.type).toBe('unit') + }) + it('30 hours at $30/hour', () => { + const result = evalLine('30 hours at $30/hour') + expect(result.type).toBe('unit') + }) + it('$99 per week * 4 weeks', () => { + const result = evalLine('$99 per week * 4 weeks') + expect(result.type).toBe('unit') + }) +}) + describe('css units', () => { it('pt to px', () => expectNumericClose('12 pt in px', 16, 1)) it('pt into px', () => expectNumericClose('12 pt into px', 16, 1)) diff --git a/src/renderer/composables/math-notebook/math-engine/preprocess.ts b/src/renderer/composables/math-notebook/math-engine/preprocess.ts index 571f9130..eba0c06f 100644 --- a/src/renderer/composables/math-notebook/math-engine/preprocess.ts +++ b/src/renderer/composables/math-notebook/math-engine/preprocess.ts @@ -170,6 +170,25 @@ function preprocessAreaVolumeAliases(line: string): string { return line } +function preprocessRates(line: string): string { + return ( + line + // "X per Y" → "X / Y" (e.g. $99 per week → $99 / week) + .replace(/\bper\b/gi, '/') + // "X at Y/Z" where Y/Z is a rate → "X * Y / Z" + // "30 hours at $30/hour" → "30 hours * $30 / hour" + .replace( + /(\d+(?:\.\d+)?)\s+(\w+)\s+at\s+(\S+)\/(\w+)/gi, + '$1 $2 * $3 / $4', + ) + // "X a day for Y" → "X / day * Y" (e.g. $24 a day for a year) + .replace( + /(\S+)\s+a\s+(day|week|month|year)\s+for\s+(?:a\s+)?(\S+)/gi, + '$1 / $2 * 1 $3', + ) + ) +} + function preprocessMultipliers(line: string): string { return ( line @@ -740,6 +759,7 @@ export function preprocessMathExpression(line: string) { processed = preprocessScales(processed) processed = preprocessAreaVolumeAliases(processed) processed = preprocessStackedUnits(processed) + processed = preprocessRates(processed) processed = preprocessMultipliers(processed) processed = preprocessPhraseFunctions(processed) processed = preprocessFunctionSyntax(processed) From b08773e88cb667eda22f6e44960d2bc063d20a2e Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 06:16:21 +0300 Subject: [PATCH 12/15] feat(math): add calendar calculations module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - days since/till/until: `days since January 1`, `days till December 25` - days between: `days between March 1 and March 31` - Relative dates: `5 days from now`, `3 days ago` - Day of week: `day of the week on January 24, 1984` → Tuesday - Week numbers: `week of year`, `week number on March 12, 2021` - Days in period: `days in February 2020` → 29, `days in Q3` → 92 - Date offsets: `3 weeks after March 14, 2019`, `28 days before March 12` - Current timestamp: `current timestamp` --- .../__tests__/useMathEngine.test.ts | 81 +++++ .../math-notebook/math-engine/calendar.ts | 285 ++++++++++++++++++ .../math-notebook/useMathEngine.ts | 16 + 3 files changed, 382 insertions(+) create mode 100644 src/renderer/composables/math-notebook/math-engine/calendar.ts diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 4bd919a8..70cb63a9 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -1279,6 +1279,87 @@ describe('bitwise operations', () => { it('right shift', () => expectValue('16 >> 2', '4')) }) +describe('calendar calculations', () => { + it('days since a date', () => { + const result = evalLine('days since January 1') + expect(result.value).toContain('day') + expect(result.numericValue).toBeGreaterThan(0) + }) + + it('days till a date', () => { + const result = evalLine('days till December 25') + expect(result.value).toContain('day') + expect(result.numericValue).toBeGreaterThan(0) + }) + + it('days between two dates', () => { + const result = evalLine('days between March 1 and March 31') + expect(result.value).toBe('30 days') + expect(result.numericValue).toBe(30) + }) + + it('X from now', () => { + const result = evalLine('5 days from now') + expect(result.type).toBe('date') + }) + + it('X ago', () => { + const result = evalLine('3 days ago') + expect(result.type).toBe('date') + }) + + it('day of the week on date', () => { + const result = evalLine('day of the week on January 24, 1984') + expect(result.value).toBe('Tuesday') + }) + + it('weekday on date', () => { + const result = evalLine('weekday on March 9, 2024') + expect(result.value).toBe('Saturday') + }) + + it('week of year', () => { + const result = evalLine('week of year') + expect(result.numericValue).toBeGreaterThan(0) + expect(result.numericValue).toBeLessThanOrEqual(53) + }) + + it('week number on date', () => { + const result = evalLine('week number on March 12, 2021') + expect(result.numericValue).toBeGreaterThan(0) + }) + + it('days in February 2020 (leap)', () => { + const result = evalLine('days in February 2020') + expect(result.value).toBe('29 days') + }) + + it('days in February 2021 (non-leap)', () => { + const result = evalLine('days in February 2021') + expect(result.value).toBe('28 days') + }) + + it('days in Q3', () => { + const result = evalLine('days in Q3') + expect(result.value).toBe('92 days') + }) + + it('3 weeks after March 14, 2019', () => { + const result = evalLine('3 weeks after March 14, 2019') + expect(result.type).toBe('date') + }) + + it('28 days before March 12', () => { + const result = evalLine('28 days before March 12') + expect(result.type).toBe('date') + }) + + it('current timestamp', () => { + const result = evalLine('current timestamp') + expect(result.numericValue).toBeGreaterThan(1000000000) + }) +}) + describe('error handling', () => { it('invalid expression returns error', () => { const result = evalLine('hello world') diff --git a/src/renderer/composables/math-notebook/math-engine/calendar.ts b/src/renderer/composables/math-notebook/math-engine/calendar.ts new file mode 100644 index 00000000..b7beac0a --- /dev/null +++ b/src/renderer/composables/math-notebook/math-engine/calendar.ts @@ -0,0 +1,285 @@ +import type { LineResult } from './types' +import { MONTH_NAME_TO_INDEX } from './constants' +import { formatMathDate } from './format' + +interface CalendarResult { + lineResult: LineResult + rawResult: any +} + +function parseSimpleDate(text: string, now: Date): Date | null { + const trimmed = text.trim().replace(/,/g, '') + + if (/^today$/i.test(trimmed)) + return new Date(now.getFullYear(), now.getMonth(), now.getDate()) + if (/^tomorrow$/i.test(trimmed)) + return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) + if (/^yesterday$/i.test(trimmed)) + return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) + + const iso = trimmed.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/) + if (iso) + return new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])) + + const mdy = trimmed.match(/^([a-z]+)\s+(\d{1,2})(?:\s+(\d{4}))?$/i) + if (mdy) { + const month = MONTH_NAME_TO_INDEX[mdy[1].toLowerCase()] + if (month) { + const year = mdy[3] ? Number(mdy[3]) : now.getFullYear() + return new Date(year, month - 1, Number(mdy[2])) + } + } + + const dmy = trimmed.match(/^(\d{1,2})\s+([a-z]+)(?:\s+(\d{4}))?$/i) + if (dmy) { + const month = MONTH_NAME_TO_INDEX[dmy[2].toLowerCase()] + if (month) { + const year = dmy[3] ? Number(dmy[3]) : now.getFullYear() + return new Date(year, month - 1, Number(dmy[1])) + } + } + + return null +} + +const DAY_NAMES_REVERSE = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +] + +function daysBetween(a: Date, b: Date): number { + const msPerDay = 86400000 + return Math.round((b.getTime() - a.getTime()) / msPerDay) +} + +function daysResult(count: number): CalendarResult { + const abs = Math.abs(count) + return { + lineResult: { + value: `${abs} ${abs === 1 ? 'day' : 'days'}`, + error: null, + type: 'number', + numericValue: abs, + }, + rawResult: abs, + } +} + +function parseTimeUnit(text: string): string { + return text.toLowerCase().replace(/s$/, '') +} + +function applyDateOffset( + date: Date, + amount: number, + unit: string, + direction: number, +) { + if (unit === 'day') + date.setDate(date.getDate() + amount * direction) + else if (unit === 'week') + date.setDate(date.getDate() + amount * 7 * direction) + else if (unit === 'month') + date.setMonth(date.getMonth() + amount * direction) + else if (unit === 'year') + date.setFullYear(date.getFullYear() + amount * direction) +} + +function getWeekNumber(date: Date): number { + const startOfYear = new Date(date.getFullYear(), 0, 1) + const diff = date.getTime() - startOfYear.getTime() + return Math.ceil((diff / 86400000 + startOfYear.getDay() + 1) / 7) +} + +export function evaluateCalendarLine( + line: string, + now: Date, + locale: string, +): CalendarResult | null { + const lower = line.toLowerCase().trim() + + // "days since X" / "days till X" / "days until X" + const sinceTillIdx = lower.search(/^days\s+(since|till|until)\s/) + if (sinceTillIdx === 0) { + const keyword = lower.match(/^days\s+(since|till|until)/)![1] + const rest = line.slice(lower.indexOf(keyword) + keyword.length).trim() + const date = parseSimpleDate(rest, now) + if (date) { + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const diff + = keyword === 'since' + ? daysBetween(date, today) + : daysBetween(today, date) + return daysResult(diff) + } + } + + // "days between X and Y" + const betweenMatch = lower.match(/^days\s+between\s/) + if (betweenMatch) { + const rest = line.slice(betweenMatch[0].length) + const andIdx = rest.toLowerCase().lastIndexOf(' and ') + if (andIdx > 0) { + const d1 = parseSimpleDate(rest.slice(0, andIdx), now) + const d2 = parseSimpleDate(rest.slice(andIdx + 5), now) + if (d1 && d2) { + return daysResult(daysBetween(d1, d2)) + } + } + } + + // "X from now" / "X ago" + const relMatch = lower.match( + /^(\d+)\s+(days?|weeks?|months?|years?)\s+(from now|ago)$/, + ) + if (relMatch) { + const amount = Number(relMatch[1]) + const unit = parseTimeUnit(relMatch[2]) + const direction = relMatch[3] === 'ago' ? -1 : 1 + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + applyDateOffset(today, amount, unit, direction) + return { + lineResult: { + value: formatMathDate(today, locale), + error: null, + type: 'date', + }, + rawResult: today, + } + } + + // "day of the week on DATE" / "weekday on DATE" + const dowMatch = lower.match(/^(?:day of the week|weekday) on\s/) + if (dowMatch) { + const rest = line.slice(dowMatch[0].length) + const date = parseSimpleDate(rest, now) + if (date) { + const dayName = DAY_NAMES_REVERSE[date.getDay()] + return { + lineResult: { value: dayName, error: null, type: 'number' }, + rawResult: dayName, + } + } + } + + // "week of year" / "week number" + if (/^week of year$/.test(lower) || /^week number$/.test(lower)) { + const weekNumber = getWeekNumber( + new Date(now.getFullYear(), now.getMonth(), now.getDate()), + ) + return { + lineResult: { + value: String(weekNumber), + error: null, + type: 'number', + numericValue: weekNumber, + }, + rawResult: weekNumber, + } + } + + // "week number on DATE" + const weekOnMatch = lower.match(/^week number on\s/) + if (weekOnMatch) { + const rest = line.slice(weekOnMatch[0].length) + const date = parseSimpleDate(rest, now) + if (date) { + const weekNumber = getWeekNumber(date) + return { + lineResult: { + value: String(weekNumber), + error: null, + type: 'number', + numericValue: weekNumber, + }, + rawResult: weekNumber, + } + } + } + + // "days in MONTH YEAR" / "days in February 2020" + const daysInMonthMatch = lower.match(/^days in ([a-z]+)(?:\s+(\d{4}))?$/) + if (daysInMonthMatch) { + const month = MONTH_NAME_TO_INDEX[daysInMonthMatch[1]] + if (month) { + const year = daysInMonthMatch[2] + ? Number(daysInMonthMatch[2]) + : now.getFullYear() + const daysInMonth = new Date(year, month, 0).getDate() + return { + lineResult: { + value: `${daysInMonth} days`, + error: null, + type: 'number', + numericValue: daysInMonth, + }, + rawResult: daysInMonth, + } + } + } + + // "days in Q1/Q2/Q3/Q4" + const daysInQMatch = lower.match(/^days in q([1-4])(?:\s+(\d{4}))?$/) + if (daysInQMatch) { + const quarter = Number(daysInQMatch[1]) + const year = daysInQMatch[2] ? Number(daysInQMatch[2]) : now.getFullYear() + const startMonth = (quarter - 1) * 3 + let total = 0 + for (let i = 0; i < 3; i++) { + total += new Date(year, startMonth + i + 1, 0).getDate() + } + return { + lineResult: { + value: `${total} days`, + error: null, + type: 'number', + numericValue: total, + }, + rawResult: total, + } + } + + // "X after/before DATE" + const offsetMatch = lower.match( + /^(\d+)\s+(days?|weeks?|months?|years?)\s+(after|before)\s/, + ) + if (offsetMatch) { + const amount = Number(offsetMatch[1]) + const unit = parseTimeUnit(offsetMatch[2]) + const direction = offsetMatch[3] === 'after' ? 1 : -1 + const rest = line.slice(offsetMatch[0].length) + const date = parseSimpleDate(rest, now) + if (date) { + applyDateOffset(date, amount, unit, direction) + return { + lineResult: { + value: formatMathDate(date, locale), + error: null, + type: 'date', + }, + rawResult: date, + } + } + } + + // "current timestamp" + if (lower === 'current timestamp') { + const ts = Math.floor(now.getTime() / 1000) + return { + lineResult: { + value: String(ts), + error: null, + type: 'number', + numericValue: ts, + }, + rawResult: ts, + } + } + + return null +} diff --git a/src/renderer/composables/math-notebook/useMathEngine.ts b/src/renderer/composables/math-notebook/useMathEngine.ts index a958c171..3e366fa2 100644 --- a/src/renderer/composables/math-notebook/useMathEngine.ts +++ b/src/renderer/composables/math-notebook/useMathEngine.ts @@ -4,6 +4,7 @@ import type { LineResult, SpecialLineResult, } from './math-engine/types' +import { evaluateCalendarLine } from './math-engine/calendar' import { DEFAULT_EM_IN_PX, DEFAULT_PPI, @@ -921,6 +922,21 @@ export function useMathEngine() { continue } + const calendarResult = evaluateCalendarLine( + trimmed, + currentDate, + activeLocale, + ) + if (calendarResult) { + results.push(calendarResult.lineResult) + prevResult = calendarResult.rawResult + const numericValue = getNumericValue(calendarResult.rawResult) + if (numericValue !== null) { + numericBlock.push(numericValue) + } + continue + } + const cssResult = evaluateCssLine(trimmed, cssContext) if (cssResult) { scope.em = cssContext.emPx From dc3ecba7c2f796ebfcc9a329c982c82dca86c08e Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 06:18:12 +0300 Subject: [PATCH 13/15] fix(math): humanize mc-prefixed units in compound unit results $30 * 4 days now shows "120 USD days" instead of "120 USD mcday" --- .../composables/__tests__/useMathEngine.test.ts | 5 +++++ .../composables/math-notebook/useMathEngine.ts | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 70cb63a9..2d95c7b6 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -577,6 +577,11 @@ describe('rates', () => { const result = evalLine('30 hours at $30/hour') expect(result.type).toBe('unit') }) + it('compound units humanize mcday', () => { + const result = evalLine('$30 * 4 days') + expect(result.value).not.toContain('mcday') + expect(result.value).toContain('day') + }) it('$99 per week * 4 weeks', () => { const result = evalLine('$99 per week * 4 weeks') expect(result.type).toBe('unit') diff --git a/src/renderer/composables/math-notebook/useMathEngine.ts b/src/renderer/composables/math-notebook/useMathEngine.ts index 3e366fa2..346498ce 100644 --- a/src/renderer/composables/math-notebook/useMathEngine.ts +++ b/src/renderer/composables/math-notebook/useMathEngine.ts @@ -382,8 +382,13 @@ function applyFormat( } } +function humanizeUnitToken(unitId: string) { + const displayUnit = HUMANIZED_UNIT_NAMES[unitId] + return displayUnit ? displayUnit.plural : unitId +} + function humanizeFormattedUnits(value: string) { - return value.replace( + let result = value.replace( /(-?\d[\d,]*(?:\.\d+)?)\s+([a-z][a-z0-9]*)\b/gi, (match, amountText: string, unitId: string) => { const numericAmount = Number.parseFloat(amountText.replace(/,/g, '')) @@ -407,6 +412,16 @@ function humanizeFormattedUnits(value: string) { return `${formattedAmount} ${unitLabel}` }, ) + + // Humanize remaining standalone unit tokens (e.g. in compound units like "USD mcday") + result = result.replace( + /\b(mc(?:second|minute|hour|day|week|month|year))\b/g, + (_, unitId: string) => { + return humanizeUnitToken(unitId) + }, + ) + + return result } function formatResult(result: any): LineResult { From d7215e7f917254d70a9771e70c94ce9d02588172 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 06:19:45 +0300 Subject: [PATCH 14/15] =?UTF-8?q?fix(math):=20grouped=20numbers=20with=20u?= =?UTF-8?q?nit=20suffix=20(1=20000m=20=E2=86=92=201000m)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grouped number regex now uses lookahead instead of word boundary at the end, so "1 000m" correctly groups to "1000m" when a unit follows without a space. --- src/renderer/composables/__tests__/useMathEngine.test.ts | 5 +++++ .../composables/math-notebook/math-engine/preprocess.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 2d95c7b6..9ad530e6 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -55,6 +55,11 @@ describe('arithmetic', () => { it('complex expression', () => expectValue('2 + 3 * 4 - 1', '13')) it('implicit multiplication', () => expectValue('6 (3)', '18')) it('grouped thousands', () => expectValue('5 300', '5,300')) + it('grouped thousands with unit suffix', () => { + const result = evalLine('1km + 1 000m') + expect(result.type).toBe('unit') + expect(result.value).toContain('km') + }) }) describe('rounding', () => { diff --git a/src/renderer/composables/math-notebook/math-engine/preprocess.ts b/src/renderer/composables/math-notebook/math-engine/preprocess.ts index eba0c06f..0cb511ce 100644 --- a/src/renderer/composables/math-notebook/math-engine/preprocess.ts +++ b/src/renderer/composables/math-notebook/math-engine/preprocess.ts @@ -44,7 +44,7 @@ function sanitizeForCurrencyDetection(line: string) { } function preprocessGroupedNumbers(line: string): string { - let result = line.replace(/\b\d{1,3}(?:\s\d{3})+\b/g, match => + let result = line.replace(/\b\d{1,3}(?:\s\d{3})+(?=\b|[a-z])/gi, match => match.replace(/\s+/g, '')) result = result.replace(/\b\d+(?:\s+\d+)+\b/g, match => From a3f8c8ef3c1c3fd5977ba910f1f2852eba63b226 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Sun, 29 Mar 2026 06:22:05 +0300 Subject: [PATCH 15/15] =?UTF-8?q?fix(math):=20simplify=20implicit=20rate?= =?UTF-8?q?=20results=20(currency=20*=20time=20=E2=86=92=20currency)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $30 * 4 days now returns "120 USD" instead of "120 USD days". When multiplying currency by time, the result is treated as an implicit rate and the time unit is removed from the output. --- .../__tests__/useMathEngine.test.ts | 6 +-- .../math-notebook/useMathEngine.ts | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 9ad530e6..de3a99d2 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -582,10 +582,10 @@ describe('rates', () => { const result = evalLine('30 hours at $30/hour') expect(result.type).toBe('unit') }) - it('compound units humanize mcday', () => { + it('implicit rate: $30 * 4 days = $120', () => { const result = evalLine('$30 * 4 days') - expect(result.value).not.toContain('mcday') - expect(result.value).toContain('day') + expect(result.value).toBe('120 USD') + expect(result.type).toBe('unit') }) it('$99 per week * 4 weeks', () => { const result = evalLine('$99 per week * 4 weeks') diff --git a/src/renderer/composables/math-notebook/useMathEngine.ts b/src/renderer/composables/math-notebook/useMathEngine.ts index 346498ce..ce8e98ce 100644 --- a/src/renderer/composables/math-notebook/useMathEngine.ts +++ b/src/renderer/composables/math-notebook/useMathEngine.ts @@ -9,6 +9,7 @@ import { DEFAULT_EM_IN_PX, DEFAULT_PPI, HUMANIZED_UNIT_NAMES, + SUPPORTED_CURRENCY_CODES, } from './math-engine/constants' import { evaluateCssLine } from './math-engine/css' import { formatMathDate, formatMathNumber } from './math-engine/format' @@ -447,6 +448,47 @@ function formatResult(result: any): LineResult { && typeof result.toNumber === 'function' && result.units ) { + // Detect compound currency*time units (implicit rate result) and simplify + if (Array.isArray(result.units) && result.units.length === 2) { + const units = result.units as Array<{ + unit: { name: string, value: number, base?: { key?: string } } + prefix: { value: number } + power: number + }> + const currencyUnit = units.find( + u => + u.unit.base?.key?.includes('STUFF') + || SUPPORTED_CURRENCY_CODES.includes(u.unit.name), + ) + const timeUnit = units.find(u => u.unit.base?.key === 'TIME') + if ( + currencyUnit + && timeUnit + && currencyUnit.power === 1 + && timeUnit.power === 1 + ) { + try { + const rawValue = result.value as number + const currencyScale + = currencyUnit.unit.value * currencyUnit.prefix.value + const timeScale = timeUnit.unit.value * timeUnit.prefix.value + const currencyAmount + = (rawValue / (currencyScale * timeScale)) * currencyScale + const simplified = math.unit(currencyAmount, currencyUnit.unit.name) + return { + value: humanizeFormattedUnits( + math.format(simplified, { precision: activeDecimalPlaces }), + ), + error: null, + type: 'unit', + } + } + catch { + // Fall through to default formatting + } + } + } + return { value: humanizeFormattedUnits( math.format(result, { precision: activeDecimalPlaces }),