quickmathjs
Version:
A simple web-based plaintext calculator harnessing the power of math.js for intuitive free-form calculations. Now with a command-line interface (CLI) for testing purposes.
869 lines (768 loc) • 49.5 kB
JavaScript
/*
QuickMathsJS-WebCalc An intuitive web-based calculator using math.js. Features inline results and supports free-form calculations.
Copyright (C) 2023 Brian Khuu
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Universal module definition (UMD) for webcalc
(function (global, factory) {
if (typeof exports === 'object' && typeof module !== 'undefined') {
// Node/CommonJS
module.exports = factory;
} else {
// Browser Globals
global.webcalc = factory(global.math);
}
}(typeof self !== 'undefined' ? self : this, function (mathInstance) {
const math = mathInstance;
const calculator = {
// These are used as statistics and for checking if a new user unfamilar
// with QuickMathJS syntax is using it. As they would usually just type
totalCalculations: 0, // Number of lines where calculations are performed
totalSoloExpressions: 0, // Number of lines with potential expressions entered
totalResultsProvided: 0, // Number of lines with results provided
// Unit Expanded Name Representations (e.g. USD exc tax)
unitNameExpansion: {}, // Mapping between unit names and it's expanded form if needed
initialise() {
// Load at least all known currency
// Note: While you can implicity declare your own custom units on assignments,
// there are cases where you would not use assignment but still need these units...
// hence I will now be including it in again by default to avoid this annoyance.
// Note: In the future we could try allowing for people to use their own API key from fixer.io
// and use this example https://mathjs.org/examples/browser/currency_conversion.html to load all the
// latest currency conversion rate as needed.
const currencies = [
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN',
'BAM', 'BBD', 'BDT', 'BGN', 'BHD', 'BIF', 'BMD', 'BND', 'BOB', 'BRL',
'BSD', 'BTC', 'BTN', 'BWP', 'BYN', 'BYR', 'BZD', 'CAD', 'CDF', 'CHF',
'CLF', 'CLP', 'CNY', 'COP', 'CRC', 'CUC', 'CUP', 'CVE', 'CZK', 'DJF',
'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR', 'FJD', 'FKP', 'GBP',
'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL',
'HRK', 'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'IRR', 'ISK',
'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR', 'KMF', 'KPW', 'KRW',
'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LTL', 'LVL',
'LYD', 'MAD', 'MDL', 'MGA', 'MKD', 'MMK', 'MNT', 'MOP', 'MRO', 'MUR',
'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK', 'NPR',
'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR',
'RON', 'RSD', 'RUB', 'RWF', 'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD',
'SHP', 'SLE', 'SLL', 'SOS', 'SSP', 'SRD', 'STD', 'SYP', 'SZL', 'THB',
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TWD', 'TZS', 'UAH', 'UGX',
'USD', 'UYU', 'UZS', 'VEF', 'VES', 'VND', 'VUV', 'WST', 'XAF', 'XAG',
'XAU', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMK', 'ZMW', 'ZWL'
];
currencies.forEach(currency => {
const extraCurrencyRepresentations = [
`${currency} inc tax`,
`${currency} exc tax`,
`${currency} inc gst`,
`${currency} exc gst`];
// Register Base Currency Representation
if (!math.Unit.isValuelessUnit(currency)) {
math.createUnit(currency);
}
// Register Alt Currency Representation
extraCurrencyRepresentations.forEach(extraCurrencyUnit => {
// Remove spaces as mathjs only support alphabet names for units
const nomalisedUnitName = extraCurrencyUnit.replace(/\s+/g, '');
if (!math.Unit.isValuelessUnit(nomalisedUnitName)) {
math.createUnit(nomalisedUnitName);
// Link these alternative representations together
// Note: Can still be overridden later on if flat tax pair ratio can be used
math.createUnit(nomalisedUnitName, `1 ${currency}`, {override: true});
// Register expanded name so it renders correctly in output
this.captureUnitExpandedRepresentation(nomalisedUnitName, extraCurrencyUnit);
}
})
});
},
// Count occurrences of \r\n and \n and determine reasonable eol setting
pick_typical_eol(source) {
var crlfCount = (source.match(/\r\n/g) || []).length;
var lfCount = (source.match(/(?<!\r)\n/g) || []).length;
return (crlfCount > lfCount) ? "\r\n" : "\n";
},
// Function to calculate content with math sections
calculateWithMathSections(incomingContent, eol=null) {
// Regular expression to match ```calc``` sections with potential attributes
if (!eol) {
eol = this.pick_typical_eol(incomingContent);
}
const mathSectionRegex = /^```calc(.*?)\r?\n([\s\S]+?)\r?\n^```$/gm;
return incomingContent.replace(mathSectionRegex, (match, attributes, mathContent) => {
const result = this.calculate(mathContent, eol);
return '```calc' + attributes + eol + result + eol + '```';
});
},
captureUnitExpandedRepresentation(normalisedPairRatio, expandedPairRatio) {
if ((normalisedPairRatio === expandedPairRatio) && !this.unitNameExpansion.hasOwnProperty(normalisedPairRatio))
return;
this.unitNameExpansion[normalisedPairRatio] = expandedPairRatio;
},
replaceWithUnitExpandedRepresentation(str) {
// Split into tokens by spaces
// Always convert to string as this is only going to be used for output formatting
const words = str.toString().split(/\s+/);
// Replace tokens with expanded representations
const replacedWords = words.map(word => {
// Check if the word is a unit token and has an expanded representation
if (this.unitNameExpansion.hasOwnProperty(word)) {
return this.unitNameExpansion[word];
} else {
return word; // Keep the original word
}
});
// Join the replaced words back into a string
const replacedString = replacedWords.join(' ');
return replacedString;
},
// Function to calculate content without math sections
calculate(incomingContent, eol=null) {
/**
* Throw error if MathJS is not loaded
*/
if (!math) {
throw new Error("QuickMathsJS-WebCalc: 'mathjs' is required. Please ensure 'mathjs' is loaded before using this module.");
}
/**
* If preferred eol not provided then determine eol via heuristic
*/
if (!eol) {
eol = this.pick_typical_eol(incomingContent);
}
/**
* Convert natural language math expressions into a format suitable for math.js.
* The function combines words into variable names using underscores while preserving
* valid math.js expressions and keywords.
*
* @param {string} line - The input math expression in natural language.
* @returns {string} - Transformed expression suitable for math.js evaluation.
*/
function convertNaturalMathToMathJsSyntax(line) {
// Add spaces after '(' and before ')'
line = line.replace(/(\()|(\))/g, match => match === '(' ? `${match} ` : ` ${match}`);
// Add spaces after '[' and before ']'
line = line.replace(/(\[)|(\])/g, match => match === '[' ? `${match} ` : ` ${match}`);
// Add spaces after '{' and before '}'
line = line.replace(/(\{)|(\})/g, match => match === '{' ? `${match} ` : ` ${match}`);
// Add spaces around '/' to make it easier to detect variable phrases and compound units
line = line.replace(/\//g, ' / ');
// Add spaces around '*' to make it easier to detect variable phrases and compound units
line = line.replace(/\*/g, ' * ');
// Split the line into tokens
const tokens = line.split(/\s+/);
const transformedTokens = [];
let buffer = [];
function joinTokens(input_buffer) {
// Scan these series of words in case it is representing a compounded unit e.g. 'kg m'
// If we have a string like 'steering degree' we can tell it's not a compounded unit even though 'degree'
// is a unit because 'steering' is not a known unit, so by context the whole thing is actually a variable.
const compounded_unit = input_buffer.every(token => math.Unit.isValuelessUnit(token));
if (compounded_unit)
{
// Is an implicit compounded unit
// Do not normalise any tokens found so far in the buffer
return input_buffer.join(' ');
}
else
{
// There is some non unit tokens here
// Let's normalise any token we found so far in the buffer
return input_buffer.join('');
}
}
tokens.forEach((token, index) => {
let normaliseThisToken = true;
// Check if token is alphabetic
if (/^[a-zA-Z]+$/.test(token)) {
// Token consist of only alphabet characters
// These keywords have special meanings in math.js
// and may represent implicit functions like '<word> mod <word>'
// which cannot be easily managed using normal operators like '+' or '*'.
const reservedKeywords = ['in', 'to', 'mod', 'not', 'and', 'xor', 'or'];
// If the current token is in the list of reserved keywords, we do not want to normalize it.
if (reservedKeywords.includes(token)) {
normaliseThisToken = false;
}
} else {
// Not Alphabet so no need to merge these token
// e.g. might be an operator or function
normaliseThisToken = false;
}
// Process each token based on if it should be normalised or not
if (normaliseThisToken) {
// Normalise these tokens so save for later
buffer.push(token);
} else {
// Don't normalise this token
if (buffer.length) {
// Let's normalise any token we found so far
transformedTokens.push(joinTokens(buffer));
buffer = [];
}
transformedTokens.push(token);
}
// If it's the last token, clear the buffer
if (index === tokens.length - 1 && buffer.length) {
transformedTokens.push(joinTokens(buffer));
}
});
return transformedTokens.join(' ');
}
/**
* Determines if the provided string represents an empty slot or a placeholder
* @param {string} str - The string to be checked.
* @returns {boolean} - True if the string is empty, contains only whitespace, or is a '?', otherwise false.
*/
function isEmpty(str) {
return str.trim() === '' || str.trim() === '?';
}
/**
* Determines if a given string can be parsed as a symbolic name (i.e., a variable)
* without throwing an error using the `math.js` library.
* @param {string} str - The string to be checked.
* @returns {boolean} - True if the string is a valid variable name, otherwise false.
*/
function isVariable(str) {
try {
const normalisedStr = convertNaturalMathToMathJsSyntax(str);
const node = math.parse(normalisedStr);
return node.isSymbolNode == true;
} catch (e) {
return false;
}
}
/**
* Determines if a given string represents a valid numerical result.
* The function checks for standard numeric representations as well as implicit multiplications
* between constants and symbols (e.g., "2m" which is internally "2 * m").
*
* Developer Notes:
* - The function does not rely solely on math.parse() and node.isConstantNode to determine
* if a string represents a constant due to issues with math.js' handling of certain expressions.
* - Instead, a combination of the mathjs parser and specific checks for node types is utilized.
* This approach was found to be more reliable and versatile for the intended use case.
* - Special handling is incorporated for certain cases:
* 1. Empty strings are treated distinctly as they are parsed as constants by mathjs.
* 2. Strings representing variables (e.g., 'number_of_cats') are excluded.
* 3. Expressions involving operations (e.g., 2.1 + 3.0) are excluded.
*
* @param {string} str - The string to be checked.
* @param {boolean} customUnitDeclarationMode - Ignore unit check, we will allow for declaring new units
* @returns {boolean} - True if the string is a valid result, otherwise false.
*/
function isOutputResult(str, customUnitDeclarationMode=false) {
try {
const normalisedStr = convertNaturalMathToMathJsSyntax(str);
const node = math.parse(normalisedStr);
// Check if string is considered empty
// Note: Internally mathjs parser considers empty string as constant
// hence we need a specific check for this
if (isEmpty(str)) {
return false;
}
/*
// Expression trees : https://mathjs.org/docs/expressions/expression_trees.html
// FunctionNode sqrt
// |
// OperatorNode +
// / \
// ConstantNode 2 x SymbolNode
//
// What we care about here to check if this is a pure result is to traverse
// this structure and check if:
// - No Function Node as that is always evaluated into a result
// - Operator Node : '*' or '/' is okay... but '-' and '+' is not
// - '*' must be implicit as
// - Constant Node is optional
// - SymbolNode is okay as long as confirmed to be a unit via math.Unit.isValuelessUnit()
*/
// Specific check here
if (node.isSymbolNode) {
// A single symbol here might be a valid result as a solo unit e.g. evaluate('km') --> 'km'
// but this will cause an syntax edge case with detecting assignment
// because of the ambiguity between a unit and variable.
// This will remain the case unless we increase the complexity of the heuristic.
// To keep things simple... lets just assume invalid if just symbol by itself.
// in most context this will be correct as normal people wont just write 'km'.
// e.g. is the 'b' in 'b = 2'
return false;
}
/**
* Recursively checks if a Math.js node represents a valid result.
* @param {MathNode} node - The current Math.js node to check.
* @returns {boolean} - True if the node represents a valid result, otherwise false.
*/
function checkNode(node) {
if (node.isSymbolNode) {
// Check if name is object placeholder result '[object Object]'
if (node.name == 'object' || node.name == 'Object')
return true;
// Skip unit check, since we will be declaring new units
if (customUnitDeclarationMode)
return true;
// Check if the symbol node represents a unit
// if not a unit, then it may be a variable which is not a result
return math.Unit.isValuelessUnit(node.name);
}
if (node.isConstantNode) {
// Constants is part of result (e.g., 0b1010, 0x2234, 23.3)
return true;
}
if (node.isFunctionNode) {
// Solo Functions is not found in result (e.g. sin() )
return false;
}
if (node.isOperatorNode) {
// Check for implicit multiplication or explicit division between constants and unit symbols
// Implicit multiplication and explicit division will always have two arguments so can be assumed
if (node.implicit) {
return node.args.every(checkNode);
}
// Check for addition or subtraction operators
if (node.op === '+' || node.op === '-' || node.op === '*') {
if (node.fn === "unaryMinus"){
return node.args.every(checkNode);
}
return false; // Reject expressions with addition or subtraction
}
// Check if '^' is being used as part of unit definition
if (node.op === '^') {
const [arg1, arg2] = node.args;
// Check if only constants here e.g. 2^2 is invalid
if (arg1.isConstantNode && arg2.isConstantNode)
return false;
// Check if child is okay and power is constant value e.g. m^2 is valid
if (checkNode(arg1) && arg2.isConstantNode)
return true;
// Assume all other forms as invalid e.g. ?^km is invalid
return false;
}
// Recursively check the children nodes
return node.args.every(checkNode);
}
// Array Nodes Has a specific handling required
// but just check all members is valid
if (node.isArrayNode){
return node.items.every(checkNode);
}
// Unhandled, but just check all members is valid
return node.args.every(checkNode);
}
return checkNode(node);
} catch (e) {
return false;
}
}
/**
* Determines if a given string can be parsed as a basic arithmetic expression,
* which includes operator nodes, function nodes, or parenthesis nodes,
* using the `math.js` library.
* @param {string} str - The string to be checked.
* @returns {boolean} - True if the string is a basic arithmetic expression, otherwise false.
*/
function isExpression(str) {
const normalisedStr = convertNaturalMathToMathJsSyntax(str);
try {
const node = math.parse(normalisedStr);
return (node.isConditionalNode ||
node.isAccessorNode ||
node.isArrayNode ||
node.isAssignmentNode ||
node.isBlockNode ||
node.isFunctionAssignmentNode ||
node.isFunctionNode ||
node.isIndexNode ||
node.isObjectNode ||
node.isOperatorNode ||
node.isParenthesisNode ||
node.isRangeNode) == true;
} catch (e) {
return false;
}
}
/**
* Determines if a given string can be parsed as a function call
* (e.g., f(x,y,z) or sin(x) ) using the `math.js` library.
* @param {string} str - The string to be checked.
* @returns {boolean} - True if the string is a function call, otherwise false.
*/
function isFunctionCall(str) {
const normalisedStr = convertNaturalMathToMathJsSyntax(str);
try {
const node = math.parse(normalisedStr);
return node.isFunctionNode;
} catch (e) {
return false;
}
}
/**
* Checks if a given string conforms to the valid ratio definition format of `XXX / YYY`.
*
* @param {string} str - The string to be validated.
* @param {object} scope - The current math.js scope containing defined variables.
* @returns {boolean} - True if the string matches the valid ratio definition format, otherwise false.
*
* This function was originally designed to support currency pair definitions like `XXX / YYY` or `XXX/YYY`.
* It has been extended to support longer unit identifiers, such as `EUR/EURincGST`.
* Note: In math.js, only alphanumeric characters are allowed in unit definitions.
*
* The function also checks against the provided scope to ensure that the base and quote from the ratio definition
* are not already defined as variables. This is to prevent overwriting existing variables or
* inadvertently using a variable name as a unit identifier.
*/
function isValidPairRatioDefinition(str, scope) {
const normalisedStr = convertNaturalMathToMathJsSyntax(str).trim();
// Regular expression pattern to validate the ratio definition format
const ratioDefinitionRegex = /^[A-Za-z]+ *\/ *[A-Za-z]+$/i;
// If the string doesn't match the expected pattern, return false
if (!ratioDefinitionRegex.test(normalisedStr.trim()))
return false;
const parts = normalisedStr.split('/');
if (parts.length !== 2)
return false;
// Check if either the base or quote is already defined in the provided scope as a variable
const pairRatioBase = parts[0].trim();
const pairRatioQuote = parts[1].trim();
//console.log(`Base unit: ${pairRatioBase}`);
//console.log(`Quote unit: ${pairRatioQuote}`);
if (pairRatioBase in scope || pairRatioQuote in scope)
return false;
// If all checks pass, return true
return true;
}
/**
* Captures the unit string from the end of the input string.
* @param {string} inputString - The input string containing possible units.
* @returns {string[]|null} - An array of captured unit strings or null if none are found.
*
* Design Note: This function employs specific checks to avoid inadvertent matches.
*/
function captureUnitsFromString(inputString) {
function checkAndStrip(regex, inputString) {
if (!(regex.test(inputString)))
return null;
return inputString.replace(regex, '').trim();
}
function checkAndStripAll(inputString) {
let outputString;
// Strip Matrices
outputString = checkAndStrip(/^\[.*?\]/, inputString);
if (outputString !== null) return outputString;
// Strip Binary
outputString = checkAndStrip(/^0[bB][01]+\s*/, inputString);
if (outputString !== null) return outputString;
// Strip Hex
outputString = checkAndStrip(/^0[xX][0-9a-fA-F]+\s*/, inputString);
if (outputString !== null) return outputString;
// Strip Octal
outputString = checkAndStrip(/^0[oO][0-7]+\s*/, inputString);
if (outputString !== null) return outputString;
// Strip Numerical (int, float, exponent)
outputString = checkAndStrip(/^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/, inputString);
if (outputString !== null) return outputString;
// No matches found anymore, no supported values present
return null;
}
// Strip known value patterns from the input to isolate possible unit strings
unitRawString = checkAndStripAll(inputString);
if (unitRawString === null)
return null;
// Check if the remaining string has recognizable units
// The string can have alphanumerics, '/', '^' followed by integers
if (!(/^[a-zA-Z\/ ]+(\^-?\d+[a-zA-Z\/ ]*)*$/).test(unitRawString))
return null;
// Replace power notations (like ^2 or ^-3) with '/', which will be split later
unitRawString = unitRawString.replace(/\^-?\d+/g, '/');
// Split by '/', resulting in individual units
let units = unitRawString.split(/\//).map(unit => unit.trim()).filter(unit => unit !== '');
// Retain only units that are purely alphabetical or spaces
units = units.filter(unit => /^[a-zA-Z ]+$/.test(unit));
return units;
}
/**
* Captures and processes a unit assignment from the input string.
* @param {string} inputString - The input string containing a unit assignment.
*/
const assignmentUnitCapture = (inputString) => {
// Capture the full unit string from the input
// Note: Exit if assignment is missing value, as we expect a value if an assignment is performed.
const unitsArray = captureUnitsFromString(inputString);
if (!unitsArray || unitsArray.length === 0)
return;
// Process each unit captured
unitsArray.forEach(unitString => {
// For strings like 'steering degree', if 'degree' is a recognized unit but 'steering' isn't,
// then the entire string 'steering degree' can be treated as a variable instead of a compound unit.
// Double check that this isn't the case
const tokens = unitString.split(/\s+/);
const compounded_unit = tokens.every(token => math.Unit.isValuelessUnit(token));
if (compounded_unit)
return;
// Normalize the unit name for consistent representation and math.js compatibility
const normalizedUnitName = convertNaturalMathToMathJsSyntax(unitString);
// Check and create the base unit if it doesn't exist
if (!math.Unit.isValuelessUnit(normalizedUnitName)) {
math.createUnit(normalizedUnitName);
}
// Capture the unit name expansion
this.captureUnitExpandedRepresentation(normalizedUnitName, unitString);
});
};
/**
* Determines the effective indentation length of a line.
* Each tab (\t) is considered equivalent to 4 spaces.
*
* @param {string} line - The line whose indentation is to be determined.
* @return {number} The effective indentation length.
*/
function determineIndentation(line) {
// Extract the leading whitespace using a regex match
const leadingWhitespace = line.match(/^(\s*)/)[0];
// Iterate over each character in the leading whitespace:
// - Add 4 for each tab character
// - Add 1 for each space character
return leadingWhitespace.split('').reduce((acc, char) => {
return acc + (char === '\t' ? 4 : 1);
}, 0);
}
function math_evaluate(str, scope) {
const normalisedStr = convertNaturalMathToMathJsSyntax(str).trim();
//console.log(`evaluating '${normalisedStr}'`)
return math.evaluate(normalisedStr, scope)
}
// Preprocess to remove any existing "Error:"
const cleanedLines = incomingContent.split(eol).map(line => {
const errorIndex = line.indexOf("Error:");
return errorIndex !== -1 ? line.substring(0, errorIndex).trimRight() : line;
});
const lines = cleanedLines.join(eol).split(eol);
let newContent = "";
this.totalCalculations = 0;
this.totalSoloExpressions = 0;
this.totalResultsProvided = 0;
// `scope` will be used to keep track of variable values as they're declared
let scope = {};
let lastEvaluatedAnswer = null;
let lastUnevaluatedLine = null;
// Iterate through each line in the textarea
let index = 0;
for (const line of lines) {
try {
const indentationLevel = determineIndentation(line);
const trimmedLine = line.trim();
//console.log("indentationLevel: "+indentationLevel+ ` : "${line}"` )
// Skip lines that are comments or empty
if (trimmedLine === '' || trimmedLine.startsWith('#')) {
// Empty Lines or Comments
newContent += `${line}`;
} else {
// Split each line by '=' to determine its structure
const parts = line.split(/(?<!\=)\=(?!\=)/);
// This is the last two part of the line used for heuristic matching of expression type
const lastTwoParts = parts.slice(-2).map(str => str.trim());
leftPart = lastTwoParts[0];
rightPart = lastTwoParts[1];
// A version of the line but where all part of the expression except for the last part is kept
// This will be used if the last part is replaced with the result
const allButLast = parts.slice(0, -1).join('=');
//console.log("parts:" + parts)
//console.log("leftPart:" + leftPart)
//console.log("rightPart:" + rightPart)
//console.log("allButLast:" + allButLast)
//console.log("MathJS Syntax:", convertNaturalMathToMathJsSyntax(line));
//console.log(`(leftPart) isOutputResult:${ isOutputResult(leftPart)}, isExpression:${ isExpression(leftPart)}, isVariable: ${ isVariable(leftPart)} :: ${leftPart} `);
//console.log(`(rightPart) isOutputResult:${isOutputResult(rightPart)}, isExpression:${isExpression(rightPart)}, isVariable: ${isVariable(rightPart)} :: ${rightPart}`);
// Handling lines with minimum of one '='
if (parts.length >= 2) {
this.totalCalculations++;
//console.log("Initial Scope:", scope);
if (isValidPairRatioDefinition(leftPart, scope) && (isOutputResult(rightPart) || isEmpty(rightPart))) {
const quotation = math.evaluate(rightPart).toString(); // Convert the quotation string to a float
const parts = leftPart.split('/');
if (!isNaN(quotation) && (parts.length === 2))
{
// Check if either the base or quote is already defined in the provided scope as a variable
const pairRatioBase = parts[0].trim();
const pairRatioQuote = parts[1].trim();
const normalisedPairRatioBase = convertNaturalMathToMathJsSyntax(pairRatioBase);
const normalisedPairRatioQuote = convertNaturalMathToMathJsSyntax(pairRatioQuote);
//console.log(`Base unit: ${normalisedPairRatioBase}`);
//console.log(`Quote unit: ${normalisedPairRatioQuote}`);
//console.log(`unit quote: ${quotation}`);
// Check and create base currency if it doesn't exist
if (!math.Unit.isValuelessUnit(normalisedPairRatioBase)) {
math.createUnit(normalisedPairRatioBase);
}
// Update or create the quote currency
math.createUnit(normalisedPairRatioQuote, `${quotation} ${normalisedPairRatioBase}`, {override: true});
// Capture unit name expansion
this.captureUnitExpandedRepresentation(normalisedPairRatioBase, pairRatioBase);
this.captureUnitExpandedRepresentation(normalisedPairRatioQuote, pairRatioQuote);
}
newContent += `${allButLast}= ${rightPart}`;
} else if (isExpression(leftPart) && (isOutputResult(rightPart) || isEmpty(rightPart) || (!isExpression(rightPart) && !isVariable(leftPart)))) {
// Case: Pure Mathematical Expression (e.g., "5 + 3 = 8" or "5 + 3 =" or "5 + 3 = <corrupted output>")
//console.log("Pure Mathematical Expression:", line);
lastEvaluatedAnswer = math_evaluate(allButLast, scope);
this.totalResultsProvided++;
// Error handling for Infinity. Possible Division by zero, as JavaScript will return Infinity
if (lastEvaluatedAnswer === Infinity) {
throw new Error("Infinity. Possible Division by zero");
}
newContent += `${allButLast}= ${this.replaceWithUnitExpandedRepresentation(lastEvaluatedAnswer)}`;
} else if (isOutputResult(leftPart) && (isOutputResult(rightPart) || isEmpty(rightPart))) {
// Case: Direct constants (e.g., "0b1100100 =")
//console.log("Case: Direct constants:", line);
lastEvaluatedAnswer = math_evaluate(allButLast, scope);
this.totalResultsProvided++;
// Error handling for Infinity. Possible Division by zero, as JavaScript will return Infinity
if (lastEvaluatedAnswer === Infinity) {
throw new Error("Infinity. Possible Division by zero");
}
newContent += `${allButLast}= ${this.replaceWithUnitExpandedRepresentation(lastEvaluatedAnswer)}`;
} else if (isVariable(leftPart) && (isExpression(rightPart) || isOutputResult(rightPart, customUnitDeclarationMode = true))) {
// Case: Variable Assignment (e.g., "a = 1 + 1" or "a = 4") Or Result (e.g., " a = 4")
if (indentationLevel >= 2) {
// If indentation is 4 or more, treat the line as a result line instead of an assignment
//console.log("Case: Overwrite Result:", line);
this.totalResultsProvided++;
lastEvaluatedAnswer = math_evaluate(allButLast, scope);
// Error handling for Infinity. Possible Division by zero, as JavaScript will return Infinity
if (lastEvaluatedAnswer === Infinity) {
throw new Error("Infinity. Possible Division by zero");
}
newContent += `${allButLast}= ${this.replaceWithUnitExpandedRepresentation(lastEvaluatedAnswer)}`;
} else {
// Regular assignment
//console.log("Variable Assignment:", line);
if (isOutputResult(rightPart, customUnitDeclarationMode = true))
{
// Capture Custom Unit only if it's a simple output result
// Complex expressions with variables will make it harder to tell variable and custom units apart
assignmentUnitCapture(rightPart);
}
lastEvaluatedAnswer = math_evaluate(line, scope);
// Error handling for Infinity. Possible Division by zero, as JavaScript will return Infinity
if (lastEvaluatedAnswer === Infinity) {
throw new Error("Infinity. Possible Division by zero");
}
newContent += `${allButLast}= ${rightPart}`;
}
} else if (isVariable(leftPart) && isVariable(rightPart)) {
// Case: Cascading Variable Assignment (e.g., "b = a")
//console.log("Case: Cascading Variable Assignment:", line);
lastEvaluatedAnswer = math_evaluate(line, scope);
// Error handling for Infinity. Possible Division by zero, as JavaScript will return Infinity
if (lastEvaluatedAnswer === Infinity) {
throw new Error("Infinity. Possible Division by zero");
}
newContent += `${allButLast}= ${rightPart}`;
} else if (isVariable(leftPart) && isEmpty(rightPart)) {
// Case: Variable with no assignment/result (e.g., "a =")
//console.log("Case: Variable with no result:", line);
// If indentation is 4 or more, treat the line as a result line instead of an assignment
this.toctalResultsProvided++;
lastEvaluatedAnswer = math_evaluate(allButLast, scope);
// Error handling for Infinity. Possible Division by zero, as JavaScript will return Infinity
if (lastEvaluatedAnswer === Infinity) {
throw new Error("Infinity. Possible Division by zero");
}
newContent += `${allButLast}= ${this.replaceWithUnitExpandedRepresentation(lastEvaluatedAnswer)}`;
} else if (isEmpty(leftPart) && (isOutputResult(rightPart) || isEmpty(rightPart))) {
//console.log("Case: Implied Results:", line, `(indent: ${indentationLevel})`);
this.totalResultsProvided++;
if (lastUnevaluatedLine != null)
{
this.totalCalculations++;
lastEvaluatedAnswer = math_evaluate(lastUnevaluatedLine, scope);
// Error handling for Infinity. Possible Division by zero, as JavaScript will return Infinity
if (lastEvaluatedAnswer === Infinity) {
throw new Error("Infinity. Possible Division by zero");
}
lastUnevaluatedLine = null;
}
newContent += `${allButLast}= ${this.replaceWithUnitExpandedRepresentation(lastEvaluatedAnswer)}`;
} else if (isFunctionCall(leftPart) && (isOutputResult(rightPart) || isVariable(rightPart) || (isExpression(rightPart)))) {
// Case: Function Definition (e.g., "b(a) = a*2")
//console.log("Case: Function Definition:", line);
math_evaluate(line, scope);
newContent += `${allButLast}= ${rightPart}`;
} else {
console.log(`Unhandled Heuristic Debug: ${line}`);
console.log("MathJS Syntax:", convertNaturalMathToMathJsSyntax(line));
console.log(`(leftPart) isOutputResult:${ isOutputResult(leftPart)}, isExpression:${ isExpression(leftPart)}, isVariable: ${ isVariable(leftPart)} :: ${leftPart} `);
console.log(`(rightPart) isOutputResult:${isOutputResult(rightPart)}, isExpression:${isExpression(rightPart)}, isVariable: ${isVariable(rightPart)} :: ${rightPart}`);
console.log("## math.parse(leftPart) =");
console.log(math.parse(leftPart));
console.log("## math.parse(rightPart) =");
console.log(math.parse(rightPart));
throw new Error("This case is not yet handled, let us know at https://github.com/mofosyne/QuickMathsJS-WebCalc/issues");
}
//console.log("Updated Scope:", scope);
} else if (!isEmpty(line)) {
// Solo expression outputs nothing but is calculated anyway if valid expression or result
// Unless it's a `<variable> : <result>` in which it is an explicit result output
// Split each line by rightmost ':' to determine its structure
// Must account for extra ':' in case of inputs like 'format(1/2, {notation: 'exponential'})'
const match = line.match(/^(.*):([^:]*)$/);
if (match) {
const leftPart = match[1];
const rightPart = match[2];
if (isVariable(leftPart) || isExpression(leftPart)) {
this.totalResultsProvided++;
lastEvaluatedAnswer = math_evaluate(leftPart, scope);
newContent += `${leftPart}: ${this.replaceWithUnitExpandedRepresentation(lastEvaluatedAnswer)}`;
} else {
lastUnevaluatedLine = line;
newContent += `${line}`;
}
} else {
// Not (Yet) Evaluated Line
// Possible Solo Expressions
if (isExpression(line)) {
// This is used to assist the UI in detecting newbies just typing expressions expecting results
this.totalSoloExpressions++;
}
lastUnevaluatedLine = line;
newContent += `${line}`;
}
} else {
// Empty Line
newContent += `${line}`;
}
}
} catch (e) {
// Append original content with extra space
newContent += `${line.trimRight()} `;
// Extract the undefined symbol name from the error message
const undefinedSymbolMatch = e.message.match(/Undefined symbol (\w+)/);
if (undefinedSymbolMatch) {
const undefinedSymbol = undefinedSymbolMatch[1];
newContent += `Error: Undefined symbol ${undefinedSymbol}`;
} else {
newContent += `Error: ${e.message}`;
}
//console.log("ERR:", e.message);
//console.log("ERRLINE:", line);
//console.log("Current Scope:", scope);
//console.log(e.stack.toString());
}
// Always append a newline unless it's the last line
if (index !== lines.length - 1) {
newContent += eol;
}
index++;
}
return newContent;
}
};
return calculator;
}));