├── config ├── .npmrc ├── .eslintignore └── .editorconfig ├── jest.config.js ├── versions.json ├── manifest.json ├── manifest-beta.json ├── .gitignore ├── tsconfig.json ├── LICENSE ├── .eslintrc ├── __mocks__ └── obsidian.ts ├── package.json ├── scripts ├── esbuild.config.mjs ├── version-increment.mjs ├── build-release.mjs ├── release-beta.mjs └── release-production.mjs ├── CHANGELOG.md ├── src ├── numerals.types.ts ├── mathjsUtilities.ts ├── numerals.test.ts ├── NumeralsSuggestor.ts ├── main.ts ├── settings.ts └── numeralsUtilities.ts ├── .github └── workflows │ └── release.yml ├── styles.css ├── README.md ├── utilities └── mathjs_symbol_parse.ipynb └── tests └── numeralsUtilities.test.ts /config/.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /config/.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | testMatch: ['/tests/**/*.test.ts'] 5 | }; 6 | -------------------------------------------------------------------------------- /config/.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.8": "0.16.0", 3 | "1.0.9": "0.16.0", 4 | "1.0.10": "0.16.0", 5 | "1.2.0": "0.16.0", 6 | "1.2.1": "0.16.0", 7 | "1.3.0": "0.16.0", 8 | "1.3.1": "0.16.0", 9 | "1.4.0": "0.16.0", 10 | "1.4.1": "0.16.0", 11 | "1.5.0": "0.16.0", 12 | "1.5.1": "0.16.0", 13 | "1.5.4": "0.16.0", 14 | "1.5.5": "0.16.0" 15 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "numerals", 3 | "name": "Numerals", 4 | "version": "1.5.5", 5 | "minAppVersion": "0.16.0", 6 | "description": "Numerals turns any code block into an advanced calculator. Evaluates math expressions on each line of a code block, including units, currency, and optional TeX rendering.", 7 | "author": "RyanC", 8 | "authorUrl": "https://github.com/gtg922r/obsidian-numerals", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /manifest-beta.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "numerals", 3 | "name": "Numerals", 4 | "version": "1.5.6", 5 | "minAppVersion": "0.16.0", 6 | "description": "Numerals turns any code block into an advanced calculator. Evaluates math expressions on each line of a code block, including units, currency, and optional TeX rendering.", 7 | "author": "RyanC", 8 | "authorUrl": "https://github.com/gtg922r/obsidian-numerals", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | package-lock.json 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES2018", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "esModuleInterop":true, 15 | "lib": [ 16 | "DOM", 17 | "ES5", 18 | "ES6", 19 | "ES7" 20 | ] 21 | }, 22 | "include": [ 23 | "**/*.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | All Rights Reserved 2 | 3 | Copyright (c) 2022 Ryan C 4 | 5 | Created by Ryan C 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 8 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 9 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 10 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 11 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 12 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 13 | THE SOFTWARE. 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /__mocks__/obsidian.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "events"; 2 | 3 | /** Basic obsidian abstraction for any file or folder in a vault. */ 4 | export abstract class TAbstractFile { 5 | /** 6 | * @public 7 | */ 8 | vault: Vault; 9 | /** 10 | * @public 11 | */ 12 | path: string; 13 | /** 14 | * @public 15 | */ 16 | name: string; 17 | /** 18 | * @public 19 | */ 20 | parent: TFolder; 21 | } 22 | 23 | /** Tracks file created/modified time as well as file system size. */ 24 | export interface FileStats { 25 | /** @public */ 26 | ctime: number; 27 | /** @public */ 28 | mtime: number; 29 | /** @public */ 30 | size: number; 31 | } 32 | 33 | /** A regular file in the vault. */ 34 | export class TFile extends TAbstractFile { 35 | stat: FileStats; 36 | basename: string; 37 | extension: string; 38 | } 39 | 40 | /** A folder in the vault. */ 41 | export class TFolder extends TAbstractFile { 42 | children: TAbstractFile[]; 43 | 44 | isRoot(): boolean { 45 | return false; 46 | } 47 | } 48 | 49 | export class Vault extends EventEmitter { 50 | getFiles() { 51 | return []; 52 | } 53 | trigger(name: string, ...data: any[]): void { 54 | this.emit(name, ...data); 55 | } 56 | } 57 | 58 | export class Component { 59 | registerEvent() {} 60 | } 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-numerals", 3 | "version": "1.5.6", 4 | "description": "Numerals turns any code block into an advanced calculator. Evaluates math expressions on each line of a code block, including units, currency, and optional TeX rendering.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node scripts/esbuild.config.mjs", 8 | "test": "jest", 9 | "build": "tsc -noEmit -skipLibCheck && node scripts/esbuild.config.mjs production", 10 | "release": "node scripts/release-production.mjs", 11 | "release:beta": "node scripts/release-beta.mjs", 12 | "release:production": "node scripts/release-production.mjs", 13 | "version": "node scripts/version-increment.mjs", 14 | "version:patch": "node scripts/version-increment.mjs patch", 15 | "version:minor": "node scripts/version-increment.mjs minor", 16 | "version:major": "node scripts/version-increment.mjs major" 17 | }, 18 | "keywords": [], 19 | "author": "RyanC", 20 | "license": "UNLICENSED", 21 | "devDependencies": { 22 | "@types/jest": "^29.5.2", 23 | "@types/node": "^16.11.6", 24 | "@typescript-eslint/eslint-plugin": "5.29.0", 25 | "@typescript-eslint/parser": "5.29.0", 26 | "builtin-modules": "3.3.0", 27 | "esbuild": "0.25.0", 28 | "jest": "^29.5.0", 29 | "jest-environment-jsdom": "^29.7.0", 30 | "obsidian": "^1.8.7", 31 | "obsidian-dataview": "^0.5.56", 32 | "ts-jest": "^29.1.0", 33 | "tslib": "2.4.0", 34 | "typescript": "^4.7.4" 35 | }, 36 | "dependencies": { 37 | "fast-deep-equal": "^3.1.3", 38 | "mathjs": "^11.3.3" 39 | } 40 | } -------------------------------------------------------------------------------- /scripts/esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from 'builtin-modules' 4 | import path from 'path'; 5 | 6 | // Get the project root directory (parent of scripts folder) 7 | const projectRoot = path.resolve(process.cwd()); 8 | 9 | const banner = 10 | `/* 11 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 12 | if you want to view the source, please visit the github repository of this plugin 13 | */ 14 | `; 15 | 16 | const prod = (process.argv[2] === 'production'); 17 | 18 | const buildOptions = { 19 | banner: { 20 | js: banner, 21 | }, 22 | entryPoints: [path.join(projectRoot, 'src/main.ts')], 23 | bundle: true, 24 | external: [ 25 | 'obsidian', 26 | 'electron', 27 | '@codemirror/autocomplete', 28 | '@codemirror/collab', 29 | '@codemirror/commands', 30 | '@codemirror/language', 31 | '@codemirror/lint', 32 | '@codemirror/search', 33 | '@codemirror/state', 34 | '@codemirror/view', 35 | '@lezer/common', 36 | '@lezer/highlight', 37 | '@lezer/lr', 38 | ...builtins], 39 | format: 'cjs', 40 | target: 'es2018', 41 | logLevel: "info", 42 | sourcemap: prod ? false : 'inline', 43 | treeShaking: true, 44 | minify: prod, 45 | outfile: path.join(projectRoot, 'main.js'), 46 | }; 47 | 48 | if (prod) { 49 | // Production build 50 | esbuild.build(buildOptions).catch(() => process.exit(1)); 51 | } else { 52 | // Development build with watch 53 | const context = await esbuild.context(buildOptions); 54 | await context.watch(); 55 | console.log('Watching for changes...'); 56 | } 57 | -------------------------------------------------------------------------------- /scripts/version-increment.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | function incrementVersion(currentVersion, type) { 4 | const parts = currentVersion.split('.').map(Number); 5 | 6 | switch (type) { 7 | case 'major': 8 | parts[0]++; 9 | parts[1] = 0; 10 | parts[2] = 0; 11 | break; 12 | case 'minor': 13 | parts[1]++; 14 | parts[2] = 0; 15 | break; 16 | case 'patch': 17 | default: 18 | parts[2]++; 19 | break; 20 | } 21 | 22 | return parts.join('.'); 23 | } 24 | 25 | function updateVersion() { 26 | const versionType = process.argv[2] || 'patch'; 27 | 28 | if (!['major', 'minor', 'patch'].includes(versionType)) { 29 | console.error('❌ Invalid version type. Use: major, minor, or patch'); 30 | process.exit(1); 31 | } 32 | 33 | try { 34 | console.log(`🔢 Incrementing ${versionType} version...`); 35 | 36 | // Read current package.json 37 | const packageJson = JSON.parse(readFileSync("package.json", "utf8")); 38 | const currentVersion = packageJson.version; 39 | 40 | // Calculate new version 41 | const newVersion = incrementVersion(currentVersion, versionType); 42 | 43 | console.log(`📦 Version: ${currentVersion} → ${newVersion}`); 44 | 45 | // Update package.json 46 | packageJson.version = newVersion; 47 | writeFileSync("package.json", JSON.stringify(packageJson, null, "\t")); 48 | 49 | console.log(`✅ Updated package.json to version ${newVersion}`); 50 | console.log(`💡 Run 'npm run release:beta' or 'npm run release:production' to build and deploy`); 51 | 52 | } catch (error) { 53 | console.error("❌ Version increment failed:", error.message); 54 | process.exit(1); 55 | } 56 | } 57 | 58 | updateVersion(); 59 | -------------------------------------------------------------------------------- /scripts/build-release.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync, execSync } from "fs"; 2 | 3 | async function buildRelease() { 4 | try { 5 | console.log("🚀 Starting release build process..."); 6 | 7 | // Read current package.json version 8 | const packageJson = JSON.parse(readFileSync("package.json", "utf8")); 9 | const targetVersion = packageJson.version; 10 | 11 | console.log(`📦 Building release version: ${targetVersion}`); 12 | 13 | // Update manifest.json with release version 14 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 15 | const { minAppVersion } = manifest; 16 | manifest.version = targetVersion; 17 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 18 | 19 | // Update versions.json with target version and minAppVersion 20 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 21 | versions[targetVersion] = minAppVersion; 22 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 23 | 24 | console.log("🔨 Building project..."); 25 | 26 | // Run the build 27 | execSync("npm run build", { stdio: "inherit" }); 28 | 29 | console.log("🏷️ Creating git tag..."); 30 | 31 | // Create and push git tag 32 | execSync(`git add manifest.json versions.json`, { stdio: "inherit" }); 33 | execSync(`git commit -m "Release ${targetVersion}"`, { stdio: "inherit" }); 34 | execSync(`git tag ${targetVersion}`, { stdio: "inherit" }); 35 | execSync(`git push origin ${targetVersion}`, { stdio: "inherit" }); 36 | 37 | console.log(`✅ Release build complete! Tagged as ${targetVersion}`); 38 | console.log("🚀 GitHub Actions will automatically create the release."); 39 | 40 | } catch (error) { 41 | console.error("❌ Release build failed:", error.message); 42 | process.exit(1); 43 | } 44 | } 45 | 46 | buildRelease(); 47 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 4 | 5 | ## [Unreleased] 6 | 7 | ## [1.5.6] - 2025-05-31 8 | ### Added 9 | - Added this CHANGELOG 10 | ### Fixed 11 | - [#101](https://github.com/gtg922r/obsidian-numerals/issues/101): Global functions not working across math blocks 12 | - [#77](https://github.com/gtg922r/obsidian-numerals/issues/77): Error description not visible in a block with result annotation 13 | - Build scripts fixed from previous cleanup 14 | 15 | ## [1.5.5] - 2025-05-27 16 | ### Fixed 17 | - Fix build breakage on new esbuild version. 18 | - Tweak release scripts. 19 | 20 | ## [1.5.4] - 2025-05-27 21 | ### Changed 22 | - Updated `esbuild` dependency to `0.25.0`. 23 | ### Fixed 24 | - Build issues after Obsidian upgrade. 25 | 26 | ## [1.5.3] - 2025-05-26 27 | ### Changed 28 | - Updated to latest Obsidian API and removed global `app` references. 29 | - Show pull request links when not on `master`. 30 | 31 | ## [1.5.2] - 2025-05-26 32 | ### Added 33 | - `@prev` magic variable to reference previous line's result. 34 | ### Changed 35 | - Various script updates for building and releasing. 36 | 37 | ## [1.5.1] - 2024-06-16 38 | ### Added 39 | - `@hideRows` directive to hide lines that lack a `=>` result annotation. 40 | 41 | ## [1.5.0] - 2024-06-11 42 | ### Added 43 | - Global variables using the `$` prefix that are shared across math blocks. 44 | - Result insertion syntax with `@[label]::result` to write results to notes. 45 | - Support for Dataview metadata in suggestions. 46 | ### Changed 47 | - Release workflow and lint configuration improvements. 48 | 49 | ## [1.4.1] - 2024-03-02 50 | ### Fixed 51 | - Bug in result insertion logic. 52 | 53 | ## [1.4.0] - 2024-03-01 54 | ### Added 55 | - `@sum` and `@total` directives for summing previous lines. 56 | - Auto-completion for Greek characters. 57 | ### Fixed 58 | - TeX rendering in certain locales. 59 | -------------------------------------------------------------------------------- /scripts/release-beta.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | import { execSync } from "child_process"; 3 | 4 | async function releaseBeta() { 5 | try { 6 | console.log("🚀 Starting beta release process..."); 7 | 8 | // Read current package.json version 9 | const packageJson = JSON.parse(readFileSync("package.json", "utf8")); 10 | const currentVersion = packageJson.version; 11 | 12 | console.log(`📦 Preparing beta release: ${currentVersion}`); 13 | 14 | // Update manifest-beta.json with current version 15 | let manifestBeta = JSON.parse(readFileSync("manifest-beta.json", "utf8")); 16 | manifestBeta.version = currentVersion; 17 | writeFileSync("manifest-beta.json", JSON.stringify(manifestBeta, null, "\t")); 18 | 19 | console.log("🔨 Building project..."); 20 | 21 | // Run the build 22 | execSync("npm run build", { stdio: "inherit" }); 23 | 24 | console.log("🏷️ Creating and pushing git tag..."); 25 | 26 | // Create and push git tag 27 | execSync(`git add package.json manifest-beta.json`, { stdio: "inherit" }); 28 | execSync(`git commit -m "Beta release ${currentVersion}"`, { stdio: "inherit" }); 29 | execSync(`git tag ${currentVersion}`, { stdio: "inherit" }); 30 | execSync(`git push origin`, { stdio: "inherit" }); 31 | execSync(`git push origin ${currentVersion}`, { stdio: "inherit" }); 32 | 33 | // Get current branch name for PR URL 34 | const currentBranch = execSync('git branch --show-current', { encoding: 'utf8' }).trim(); 35 | 36 | console.log(`✅ Beta release complete! Tagged as ${currentVersion}`); 37 | console.log("🚀 GitHub Actions will automatically build and publish the release."); 38 | console.log(`📦 Release page: https://github.com/gtg922r/obsidian-numerals/releases/tag/${currentVersion}`); 39 | 40 | // Only show PR link if not on master branch 41 | if (currentBranch !== 'master') { 42 | console.log(`📋 Create PR: https://github.com/gtg922r/obsidian-numerals/compare/master...${currentBranch}?quick_pull=1&title=Beta%20Release%20${currentVersion}&body=Beta%20release%20for%20version%20${currentVersion}`); 43 | } 44 | 45 | } catch (error) { 46 | console.error("❌ Beta release failed:", error.message); 47 | process.exit(1); 48 | } 49 | } 50 | 51 | releaseBeta(); 52 | -------------------------------------------------------------------------------- /scripts/release-production.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | import { execSync } from "child_process"; 3 | 4 | async function releaseProduction() { 5 | try { 6 | console.log("🚀 Starting production release process..."); 7 | 8 | // Read current package.json version 9 | const packageJson = JSON.parse(readFileSync("package.json", "utf8")); 10 | const targetVersion = packageJson.version; 11 | 12 | console.log(`📦 Preparing production release: ${targetVersion}`); 13 | 14 | // Update manifest.json with release version 15 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 16 | const { minAppVersion } = manifest; 17 | manifest.version = targetVersion; 18 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 19 | 20 | // Update versions.json with target version and minAppVersion 21 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 22 | versions[targetVersion] = minAppVersion; 23 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 24 | 25 | console.log("🔨 Building project..."); 26 | 27 | // Run the build 28 | execSync("npm run build", { stdio: "inherit" }); 29 | 30 | console.log("🏷️ Creating and pushing git tag..."); 31 | 32 | // Create and push git tag 33 | execSync(`git add package.json manifest.json versions.json`, { stdio: "inherit" }); 34 | execSync(`git commit -m "Release ${targetVersion}"`, { stdio: "inherit" }); 35 | execSync(`git tag ${targetVersion}`, { stdio: "inherit" }); 36 | execSync(`git push origin`, { stdio: "inherit" }); 37 | execSync(`git push origin ${targetVersion}`, { stdio: "inherit" }); 38 | 39 | // Get current branch name for PR URL 40 | const currentBranch = execSync('git branch --show-current', { encoding: 'utf8' }).trim(); 41 | 42 | console.log(`✅ Production release complete! Tagged as ${targetVersion}`); 43 | console.log("🚀 GitHub Actions will automatically build and publish the release."); 44 | console.log(`📦 Release page: https://github.com/gtg922r/obsidian-numerals/releases/tag/${targetVersion}`); 45 | 46 | // Only show PR link if not on master branch 47 | if (currentBranch !== 'master') { 48 | console.log(`📋 Create PR: https://github.com/gtg922r/obsidian-numerals/compare/master...${currentBranch}?quick_pull=1&title=Release%20${targetVersion}&body=Production%20release%20for%20version%20${targetVersion}`); 49 | } 50 | 51 | } catch (error) { 52 | console.error("❌ Production release failed:", error.message); 53 | process.exit(1); 54 | } 55 | } 56 | 57 | releaseProduction(); 58 | -------------------------------------------------------------------------------- /src/numerals.types.ts: -------------------------------------------------------------------------------- 1 | /**************************************************** 2 | * Settings Related Types and Interfaces 3 | ****************************************************/ 4 | 5 | export enum NumeralsLayout { 6 | TwoPanes = "TwoPanes", 7 | AnswerRight = "AnswerRight", 8 | AnswerBelow = "AnswerBelow", 9 | AnswerInline = "AnswerInline", 10 | } 11 | 12 | export enum NumeralsRenderStyle { 13 | Plain = "Plain", 14 | TeX ="TeX", 15 | SyntaxHighlight = "SyntaxHighlight", 16 | } 17 | 18 | export enum NumeralsNumberFormat { 19 | System = "System", 20 | Fixed = "Fixed", 21 | Exponential = "Exponential", 22 | Engineering = "Engineering", 23 | Format_CommaThousands_PeriodDecimal = "Format_CommaThousands_PeriodDecimal", 24 | Format_PeriodThousands_CommaDecimal = "Format_PeriodThousands_CommaDecimal", 25 | Format_SpaceThousands_CommaDecimal = "Format_SpaceThousands_CommaDecimal", 26 | Format_Indian = "Format_Indian" 27 | } 28 | 29 | interface CurrencySymbolMapping { 30 | symbol: string; 31 | currency: string; // ISO 4217 Currency Code 32 | } 33 | 34 | export interface NumeralsSettings { 35 | resultSeparator: string; 36 | layoutStyle: NumeralsLayout; 37 | alternateRowColor: boolean; 38 | defaultRenderStyle: NumeralsRenderStyle; 39 | hideLinesWithoutMarkupWhenEmitting: boolean; // "Emitting" is "result annotation" 40 | hideEmitterMarkupInInput: boolean; 41 | dollarSymbolCurrency: CurrencySymbolMapping; 42 | yenSymbolCurrency: CurrencySymbolMapping; 43 | provideSuggestions: boolean; 44 | suggestionsIncludeMathjsSymbols: boolean; 45 | numberFormat: NumeralsNumberFormat; 46 | forceProcessAllFrontmatter: boolean; 47 | customCurrencySymbol: CurrencyType | null; 48 | enableGreekAutoComplete: boolean; 49 | } 50 | 51 | 52 | export const DEFAULT_SETTINGS: NumeralsSettings = { 53 | resultSeparator: " → ", 54 | layoutStyle: NumeralsLayout.TwoPanes, 55 | alternateRowColor: true, 56 | defaultRenderStyle: NumeralsRenderStyle.Plain, 57 | hideLinesWithoutMarkupWhenEmitting: true, 58 | hideEmitterMarkupInInput: true, 59 | dollarSymbolCurrency: {symbol: "$", currency: "USD"}, 60 | yenSymbolCurrency: {symbol: "¥", currency: "JPY"}, 61 | provideSuggestions: true, 62 | suggestionsIncludeMathjsSymbols: false, 63 | numberFormat: NumeralsNumberFormat.System, 64 | forceProcessAllFrontmatter: false, 65 | customCurrencySymbol: null, 66 | enableGreekAutoComplete: true, 67 | } 68 | 69 | 70 | export interface CurrencyType { 71 | symbol: string; 72 | unicode: string; 73 | name: string; 74 | currency: string; 75 | } 76 | 77 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 78 | export type mathjsFormat = number | math.FormatOptions | ((item: any) => string) | undefined; 79 | 80 | export class NumeralsScope extends Map{} 81 | 82 | export type numeralsBlockInfo = { 83 | emitter_lines: number[]; 84 | insertion_lines: number[]; 85 | hidden_lines: number[]; 86 | shouldHideNonEmitterLines: boolean; 87 | } 88 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build And Release obsidian plugin 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - "*" # Push events to matching any tag format, i.e. 1.0, 20.15.10 8 | 9 | env: 10 | PLUGIN_NAME: numerals # Change this to the name of your plugin-id folder 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: "16.x" # You might need to adjust this value to your own version 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run build --if-present 27 | mkdir ${{ env.PLUGIN_NAME }} 28 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 29 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 30 | ls 31 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 32 | - name: Create Release 33 | id: create_release 34 | uses: actions/create-release@v1 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | VERSION: ${{ github.ref }} 38 | with: 39 | tag_name: ${{ github.ref }} 40 | release_name: ${{ github.ref }} 41 | draft: true 42 | body: "# Numerals ${{ github.ref }}\n-" 43 | prerelease: true 44 | - name: Upload zip file 45 | id: upload-zip 46 | uses: actions/upload-release-asset@v1 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | with: 50 | upload_url: ${{ steps.create_release.outputs.upload_url }} 51 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 52 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 53 | asset_content_type: application/zip 54 | - name: Upload main.js 55 | id: upload-main 56 | uses: actions/upload-release-asset@v1 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | with: 60 | upload_url: ${{ steps.create_release.outputs.upload_url }} 61 | asset_path: ./main.js 62 | asset_name: main.js 63 | asset_content_type: text/javascript 64 | - name: Upload manifest.json 65 | id: upload-manifest 66 | uses: actions/upload-release-asset@v1 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | with: 70 | upload_url: ${{ steps.create_release.outputs.upload_url }} 71 | asset_path: ./manifest.json 72 | asset_name: manifest.json 73 | asset_content_type: application/json 74 | - name: Upload styles.css 75 | id: upload-css 76 | uses: actions/upload-release-asset@v1 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | with: 80 | upload_url: ${{ steps.create_release.outputs.upload_url }} 81 | asset_path: ./styles.css 82 | asset_name: styles.css 83 | asset_content_type: text/css 84 | 85 | -------------------------------------------------------------------------------- /src/mathjsUtilities.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns an array of built-in mathjs symbols including functions, constants, and physical constants 3 | * Also includes a prefix to indicate the type of symbol 4 | * @returns {string[]} Array of mathjs built-in symbols 5 | */ 6 | export function getMathJsSymbols(): string[] { 7 | 8 | const mathjsBuiltInSymbols: string[] = [ 9 | 'f|abs()', 'f|acos()', 'f|acosh()', 'f|acot()', 'f|acoth()', 10 | 'f|acsc()', 'f|acsch()', 'f|add()', 'f|and()', 'f|apply()', 11 | 'f|arg()', 'f|asec()', 'f|asech()', 'f|asin()', 'f|asinh()', 12 | 'f|atan()', 'f|atan2()', 'f|atanh()', 'p|atm', 'p|atomicMass', 13 | 'p|avogadro', 'f|bellNumbers()', 'f|bin()', 'f|bitAnd()', 'f|bitNot()', 14 | 'f|bitOr()', 'f|bitXor()', 'p|bohrMagneton', 'p|bohrRadius', 'p|boltzmann', 15 | 'f|catalan()', 'f|cbrt()', 'f|ceil()', 'p|classicalElectronRadius', 'f|clone()', 16 | 'f|column()', 'f|combinations()', 'f|combinationsWithRep()', 'f|compare()', 'f|compareNatural()', 17 | 'f|compareText()', 'f|compile()', 'f|composition()', 'f|concat()', 'p|conductanceQuantum', 18 | 'f|conj()', 'f|cos()', 'f|cosh()', 'f|cot()', 'f|coth()', 19 | 'p|coulomb', 'f|count()', 'f|cross()', 'f|csc()', 'f|csch()', 20 | 'f|ctranspose()', 'f|cube()', 'f|cumsum()', 'f|deepEqual()', 'f|derivative()', 21 | 'f|det()', 'p|deuteronMass', 'f|diag()', 'f|diff()', 'f|distance()', 22 | 'f|divide()', 'f|dot()', 'f|dotDivide()', 'f|dotMultiply()', 'f|dotPow()', 23 | 'c|e', 'p|efimovFactor', 'f|eigs()', 'p|electricConstant', 'p|electronMass', 24 | 'p|elementaryCharge', 'f|equal()', 'f|equalText()', 'f|erf()', 'f|evaluate()', 25 | 'f|exp()', 'f|expm()', 'f|expm1()', 'f|factorial()', 'p|faraday', 26 | 'p|fermiCoupling', 'f|fft()', 'f|filter()', 'p|fineStructure', 'p|firstRadiation', 27 | 'f|fix()', 'f|flatten()', 'f|floor()', 'f|forEach()', 'f|format()', 28 | 'f|gamma()', 'p|gasConstant', 'f|gcd()', 'f|getMatrixDataType()', 'p|gravitationConstant', 29 | 'p|gravity', 'p|hartreeEnergy', 'f|hasNumericValue()', 'f|help()', 'f|hex()', 30 | 'f|hypot()', 'c|i', 'f|identity()', 'f|ifft()', 'f|im()', 31 | 'c|Infinity', 'f|intersect()', 'f|inv()', 'p|inverseConductanceQuantum', 'f|invmod()', 32 | 'f|isInteger()', 'f|isNaN()', 'f|isNegative()', 'f|isNumeric()', 'f|isPositive()', 33 | 'f|isPrime()', 'f|isZero()', 'f|kldivergence()', 'p|klitzing', 'f|kron()', 34 | 'f|larger()', 'f|largerEq()', 'f|lcm()', 'f|leafCount()', 'f|leftShift()', 35 | 'f|lgamma()', 'c|LN10', 'c|LN2', 'f|log()', 'f|log10()', 36 | 'c|LOG10E', 'f|log1p()', 'f|log2()', 'c|LOG2E', 'p|loschmidt', 37 | 'f|lsolve()', 'f|lsolveAll()', 'f|lup()', 'f|lusolve()', 'f|lyap()', 38 | 'f|mad()', 'p|magneticConstant', 'p|magneticFluxQuantum', 'f|map()', 'f|matrixFromColumns()', 39 | 'f|matrixFromFunction()', 'f|matrixFromRows()', 'f|max()', 'f|mean()', 'f|median()', 40 | 'f|min()', 'f|mod()', 'f|mode()', 'p|molarMass', 'p|molarMassC12', 41 | 'p|molarPlanckConstant', 'p|molarVolume', 'f|multinomial()', 'f|multiply()', 'c|NaN', 42 | 'p|neutronMass', 'f|norm()', 'f|not()', 'f|nthRoot()', 'f|nthRoots()', 43 | 'p|nuclearMagneton', 'c|null', 'f|numeric()', 'f|oct()', 'f|ones()', 44 | 'f|or()', 'f|parser()', 'f|partitionSelect()', 'f|permutations()', 'c|phi', 45 | 'c|pi', 'f|pickRandom()', 'f|pinv()', 'p|planckCharge', 'p|planckConstant', 46 | 'p|planckLength', 'p|planckMass', 'p|planckTemperature', 'p|planckTime', 'f|polynomialRoot()', 47 | 'f|pow()', 'f|print()', 'f|prod()', 'p|protonMass', 'f|qr()', 48 | 'f|quantileSeq()', 'p|quantumOfCirculation', 'f|random()', 'f|randomInt()', 'f|range()', 49 | 'f|rationalize()', 'f|re()', 'p|reducedPlanckConstant', 'f|reshape()', 'f|resize()', 50 | 'f|resolve()', 'f|rightArithShift()', 'f|rightLogShift()', 'f|rotate()', 'f|rotationMatrix()', 51 | 'f|round()', 'f|row()', 'p|rydberg', 'p|sackurTetrode', 'f|schur()', 52 | 'f|sec()', 'f|sech()', 'p|secondRadiation', 'f|setCartesian()', 'f|setDifference()', 53 | 'f|setDistinct()', 'f|setIntersect()', 'f|setIsSubset()', 'f|setMultiplicity()', 'f|setPowerset()', 54 | 'f|setSize()', 'f|setSymDifference()', 'f|setUnion()', 'f|sign()', 'f|simplify()', 55 | 'f|simplifyConstant()', 'f|simplifyCore()', 'f|sin()', 'f|sinh()', 'f|size()', 56 | 'f|slu()', 'f|smaller()', 'f|smallerEq()', 'f|sort()', 'p|speedOfLight', 57 | 'f|sqrt()', 'c|SQRT1_2', 'c|SQRT2', 'f|sqrtm()', 'f|square()', 58 | 'f|squeeze()', 'f|std()', 'p|stefanBoltzmann', 'f|stirlingS2()', 'f|subset()', 59 | 'f|subtract()', 'f|sum()', 'f|sylvester()', 'f|symbolicEqual()', 'f|tan()', 60 | 'f|tanh()', 'c|tau', 'p|thomsonCrossSection', 'f|to()', 'f|trace()', 61 | 'f|transpose()', 'f|typeOf()', 'f|unaryMinus()', 'f|unaryPlus()', 'f|unequal()', 62 | 'f|usolve()', 'f|usolveAll()', 'p|vacuumImpedance', 'f|variance()', 'p|weakMixingAngle', 63 | 'p|wienDisplacement', 'f|xgcd()', 'f|xor()', 'f|zeros()', 64 | ]; 65 | 66 | return mathjsBuiltInSymbols; 67 | } 68 | -------------------------------------------------------------------------------- /src/numerals.test.ts: -------------------------------------------------------------------------------- 1 | import { unescapeSubscripts, getScopeFromFrontmatter } from "./numeralsUtilities"; 2 | 3 | describe("unescapeSubscripts function", () => { 4 | test("Basic Case", () => { 5 | expect(unescapeSubscripts("x\\_1")).toBe("x_{1}"); 6 | }); 7 | 8 | test("Basic Case - Multiple subscript characters ", () => { 9 | expect(unescapeSubscripts("x\\_red")).toBe("x_{red}"); 10 | }); 11 | 12 | test("Basic Case - Longer Words", () => { 13 | expect(unescapeSubscripts("blue\\_red")).toBe("blue_{red}"); 14 | }); 15 | 16 | test("Basic Case - Numbers", () => { 17 | expect(unescapeSubscripts("Dog\\_1")).toBe("Dog_{1}"); 18 | }); 19 | 20 | test("No Subscript", () => { 21 | expect(unescapeSubscripts("no_subscript_here")).toBe( 22 | "no_subscript_here" 23 | ); 24 | }); 25 | 26 | test("Multiple Escaped Subscripts", () => { 27 | expect(unescapeSubscripts("a\\_b\\_c")).toBe("a_{b}\\_c"); 28 | }); 29 | 30 | test("Non-English Characters", () => { 31 | expect(unescapeSubscripts("α\\_1")).toBe("α_{1}"); 32 | }); 33 | 34 | test("Special Characters", () => { 35 | expect(unescapeSubscripts("$_\\_$")).toBe("$__{$}"); 36 | }); 37 | 38 | test("Nested Escaped Subscripts", () => { 39 | expect(unescapeSubscripts("x\\_y\\_z")).toBe("x_{y}\\_z"); 40 | }); 41 | 42 | test("Escaped Subscripts At The Start", () => { 43 | expect(unescapeSubscripts("\\_x")).toBe("\\_x"); 44 | }); 45 | 46 | test("Empty String", () => { 47 | expect(unescapeSubscripts("")).toBe(""); 48 | }); 49 | 50 | test("Input With Only Escaped Underscore", () => { 51 | expect(unescapeSubscripts("\\_")).toBe("\\_"); 52 | }); 53 | 54 | test("Long Input", () => { 55 | const longInput = "x".repeat(10000) + "\\_1"; 56 | const expectedOutput = "x".repeat(10000) + "_{1}"; 57 | expect(unescapeSubscripts(longInput)).toBe(expectedOutput); 58 | }); 59 | 60 | test("Multiple Instances in Single Input", () => { 61 | expect(unescapeSubscripts("x\\_1 + y\\_2 = z\\_3")).toBe( 62 | "x_{1} + y_{2} = z_{3}" 63 | ); 64 | }); 65 | 66 | test("Input with Spaces", () => { 67 | expect(unescapeSubscripts("x \\_ 1")).toBe("x \\_ 1"); 68 | }); 69 | 70 | test("Input with Additional Escape Characters", () => { 71 | expect(unescapeSubscripts("x\\\\_1")).toBe("x\\\\_1"); 72 | }); 73 | 74 | test("Combination of Escaped and Non-escaped Subscripts", () => { 75 | expect(unescapeSubscripts("a_b + x\\_1")).toBe("a_b + x_{1}"); 76 | }); 77 | 78 | test("Unicode Characters as Subscripts", () => { 79 | expect(unescapeSubscripts("x\\_αβγ")).toBe("x_{αβγ}"); 80 | }); 81 | 82 | test("Non-alphanumeric Characters Following Escaped Subscript", () => { 83 | expect(unescapeSubscripts("x\\_1+2")).toBe("x_{1}+2"); 84 | }); 85 | 86 | test("Mix of Capitals and Lowercase", () => { 87 | expect(unescapeSubscripts("XyZ\\_aBc")).toBe("XyZ_{aBc}"); 88 | }); 89 | 90 | test("Escaped Subscripts Inside Parentheses", () => { 91 | expect(unescapeSubscripts("(a\\_1)(b\\_2)")).toBe("(a_{1})(b_{2})"); 92 | }); 93 | 94 | test("Input with Multiple Lines", () => { 95 | expect(unescapeSubscripts("x\\_1\ny\\_2\nz\\_3")).toBe( 96 | "x_{1}\ny_{2}\nz_{3}" 97 | ); 98 | }); 99 | 100 | test("Input with Non-English Words", () => { 101 | expect(unescapeSubscripts("über\\_alles")).toBe("über_{alles}"); 102 | }); 103 | }); 104 | 105 | describe("processFrontmatter", () => { 106 | 107 | test('should process all keys when numerals is set to "all"', () => { 108 | const frontmatter = { numerals: "all", x: "5+2", y: 7 }; 109 | const scope = new Map(); 110 | const expectedScope = new Map([ 111 | ["x", 7], 112 | ["y", 7], 113 | ]); 114 | expect(getScopeFromFrontmatter(frontmatter, scope)).toEqual(expectedScope); 115 | }); 116 | 117 | test('should not process any keys when numerals is set to "none"', () => { 118 | const frontmatter = { numerals: "none", x: "5+2", y: 7 }; 119 | const scope = new Map(); 120 | expect(getScopeFromFrontmatter(frontmatter, scope)).toEqual(new Map()); 121 | }); 122 | 123 | test("should process only specific key when numerals is a string", () => { 124 | const frontmatter = { numerals: "x", x: "5+2", y: 7 }; 125 | const scope = new Map(); 126 | const expectedScope = new Map([["x", 7]]); 127 | expect(getScopeFromFrontmatter(frontmatter, scope)).toEqual(expectedScope); 128 | }); 129 | 130 | test("should process specific keys when numerals is an array", () => { 131 | const frontmatter = { numerals: ["x"], x: "5+2", y: 7 }; 132 | const scope = new Map(); 133 | const expectedScope = new Map([["x", 7]]); 134 | expect(getScopeFromFrontmatter(frontmatter, scope)).toEqual(expectedScope); 135 | }); 136 | 137 | test("should process all keys when forceAll is set to true", () => { 138 | const frontmatter = { x: "5+2", y: 7 }; 139 | const scope = new Map(); 140 | const expectedScope = new Map([ 141 | ["x", 7], 142 | ["y", (7)], 143 | ]); 144 | expect(getScopeFromFrontmatter(frontmatter, scope, true)).toEqual( 145 | expectedScope 146 | ); 147 | }); 148 | 149 | test("should add processed keys to existing scope", () => { 150 | const frontmatter = { numerals: "all", x: "5+2", y: 7 }; 151 | const scope = new Map([["z", 2]]); 152 | const expectedScope = new Map([ 153 | ["z", 2], 154 | ["x", 7], 155 | ["y", 7], 156 | ]); 157 | expect(getScopeFromFrontmatter(frontmatter, scope)).toEqual(expectedScope); 158 | }); 159 | 160 | test("should handle invalid expressions", () => { 161 | const frontmatter = { numerals: "all", x: "invalid_expression", y: 7 }; 162 | const scope = new Map(); 163 | expect(() => getScopeFromFrontmatter(frontmatter, scope)).toThrow(); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* @settings 2 | 3 | name: Numerals 4 | id: numerals-style 5 | settings: 6 | - 7 | id: numerals-comment 8 | title: In-line Comment Color 9 | description: Color of the text in in-line comments. Default is `--code-comment`. 10 | type: variable-themed-color 11 | format: hex 12 | opacity: false 13 | default-light: '#' 14 | default-dark: '#' 15 | - 16 | id: numerals-heading 17 | title: Heading / Comment Line Color 18 | description: Color of lines with only a comment. Default is `--code-comment`. 19 | type: variable-themed-color 20 | format: hex 21 | opacity: false 22 | default-light: '#' 23 | default-dark: '#' 24 | - 25 | id: numerals-background 26 | title: Block background color 27 | description: Background color of the block. Default is same as code-block background. 28 | type: variable-themed-color 29 | format: hex 30 | opacity: false 31 | default-light: '#' 32 | default-dark: '#' 33 | - 34 | id: numerals-alternating-row-color 35 | title: Alternating Row Color 36 | description: Background color for the row when alternating rows (when enabled) 37 | type: variable-themed-color 38 | format: hex 39 | opacity: false 40 | default-light: '#' 41 | default-dark: '#' 42 | - 43 | id: numerals-font 44 | title: Numerals block font 45 | description: Font used for Numerals block. Default is same as a code-block 46 | type: variable-text 47 | default: '' 48 | - 49 | id: numerals-size 50 | title: Numerals block font size 51 | description: Accepts an CSS font-size value. Default is same as a code-block 52 | type: variable-text 53 | default: '' 54 | - 55 | id: numerals-emitter-input-weight 56 | title: Annotated Result Input Font Weight 57 | description: Font weight for input which has result annotation (`=>`) (accepts CSS font-weight value). Default is `var(--normal-weight)`. 58 | type: variable-text 59 | default: '' 60 | */ 61 | 62 | /***********************************/ 63 | /******** Non-setting specific **/ 64 | 65 | body { 66 | --numerals-comment: var(--code-comment); 67 | --numerals-heading: var(--code-comment); 68 | --numerals-background: var(--code-background); 69 | --numerals-font: var(--font-monospace); 70 | --numerals-size: var(--code-size); 71 | --numerals-emitter-input-weight: var(--normal-weight); 72 | --numerals-alternating-row-color: var(--background-modifier-hover); 73 | } 74 | 75 | .numerals-syntax .numerals-input span { 76 | padding: 0 2px; 77 | } 78 | .numerals-syntax .numerals-input { 79 | overflow-wrap: break-word; 80 | } 81 | .numerals-block .numerals-input .math-parenthesis, 82 | .numerals-block .numerals-input .math-paranthesis { 83 | padding-left: 0px; 84 | padding-right: 0px; 85 | } 86 | 87 | .numerals-block .numerals-input .math-number { 88 | color: var(--code-value); 89 | } 90 | .numerals-block .numerals-input .math-string { 91 | color: var(--code-string); 92 | } 93 | .numerals-block .numerals-input .math-boolean { 94 | color: var(--code-value); 95 | } 96 | .numerals-block .numerals-input .math-undefined { 97 | color: var(--code-keyword); 98 | display: none; 99 | } 100 | .numerals-block .numerals-input .math-function { 101 | color: var(--code-function); 102 | } 103 | .numerals-block .numerals-input .math-parameter { 104 | color: var(--code-property); 105 | } 106 | .numerals-block .numerals-input .math-property { 107 | color: var(--code-property); 108 | } 109 | .numerals-block .numerals-input .math-symbol { 110 | color: var(--code-property); 111 | } 112 | .numerals-block .numerals-input .math-operator { 113 | color: var(--code-operator); 114 | } 115 | .numerals-block .numerals-input .math-parenthesis, 116 | .numerals-block .numerals-input .math-paranthesis { 117 | color: var(--code-punctuation); 118 | padding-left: 0px; 119 | padding-right: 0px; 120 | } 121 | .numerals-block .numerals-input .math-separator { 122 | color: var(--code-punctuation); 123 | } 124 | 125 | .numerals-block { 126 | color: var(--code-normal); 127 | background-color: var(--numerals-background); 128 | font-family: var(--numerals-font); 129 | font-size: var(--numerals-size); 130 | padding: var(--size-4-4); 131 | } 132 | 133 | .numerals-block .MathJax { 134 | text-align: left !important; 135 | margin-top: .5em !important; 136 | margin-bottom: .5em !important; 137 | } 138 | 139 | .numerals-input.numerals-empty { 140 | font-weight: bold; 141 | color: var(--numerals-heading); 142 | } 143 | 144 | .numerals-input .numerals-tex { 145 | display:inline-block; 146 | } 147 | 148 | .numerals-input .numerals-inline-comment { 149 | display: inline-block; 150 | padding-left: 1em; 151 | color: var(--numerals-comment); 152 | } 153 | .numerals-input .numerals-sum { 154 | font-style: italic; 155 | } 156 | 157 | .numerals-alt-row-color .numerals-line:nth-child(even){ 158 | background-color: var(--numerals-alternating-row-color); 159 | } 160 | 161 | .numerals-alt-row-color .numerals-line .numerals-input{ 162 | padding-left: var(--size-2-2); 163 | } 164 | 165 | .numerals-alt-row-color .numerals-line .numerals-result { 166 | padding-right: var(--size-2-2); 167 | } 168 | 169 | .numerals-error-name { 170 | color:var(--color-red); 171 | padding-right: var(--size-4-2); 172 | } 173 | /* Ensure error text remains visible even when non-emitter results are hidden */ 174 | .numerals-error-message { 175 | color: var(--code-punctuation); 176 | } 177 | 178 | /**********************************/ 179 | /* ** Right-aligned Style ** */ 180 | 181 | /* TODO Switch to a diffent display layout so that result can be centered */ 182 | 183 | .numerals-answer-right .numerals-line { 184 | line-height: var(--line-height-tight); 185 | clear: both; 186 | overflow: auto; 187 | } 188 | 189 | .numerals-answer-right .numerals-input { 190 | float: left; 191 | } 192 | 193 | .numerals-answer-right .numerals-result { 194 | float: right; 195 | color: var(--code-punctuation); 196 | } 197 | 198 | /**********************************/ 199 | /* ** Two Panes Style ** */ 200 | /* this leads to filling all the way to the bottom of the container. probably bigger than desired */ 201 | .numerals-panes .numerals-line { 202 | line-height: var(--line-height-tight); 203 | 204 | } 205 | 206 | .numerals-panes .numerals-line { 207 | display: flex; 208 | } 209 | 210 | .numerals-panes .numerals-input { 211 | width:75%; 212 | } 213 | 214 | .numerals-panes .numerals-result { 215 | color: var(--code-punctuation); 216 | background-color: var(--background-modifier-hover); 217 | width: 25%; 218 | text-align: left; 219 | padding-left: var(--size-2-2); 220 | border-left: 1px solid var(--background-modifier-border-focus); 221 | } 222 | 223 | /* .numerals-panes .MathJax { 224 | float:left; 225 | } */ 226 | 227 | 228 | /**********************************/ 229 | /* ** Result on following line ** */ 230 | 231 | .numerals-answer-below .numerals-line { 232 | line-height: var(--line-height-tight); 233 | } 234 | 235 | .numerals-answer-below .numerals-line .numerals-result, 236 | .numerals-answer-below .numerals-line .numerals-input { 237 | display:block; 238 | } 239 | 240 | .numerals-answer-below .numerals-line .numerals-result { 241 | color: var(--code-punctuation); 242 | padding-left: var(--size-4-4); 243 | padding-bottom: var(--size-2-1); 244 | } 245 | 246 | .numerals-answer-below .numerals-line .numerals-input { 247 | padding-top: var(--size-2-1); 248 | } 249 | 250 | .numerals-answer-below .numerals-input.numerals-empty { 251 | padding-top: var(--size-4-3); 252 | padding-bottom: var(--size-4-1); 253 | } 254 | 255 | .numerals-answer-below .numerals-result.numerals-empty { 256 | display: none; 257 | } 258 | 259 | /* Don't show text in .numerals-result that and aren't descendents of .numerals-emitter */ 260 | .numerals-emitters-present:not(.numerals-hide-non-emitters) .numerals-result:not(.numerals-emitter .numerals-result) { 261 | color: var(--code-comment); 262 | } 263 | 264 | .numerals-emitter .numerals-input { 265 | font-weight: var(--numerals-emitter-input-weight) 266 | } 267 | 268 | .numerals-emitters-present.numerals-hide-non-emitters .numerals-result:not(.numerals-emitter .numerals-result) { 269 | color: transparent; 270 | } 271 | 272 | /**********************************/ 273 | /* ** Inline Style ** */ 274 | /* TODO Switch to a diffent display layout so that result can be centered */ 275 | 276 | .numerals-answer-right .numerals-line { 277 | line-height: var(--line-height-tight); 278 | /* clear: both; */ 279 | /* overflow: auto; */ 280 | } 281 | 282 | .numerals-answer-inline .numerals-input { 283 | display:inline-block; 284 | padding-right: 20px; 285 | /* float: left; */ 286 | } 287 | 288 | .numerals-answer-inline .numerals-result { 289 | /* float: right; */ 290 | color: var(--code-punctuation); 291 | display:inline-block; 292 | } 293 | 294 | 295 | /***************************/ 296 | /* ** Suggestion Style ** */ 297 | 298 | /* .numerals-suggestion-icon { 299 | --icon-size: 1em; 300 | } */ 301 | .numerals-suggestion { 302 | font-family: var(--numerals-font); 303 | font-size: var(--numerals-size); 304 | } 305 | -------------------------------------------------------------------------------- /src/NumeralsSuggestor.ts: -------------------------------------------------------------------------------- 1 | import NumeralsPlugin from "./main"; 2 | import { getMetadataForFileAtPath, getScopeFromFrontmatter } from "./numeralsUtilities"; 3 | import { 4 | EditorSuggest, 5 | EditorPosition, 6 | Editor, 7 | TFile, 8 | EditorSuggestTriggerInfo, 9 | EditorSuggestContext, 10 | setIcon, 11 | } from "obsidian"; 12 | import { getMathJsSymbols } from "./mathjsUtilities"; 13 | 14 | const greekSymbols = [ 15 | { trigger: 'alpha', symbol: 'α' }, 16 | { trigger: 'beta', symbol: 'β' }, 17 | { trigger: 'gamma', symbol: 'γ' }, 18 | { trigger: 'delta', symbol: 'δ' }, 19 | { trigger: 'epsilon', symbol: 'ε' }, 20 | { trigger: 'zeta', symbol: 'ζ' }, 21 | { trigger: 'eta', symbol: 'η' }, 22 | { trigger: 'theta', symbol: 'θ' }, 23 | { trigger: 'iota', symbol: 'ι' }, 24 | { trigger: 'kappa', symbol: 'κ' }, 25 | { trigger: 'lambda', symbol: 'λ' }, 26 | { trigger: 'mu', symbol: 'μ' }, 27 | { trigger: 'nu', symbol: 'ν' }, 28 | { trigger: 'xi', symbol: 'ξ' }, 29 | { trigger: 'omicron', symbol: 'ο' }, 30 | { trigger: 'pi', symbol: 'π' }, 31 | { trigger: 'rho', symbol: 'ρ' }, 32 | { trigger: 'sigma', symbol: 'σ' }, 33 | { trigger: 'tau', symbol: 'τ' }, 34 | { trigger: 'upsilon', symbol: 'υ' }, 35 | { trigger: 'phi', symbol: 'φ' }, 36 | { trigger: 'chi', symbol: 'χ' }, 37 | { trigger: 'psi', symbol: 'ψ' }, 38 | { trigger: 'omega', symbol: 'ω' }, 39 | { trigger: 'Gamma', symbol: 'Γ' }, 40 | { trigger: 'Delta', symbol: 'Δ' }, 41 | { trigger: 'Theta', symbol: 'Θ' }, 42 | { trigger: 'Lambda', symbol: 'Λ' }, 43 | { trigger: 'Xi', symbol: 'Ξ' }, 44 | { trigger: 'Pi', symbol: 'Π' }, 45 | { trigger: 'Sigma', symbol: 'Σ' }, 46 | { trigger: 'Phi', symbol: 'Φ' }, 47 | { trigger: 'Psi', symbol: 'Ψ' }, 48 | { trigger: 'Omega', symbol: 'Ω' }, 49 | ]; 50 | 51 | const numeralsDirectives = [ 52 | "@hideRows", 53 | "@Sum", 54 | "@Total", 55 | "@Prev", 56 | ] 57 | 58 | export class NumeralsSuggestor extends EditorSuggest { 59 | plugin: NumeralsPlugin; 60 | 61 | /** 62 | * Time of last suggestion list update 63 | * @type {number} 64 | * @private */ 65 | private lastSuggestionListUpdate = 0; 66 | 67 | /** 68 | * List of possible suggestions based on current code block 69 | * @type {string[]} 70 | * @private */ 71 | private localSuggestionCache: string[] = []; 72 | 73 | //empty constructor 74 | constructor(plugin: NumeralsPlugin) { 75 | super(plugin.app); 76 | this.plugin = plugin; 77 | } 78 | 79 | /** 80 | * This function is triggered when the user starts typing in the editor. It checks if the user is in a math block and if there is a word in the current line. 81 | * If these conditions are met, it returns an object with the start and end positions of the word and the word itself as the query. 82 | * If not, it returns null. 83 | * 84 | * @param cursor - The current position of the cursor in the editor. 85 | * @param editor - The current editor instance. 86 | * @param file - The current file being edited. 87 | * @returns An object with the start and end positions of the word and the word itself as the query, or null if the conditions are not met. 88 | */ 89 | onTrigger(cursor: EditorPosition, editor: Editor, file: TFile): EditorSuggestTriggerInfo | null { 90 | const currentFileToCursor = editor.getRange({line: 0, ch: 0}, cursor); 91 | const indexOfLastCodeBlockStart = currentFileToCursor.lastIndexOf('```'); 92 | // check if the next 4 characters after the last ``` are math or MATH 93 | const isMathBlock = currentFileToCursor.slice(indexOfLastCodeBlockStart + 3, indexOfLastCodeBlockStart + 7).toLowerCase() === 'math'; 94 | 95 | if (!isMathBlock) { 96 | return null; 97 | } 98 | 99 | // Get last word in current line 100 | const currentLineToCursor = editor.getLine(cursor.line).slice(0, cursor.ch); 101 | const currentLineLastWordStart = currentLineToCursor.search(/[:]?[$@\w\u0370-\u03FF]+$/); 102 | // if there is no word, return null 103 | if (currentLineLastWordStart === -1) { 104 | return null; 105 | } 106 | 107 | return { 108 | start: {line: cursor.line, ch: currentLineLastWordStart}, 109 | end: cursor, 110 | query: currentLineToCursor.slice(currentLineLastWordStart) 111 | }; 112 | } 113 | 114 | getSuggestions(context: EditorSuggestContext): string[] | Promise { 115 | let localSymbols: string [] = []; 116 | 117 | // check if the last suggestion list update was less than 200ms ago 118 | if (performance.now() - this.lastSuggestionListUpdate > 200) { 119 | const currentFileToStart = context.editor.getRange({line: 0, ch: 0}, context.start); 120 | const indexOfLastCodeBlockStart = currentFileToStart.lastIndexOf('```'); 121 | 122 | if (indexOfLastCodeBlockStart > -1) { 123 | //technically there is a risk we aren't in a math block, but we shouldn't have been triggered if we weren't 124 | const lastCodeBlockStart = currentFileToStart.lastIndexOf('```'); 125 | const lastCodeBlockStartToCursor = currentFileToStart.slice(lastCodeBlockStart); 126 | 127 | // Return all variable names in the last codeblock up to the cursor 128 | const matches = lastCodeBlockStartToCursor.matchAll(/^\s*(\S*?)\s*=.*$/gm); 129 | // create array from first capture group of matches and remove duplicates 130 | localSymbols = [...new Set(Array.from(matches, (match) => 'v|' + match[1]))]; 131 | } 132 | 133 | // combine frontmatter and dataview metadata, with dataview metadata taking precedence 134 | const metadata = getMetadataForFileAtPath(context.file.path, this.app, this.plugin.scopeCache); 135 | 136 | 137 | if (metadata) { 138 | const frontmatterSymbols = getScopeFromFrontmatter(metadata, undefined, this.plugin.settings.forceProcessAllFrontmatter, undefined, true); 139 | // add frontmatter symbols to local symbols 140 | const frontmatterSymbolsArray = Array.from(frontmatterSymbols.keys()).map(symbol => 'v|' + symbol); 141 | localSymbols = [...new Set([...localSymbols, ...frontmatterSymbolsArray])]; 142 | } 143 | 144 | this.localSuggestionCache = localSymbols; 145 | this.lastSuggestionListUpdate = performance.now(); 146 | } else { 147 | localSymbols = this.localSuggestionCache 148 | } 149 | 150 | const query_lower = context.query.toLowerCase(); 151 | 152 | // case-insensitive filter local suggestions based on query. Don't return value if full match 153 | const local_suggestions = localSymbols.filter((value) => value.slice(0, -1).toLowerCase().startsWith(query_lower, 2)); 154 | local_suggestions.sort((a, b) => a.slice(2).localeCompare(b.slice(2))); 155 | 156 | // case-insensitive filter mathjs suggestions based on query. Don't return value if full match 157 | let suggestions: string[] = []; 158 | if (this.plugin.settings.suggestionsIncludeMathjsSymbols) { 159 | const mathjs_suggestions = getMathJsSymbols().filter((value) => value.slice(0, -1).toLowerCase().startsWith(query_lower, 2)); 160 | suggestions = local_suggestions.concat(mathjs_suggestions); 161 | } else { 162 | suggestions = local_suggestions; 163 | } 164 | 165 | suggestions = suggestions.concat( 166 | numeralsDirectives 167 | .filter((value) => value.slice(0,-1).toLowerCase().startsWith(query_lower, 0)) 168 | .map((value) => 'm|' + value) 169 | ); 170 | 171 | // TODO MOVE THESE UP INTO THE CACHED portion. also trigger isn't the right name 172 | if (this.plugin.settings.enableGreekAutoComplete) { 173 | const greek_suggestions = greekSymbols.filter(({ trigger }) => (":" + trigger.toLowerCase()).startsWith(query_lower)).map(({ symbol, trigger }) => 'g|' + symbol + '|' + trigger); 174 | suggestions = suggestions.concat(greek_suggestions); 175 | } 176 | 177 | return suggestions; 178 | } 179 | 180 | renderSuggestion(value: string, el: HTMLElement): void { 181 | 182 | el.addClasses(['mod-complex', 'numerals-suggestion']); 183 | const suggestionContent = el.createDiv({cls: 'suggestion-content'}); 184 | const suggestionTitle = suggestionContent.createDiv({cls: 'suggestion-title'}); 185 | const suggestionNote = suggestionContent.createDiv({cls: 'suggestion-note'}); 186 | const suggestionAux = el.createDiv({cls: 'suggestion-aux'}); 187 | const suggestionFlair = suggestionAux.createDiv({cls: 'suggestion-flair'}); 188 | 189 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 190 | const [iconType, suggestionText, noteText] = value.split('|'); 191 | 192 | if (iconType === 'f') { 193 | setIcon(suggestionFlair, 'function-square'); 194 | } else if (iconType === 'c') { 195 | setIcon(suggestionFlair, 'locate-fixed'); 196 | } else if (iconType === 'v') { 197 | setIcon(suggestionFlair, 'file-code'); 198 | } else if (iconType === 'p') { 199 | setIcon(suggestionFlair, 'box'); 200 | } else if (iconType === 'm') { 201 | setIcon(suggestionFlair, 'sparkles'); 202 | } else if (iconType === 'g') { 203 | setIcon(suggestionFlair, 'case-lower'); // Assuming 'symbol' is a valid icon name 204 | } 205 | suggestionTitle.setText(suggestionText); 206 | if (noteText) { 207 | suggestionNote.setText(noteText); 208 | } 209 | 210 | } 211 | 212 | /** 213 | * Called when a suggestion is selected. Replaces the current word with the selected suggestion 214 | * @param value The selected suggestion 215 | * @param evt The event that triggered the selection 216 | * @returns void 217 | */ 218 | selectSuggestion(value: string, evt: MouseEvent | KeyboardEvent): void { 219 | if (this.context) { 220 | const editor = this.context.editor; 221 | const [suggestionType, suggestion] = value.split('|'); 222 | const start = this.context.start; 223 | const end = editor.getCursor(); // get new end position in case cursor has moved 224 | 225 | editor.replaceRange(suggestion, start, end); 226 | const newCursor = end; 227 | 228 | if (suggestionType === 'f') { 229 | newCursor.ch = start.ch + suggestion.length-1; 230 | } else { 231 | newCursor.ch = start.ch + suggestion.length; 232 | } 233 | editor.setCursor(newCursor); 234 | 235 | this.close() 236 | } 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NumeralsSuggestor } from "./NumeralsSuggestor"; 2 | import { 3 | StringReplaceMap, 4 | defaultCurrencyMap, 5 | processAndRenderNumeralsBlockFromSource, 6 | getLocaleFormatter, 7 | getMetadataForFileAtPath, 8 | addGobalsFromScopeToPageCache } from "./numeralsUtilities"; 9 | import { 10 | CurrencyType, 11 | NumeralsLayout, 12 | NumeralsRenderStyle, 13 | NumeralsNumberFormat, 14 | NumeralsSettings, 15 | mathjsFormat, 16 | DEFAULT_SETTINGS, 17 | NumeralsScope, 18 | } from "./numerals.types"; 19 | import { 20 | NumeralsSettingTab, 21 | currencyCodesForDollarSign, 22 | currencyCodesForYenSign, 23 | } from "./settings"; 24 | import equal from 'fast-deep-equal'; 25 | import { 26 | Plugin, 27 | renderMath, 28 | loadMathJax, 29 | MarkdownPostProcessorContext, 30 | MarkdownRenderChild, 31 | } from "obsidian"; 32 | 33 | import { getAPI } from 'obsidian-dataview'; 34 | 35 | // if use syntax tree directly will need "@codemirror/language": "^6.3.2", // Needed for accessing syntax tree 36 | // import {syntaxTree, tokenClassNodeProp} from '@codemirror/language'; 37 | 38 | import * as math from 'mathjs'; 39 | 40 | 41 | 42 | 43 | // Modify mathjs internal functions to allow for use of currency symbols 44 | const currencySymbols: string[] = defaultCurrencyMap.map(m => m.symbol); 45 | const isAlphaOriginal = math.parse.isAlpha; 46 | math.parse.isAlpha = function (c, cPrev, cNext) { 47 | return isAlphaOriginal(c, cPrev, cNext) || currencySymbols.includes(c) 48 | }; 49 | 50 | // @ts-ignore 51 | const isUnitAlphaOriginal = math.Unit.isValidAlpha; // @ts-ignore 52 | math.Unit.isValidAlpha = 53 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 54 | function (c: string, cPrev: any, cNext: any) { 55 | return isUnitAlphaOriginal(c, cPrev, cNext) || currencySymbols.includes(c) 56 | }; 57 | 58 | 59 | /** 60 | * Map Numerals Number Format to mathjs format options 61 | * @param format Numerals Number Format 62 | * @returns mathjs format object 63 | * @see https://mathjs.org/docs/reference/functions/format.html 64 | */ 65 | function getMathjsFormat(format: NumeralsNumberFormat): mathjsFormat { 66 | switch (format) { 67 | case NumeralsNumberFormat.System: 68 | return getLocaleFormatter(); 69 | case NumeralsNumberFormat.Fixed: 70 | return {notation: "fixed"}; 71 | case NumeralsNumberFormat.Exponential: 72 | return {notation: "exponential"}; 73 | case NumeralsNumberFormat.Engineering: 74 | return {notation: "engineering"}; 75 | case NumeralsNumberFormat.Format_CommaThousands_PeriodDecimal: 76 | return getLocaleFormatter('en-US'); 77 | case NumeralsNumberFormat.Format_PeriodThousands_CommaDecimal: 78 | return getLocaleFormatter('de-DE'); 79 | case NumeralsNumberFormat.Format_SpaceThousands_CommaDecimal: 80 | return getLocaleFormatter('fr-FR'); 81 | case NumeralsNumberFormat.Format_Indian: 82 | return getLocaleFormatter('en-IN'); 83 | default: 84 | return {notation: "fixed"}; 85 | } 86 | } 87 | 88 | export default class NumeralsPlugin extends Plugin { 89 | settings: NumeralsSettings; 90 | private currencyMap: CurrencyType[] = defaultCurrencyMap; 91 | private preProcessors: StringReplaceMap[]; 92 | private currencyPreProcessors: StringReplaceMap[]; 93 | private numberFormat: mathjsFormat; 94 | public scopeCache: Map = new Map(); 95 | 96 | async numeralsMathBlockHandler(type: NumeralsRenderStyle, source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext): Promise { 97 | // TODO: Rendering is getting called twice. Once without newline at the end of the code block and once with. 98 | // This is causing the code block to be rendered twice. Need to figure out why and fix it. 99 | 100 | let metadata = getMetadataForFileAtPath(ctx.sourcePath, this.app, this.scopeCache); 101 | 102 | const scope = processAndRenderNumeralsBlockFromSource( 103 | el, 104 | source, 105 | ctx, 106 | metadata, 107 | type, 108 | this.settings, 109 | this.numberFormat, 110 | this.preProcessors, 111 | this.app 112 | ); 113 | 114 | addGobalsFromScopeToPageCache(ctx.sourcePath, scope, this.scopeCache); 115 | 116 | const numeralsBlockChild = new MarkdownRenderChild(el); 117 | 118 | const numeralsBlockCallback = (_callbackType: unknown, _file: unknown, _oldPath?: unknown) => { 119 | const currentMetadata = getMetadataForFileAtPath(ctx.sourcePath, this.app, this.scopeCache); 120 | if (equal(currentMetadata, metadata)) { 121 | return; 122 | } else { 123 | metadata = currentMetadata; 124 | } 125 | 126 | el.empty(); 127 | 128 | const scope = processAndRenderNumeralsBlockFromSource( 129 | el, 130 | source, 131 | ctx, 132 | metadata, 133 | type, 134 | this.settings, 135 | this.numberFormat, 136 | this.preProcessors, 137 | this.app 138 | ); 139 | 140 | addGobalsFromScopeToPageCache(ctx.sourcePath, scope, this.scopeCache); 141 | } 142 | 143 | const dataviewAPI = getAPI(); 144 | if (dataviewAPI) { 145 | //@ts-expect-error: "No overload matches this call" 146 | this.registerEvent(this.app.metadataCache.on("dataview:metadata-change", numeralsBlockCallback)); 147 | } else { 148 | this.registerEvent(this.app.metadataCache.on("changed", numeralsBlockCallback)); 149 | } 150 | 151 | numeralsBlockChild.onunload = () => { 152 | this.app.metadataCache.off("changed", numeralsBlockCallback); 153 | this.app.metadataCache.off("dataview:metadata-change", numeralsBlockCallback); 154 | } 155 | ctx.addChild(numeralsBlockChild); 156 | 157 | } 158 | 159 | private createCurrencyMap( 160 | dollarCurrency: string, 161 | yenCurrency: string, 162 | customCurrency: CurrencyType | null 163 | ): CurrencyType[] { 164 | let currencyMap: CurrencyType[] = defaultCurrencyMap.map(m => { 165 | if (m.symbol === "$") { 166 | if (Object.keys(currencyCodesForDollarSign).includes(dollarCurrency)) { 167 | m.currency = dollarCurrency; 168 | } 169 | } else if (m.symbol === "¥") { 170 | if (Object.keys(currencyCodesForYenSign).includes(yenCurrency)) { 171 | m.currency = yenCurrency; 172 | } 173 | } 174 | return m; 175 | }) 176 | if (customCurrency && customCurrency.symbol != "" && customCurrency.currency != "") { 177 | const customCurrencyType: CurrencyType = { 178 | name: customCurrency.name, 179 | symbol: customCurrency.symbol, 180 | unicode: customCurrency.unicode, 181 | currency: customCurrency.currency, 182 | } 183 | // add custom currency to currency map if it doesn't already exist. if it does, replace it 184 | currencyMap = currencyMap.map(m => m.symbol === customCurrencyType.symbol ? customCurrencyType : m); 185 | if (!currencyMap.some(m => m.symbol === customCurrencyType.symbol)) { 186 | currencyMap.push(customCurrencyType); 187 | } 188 | } 189 | return currencyMap; 190 | } 191 | 192 | updateCurrencyMap() { 193 | this.currencyMap = this.createCurrencyMap( 194 | this.settings.dollarSymbolCurrency.currency, 195 | this.settings.yenSymbolCurrency.currency, 196 | this.settings.customCurrencySymbol 197 | ); 198 | } 199 | 200 | async onload() { 201 | await this.loadSettings(); 202 | 203 | // // DEBUGGING PURPOSES ONLY: Add command to reset plugin settings 204 | // this.addCommand({ 205 | // id: 'reset-numerals-settings', 206 | // name: 'Reset Numerals Settings to Defaults', 207 | // callback: async () => { 208 | // this.settings = DEFAULT_SETTINGS; 209 | // await this.saveSettings(); 210 | // new Notice('All Numerals settings reset to defaults') 211 | // } 212 | // }); 213 | 214 | 215 | // Load MathJax for TeX Rendering 216 | await loadMathJax(); 217 | 218 | this.updateCurrencyMap(); 219 | 220 | // Configure currency commands in MathJax 221 | const configureCurrencyStr = this.currencyMap.map(m => '\\def\\' + m.name + '{\\unicode{' + m.unicode + '}}').join('\n'); 222 | renderMath(configureCurrencyStr, true); 223 | 224 | 225 | // TODO: Once mathjs support removing units (josdejong/mathjs#2081), 226 | // rerun unit creation and regex preprocessors on settings change 227 | for (const moneyType of this.currencyMap) { 228 | if (moneyType.currency != '') { 229 | math.createUnit(moneyType.currency, {aliases:[moneyType.currency.toLowerCase(), moneyType.symbol]}); 230 | } 231 | } 232 | // TODO: Incorporate this in a setup function that can be called when settings change, which should reduce need for restart after change 233 | this.currencyPreProcessors = this.currencyMap.map(m => { 234 | return {regex: RegExp('\\' + m.symbol + '([\\d\\.]+)','g'), replaceStr: '$1 ' + m.currency} 235 | }) 236 | 237 | this.preProcessors = [ 238 | // {regex: /\$((\d|\.|(,\d{3}))+)/g, replace: '$1 USD'}, // Use this if commas haven't been removed already 239 | {regex: /,(\d{3})/g, replaceStr: '$1'}, // remove thousands seperators. Will be wrong for add(100,100) 240 | ...this.currencyPreProcessors 241 | ]; 242 | 243 | // Register Markdown Code Block Processors and pass in the render style 244 | const priority = 100; 245 | this.registerMarkdownCodeBlockProcessor("math", this.numeralsMathBlockHandler.bind(this, null), priority); 246 | this.registerMarkdownCodeBlockProcessor("Math", this.numeralsMathBlockHandler.bind(this, null), priority); 247 | this.registerMarkdownCodeBlockProcessor("math-plain", this.numeralsMathBlockHandler.bind(this, NumeralsRenderStyle.Plain), priority); 248 | this.registerMarkdownCodeBlockProcessor("math-tex", this.numeralsMathBlockHandler.bind(this, NumeralsRenderStyle.TeX), priority); 249 | this.registerMarkdownCodeBlockProcessor("math-TeX", this.numeralsMathBlockHandler.bind(this, NumeralsRenderStyle.TeX), priority); 250 | this.registerMarkdownCodeBlockProcessor("math-highlight", this.numeralsMathBlockHandler.bind(this, NumeralsRenderStyle.SyntaxHighlight), priority); 251 | 252 | // This adds a settings tab so the user can configure various aspects of the plugin 253 | this.addSettingTab(new NumeralsSettingTab(this.app, this)); 254 | 255 | // Register editor suggest handler 256 | if (this.settings.provideSuggestions) { 257 | this.registerEditorSuggest(new NumeralsSuggestor(this)); 258 | } 259 | 260 | // Setup number formatting 261 | this.updateLocale(); 262 | 263 | } 264 | 265 | async loadSettings() { 266 | 267 | const loadData = await this.loadData(); 268 | if (loadData) { 269 | // Check for signature of old setting format, then port to new setting format 270 | if (loadData.layoutStyle == undefined) { 271 | const oldRenderStyleMap = { 272 | 1: NumeralsLayout.TwoPanes, 273 | 2: NumeralsLayout.AnswerRight, 274 | 3: NumeralsLayout.AnswerBelow} 275 | 276 | loadData.layoutStyle = oldRenderStyleMap[loadData.renderStyle as keyof typeof oldRenderStyleMap]; 277 | if(loadData.layoutStyle) { 278 | delete loadData.renderStyle 279 | this.settings = loadData 280 | this.saveSettings(); 281 | } else { 282 | console.log("Numerals: Error porting old layout style") 283 | } 284 | 285 | } else if (loadData.layoutStyle in [0, 1, 2, 3]) { 286 | const oldLayoutStyleMap = { 287 | 0: NumeralsLayout.TwoPanes, 288 | 1: NumeralsLayout.AnswerRight, 289 | 2: NumeralsLayout.AnswerBelow, 290 | 3: NumeralsLayout.AnswerInline, 291 | } 292 | 293 | loadData.layoutStyle = oldLayoutStyleMap[loadData.layoutStyle as keyof typeof oldLayoutStyleMap]; 294 | if(loadData.layoutStyle) { 295 | this.settings = loadData 296 | this.saveSettings(); 297 | } else { 298 | console.log("Numerals: Error porting old layout style") 299 | } 300 | } 301 | } 302 | 303 | this.settings = Object.assign({}, DEFAULT_SETTINGS, loadData); 304 | } 305 | 306 | async saveSettings() { 307 | await this.saveData(this.settings); 308 | } 309 | 310 | /** 311 | * Update the locale used for formatting numbers. Takes no arguments and returnings nothing 312 | * @returns {void} 313 | */ 314 | updateLocale(): void { 315 | this.numberFormat = getMathjsFormat(this.settings.numberFormat); 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Numerals Obsidian Plugin 2 | ![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=downloads&query=%24%5B%22numerals%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/gtg922r/obsidian-numerals?color=%23483699) ![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/gtg922r/obsidian-numerals?include_prereleases&label=BRAT%20beta) 3 | 4 | *Numerals* gives you the power of an advanced calculator inside a `math` code block, complete with currencies, units, variables, and math functions! Now you can perform calculations inline with your notes, and see both the input and the evaluated result. *Numerals* works with Live Preview as well as Reader view, and offers TeX-style rendering or Syntax Highlighting as well as auto-completion suggestions. Comments or explanations can be added with `#`, and important results can be indicated with `=>` after the calculation. 5 | ![Numerals Lemonade Stand - Side by Side](https://user-images.githubusercontent.com/1195174/200186757-a71b5e7a-df96-4350-b6a4-366d758e696d.png) 6 | ![Numerals Tex Example](https://user-images.githubusercontent.com/1195174/201516487-75bb7a08-76ab-4ff3-bf6b-d654aa284ab7.png) 7 | 8 | To get started, simply install and enable the plugin. Add a `math` code block with your desired calculations: 9 | ````markdown 10 | ```math 11 | 20 mi / 4 hr to m/s 12 | ``` 13 | ```` 14 | 15 | ## Features 16 | - Units 17 | - `1ft + 12in` → `2ft` 18 | - `20 mi / 4 hr to m/s` → `2.235 m / s` 19 | - `100 km/hr in mi/hr` → `62.137 mi / hr` 20 | - `9.81 m/s^2 * 100 kg * 40 m` → `39.24 kJ` 21 | - Currency 22 | - `$1,000 * 2` → `2,000 USD` 23 | - `£10 + £0.75` → `10.75 GBP` 24 | - `$100/hr * 3days` → `7,200 USD` 25 | - Set custom currencies, for example `₿` 26 | - Math functions 27 | - `sqrt`, `sin`, `cos`, `abs`, `log`, etc (see [mathjs](https://mathjs.org/docs/reference/functions.html) for full list) 28 | - Hex, Binary, Octal, and other bases 29 | - `0xff + 0b100` → `259` 30 | - `hex(0xff + 0b100)` → `"0x103"` 31 | - Natural Constants 32 | - `e`, `i`, `pi`, `speedOfLight`, `gravitationConstant`, `vacuumImpedance`, `avogadro` 33 | - And many more (see [mathjs: Constants](https://mathjs.org/docs/reference/constants.html) and [mathjs: Units](https://mathjs.org/docs/datatypes/units.html) for more) 34 | - Auto-complete suggestions 35 | - By default will offer auto-complete suggestions for any variables defined in a math codeblock being edited 36 | - Optional setting to include all available functions, constants, and physical constants 37 | - Totals of previous lines using `@total` or `@sum` special operator 38 | - When Numerals encounters `@total` or `@sum` it inserts the sum of all previous lines up until the last blank line or comment 39 | - Previous result 40 | - Use previous line result in current calculation with `@prev` 41 | - Greek Letters 42 | - Variables can be named using greek letters, e.g. `μ = 3 m/s` 43 | - Greek letters can be auto-completed by typing `:`, e.g. `:mu` in a math block will offer `μ` as an auto-complete suggestion 44 | - Note-Global Variables 45 | - Any variable name or function definition preceeded by an `$` symbol will be made available to all math blocks on a page 46 | - Fractions: 47 | - `fraction(1/3) + fraction(1/4)` → `7/12` 48 | - Comments and Headings: 49 | - `#` at the end of a line will be ignored, but rendered in faint text as a comment 50 | - A line starting with `#` will be ignored by the math engine, but will be bolded when rendered 51 | - Result Annotation: 52 | - `=>` at the end of a line (but before a comment) will tell *Numerals* that a result should be highlighted. Any line in that code block *without* a `=>` annotation will be rendered faintly (or hidden depending on settings). 53 | - Result Insertion: 54 | - Using the `@[...]` syntax (for example: `@[profit]`), Numerals will insert the results of a calculation into the raw text of your note, following `::` 55 | - Uses dataview notation, which allows writing back to dataview values. For example, `@[profit]` will be modified to say `@[profit::10 USD]` 56 | - Access Frontmatter Properties 57 | - Numerals math blocks will have access to any property name specified in the `numerals:` property. Setting `numerals` to `all`, will make all properties in a note available to *Numerals* 58 | - Multiple properties can be specified as a list, e.g. `numerals: [apples, pears]` will makes both the `apples` and `pears` property available to Numerals 59 | - Any property in the YAML frontmatter beginning with `$` automatically becomes a note-global variable (or function) accessible in every math block on the page 60 | - Functions can be defined in YAML by name along with their arguments, e.g. `$f(x): x+2` 61 | 62 | *Numerals* utilizes the [mathjs](https://mathjs.org/) library for all calculations. *Numerals* implements a preprocessor to allow more human-friendly syntax, such as currency symbols and thousands separators. For all available functions and capabilities (which includes matrices, vectors, symbolic algebra and calculus, etc), see the [mathjs documentation](https://mathjs.org/docs/index.html) 63 | 64 | 65 | ## Styling Options 66 | *Numerals* has been tested with the default theme and most other top themes. It uses default values such that it should play nice with any other theme. There are also several configurable settings to modify how *Numerals* renders math blocks 67 | 68 | ### Render Style 69 | *Numerals* supports rendering inputs/ouputs as either: 70 | 1. Plain Text 71 | 2. TeX 72 | 3. Syntax Highlighting 73 | 74 | One of these options can either be chosen as a default from *Numerals* settings, or then can be applied on a per-block basis by using `math-plain`, `math-tex`, or `math-highlight` rather than a `math` code block. 75 | 76 | ![Numerals Render Style Side by Side](https://user-images.githubusercontent.com/1195174/201587645-5a79aafa-5008-49d0-b584-5c6a99c7edc5.png) 77 | 78 | 79 | ### Layout 80 | #### 2-panes 81 | - Answer is shown to the right of the input with a background color and a separator. 82 | - Distinctive style that separates input from evaluated answers 83 | 84 | ![Numerals 2 Panes](https://user-images.githubusercontent.com/1195174/200186692-0b6a0a7b-3f77-47f8-887f-d7d333b53967.png) 85 | 86 | #### Answer to the Right 87 | - Answer to the right: answer is shown in the same line as the input, but right-aligned 88 | - More subtle than 2-panes that works well with just a few calculations 89 | 90 | ![Numerals answer right](https://user-images.githubusercontent.com/1195174/200186885-dedf1ccb-0464-4732-976e-0eaf54f5d098.png) 91 | 92 | #### Answer Below 93 | - Answer is shown below the input, on the next line. 94 | - Less compact vertically, but more compact horizontally 95 | 96 | ![Numerals answer below](https://user-images.githubusercontent.com/1195174/200186929-8e5bf0de-ab1e-47d0-a3f3-cf5164136c62.png) 97 | 98 | ### Alternating Row Colors 99 | Choose between a consistent code block background color (left), or alternating rows to help track from input to result (right). 100 | 101 | ![Numerals Alternating Row Style Comparison](https://user-images.githubusercontent.com/1195174/200187338-24912a83-eb1e-4188-a843-e189f33e7133.png) 102 | 103 | ### Auto-completion Suggestions 104 | By default, _Numerals_ will provide auto-completion suggestions for variables that have been defined in a particular `math` codeblock. Turning on _Include Functions and Constants in Suggestions_ will also provide suggestions for all functions, math constants, and physical constants supported in _Numerals_. 105 | 106 | ![Auto-completion of Functions](https://user-images.githubusercontent.com/1195174/215416147-68110298-0e10-44e5-9351-83efc3a17bba.png) 107 | 108 | ### Format of Numbers in Rendered Results 109 | *Numerals* allows the user to specify the format of rendered results. 110 | - **System Formatted** (Default): Use your local system settings for number formatting (including thousands and decimal separator) 111 | - **Fixed**: No thousands separator and full precision. Period as decimal separator (e.g. `100000.1`) 112 | - **Exponential**: Always use exponential notation. (e.g. `1.000001e5`) 113 | - **Engineering**: Exponential notation with exponent a multiple of 3. (e.g. `100.0001e3`) 114 | - **Formatted**: Forces a specific type of formatted notation. 115 | - Style 1: `100,000.1` 116 | - Style 2: `100.000,1` 117 | - Style 3: `100 000,1` 118 | - Style 4: `1,00,000.1` 119 | 120 | ## Installation 121 | *Numerals* can be found in the Obsidian community plugin list. 122 | 123 | ### Using BRAT 124 | To try the latest features of *Numerals* before they are released, and provide helpful feedback and testing, try *Numerals* by using the [Obsidian BRAT plugin](https://github.com/TfTHacker/obsidian42-brat). **All new *Numerals* features will be pushed to beta testers first.** 125 | 126 | 1. Ensure BRAT is installed 127 | 2. Trigger the command `Obsidian42 - BRAT: Add a beta plugin for testing` 128 | 3. Enter this repository, `gtg922r/obsidian-numerals` 129 | 4. Activate *Numerals* plugin in community plugin list 130 | 131 | ## Features in progress and roadmap 132 | - [x] Support for mapping currency symbols to different currencies ([#17](https://github.com/gtg922r/obsidian-numerals/issues/17)) 133 | both `$` and `¥` can be mapped to different currencies in settings 134 | - [x] Style Settings support for all colors and other style options ([#13](https://github.com/gtg922r/obsidian-numerals/issues/13)) 135 | - Partial support added in 1.0.5 136 | - [x] Result annotation, similar to Calca feature ([#4](https://github.com/gtg922r/obsidian-numerals/issues/4)) 137 | - Support added in 1.0.5 138 | - [x] Autocompletion of functions and variable inside math code block ([#15](https://github.com/gtg922r/obsidian-numerals/issues/15)) 139 | - Support added in 1.0.8 140 | - [ ] Inline calculation for inline code blocks ([#5](https://github.com/gtg922r/obsidian-numerals/issues/5)) 141 | 142 | Feel free to suggest additional features by creating an [issue](https://github.com/gtg922r/obsidian-numerals/issues)! 143 | 144 | ## Development 145 | 146 | *Numerals* uses a three-stage development workflow: **Version → Build → Release** 147 | 148 | ### 1. Version Management 149 | 150 | Update the version number in `package.json`: 151 | 152 | ```bash 153 | npm run version:patch # 1.5.1 → 1.5.2 154 | npm run version:minor # 1.5.1 → 1.6.0 155 | npm run version:major # 1.5.1 → 2.0.0 156 | npm run version # defaults to patch 157 | ``` 158 | 159 | These commands only update the version in `package.json` and do not sync to manifests or create releases. 160 | 161 | ### 2. Build 162 | 163 | Compile the TypeScript and bundle the plugin: 164 | 165 | ```bash 166 | npm run build # Build for production 167 | npm run dev # Build for development with watch mode 168 | ``` 169 | 170 | The `build` command compiles TypeScript and creates the `main.js` file that Obsidian loads. 171 | 172 | ### 3. Release 173 | 174 | Create tagged releases that trigger automated GitHub Actions: 175 | 176 | #### Beta Releases 177 | ```bash 178 | npm run release:beta 179 | ``` 180 | - Creates a timestamped beta version (e.g., `1.5.1-beta.2024-01-15T10-30-00`) 181 | - Updates `manifest-beta.json` with the beta version 182 | - Builds the project 183 | - Creates a git tag and pushes it to trigger GitHub Actions 184 | - Automatically creates a beta release on GitHub 185 | 186 | #### Production Releases 187 | ```bash 188 | npm run release:production # Full command 189 | npm run release # Shortcut for production 190 | ``` 191 | - Uses the current version from `package.json` 192 | - Updates `manifest.json` and `versions.json` 193 | - Builds the project 194 | - Creates a git tag and pushes it to trigger GitHub Actions 195 | - Automatically creates a production release on GitHub 196 | 197 | ### Complete Development Workflow 198 | 199 | 1. **Make changes** to the codebase 200 | 2. **Test locally** with `npm run dev` 201 | 3. **Increment version**: `npm run version:patch` (or minor/major) 202 | 4. **Test with beta release**: `npm run release:beta` 203 | 5. **Create production release**: `npm run release` 204 | 205 | The release scripts handle all manifest syncing, building, tagging, and triggering GitHub Actions for automated release publishing. 206 | 207 | ## Related 208 | There are a number of other plugins that address math and calculation use cases in Obsidian. 209 | - If you are primarily interested in evaluating math expressions and inserting the result into your notes, look into [meld-cp/obsidian-calc](https://github.com/meld-cp/obsidian-calc) 210 | - If you are looking for a full-featured Computer Algebra System including plots and with similar code block rendering, consider [Canna71/obsidian-mathpad: Computer Algebra System (CAS) for Obsidian.md](https://github.com/Canna71/obsidian-mathpad) 211 | 212 | There are also a number of "calculator as notes" apps that acted as the inspiration for *Numerals*. If you are looking for a purpose-built app outside of Obsidian, consider: 213 | - [Numi. Beautiful calculator app for Mac.](https://numi.app/) 214 | - [Numbr](https://numbr.dev/) 215 | - [Soulver 3 - Notepad Calculator App for Mac](https://soulver.app/) 216 | 217 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | /////////////////////////////////////////// 2 | // Imports 3 | /////////////////////////////////////////// 4 | 5 | import NumeralsPlugin from "./main"; 6 | import { NumeralsSuggestor } from "./NumeralsSuggestor"; 7 | import { htmlToElements } from "./numeralsUtilities"; 8 | import { NumeralsRenderStyle, NumeralsNumberFormat, NumeralsLayout } from "./numerals.types"; 9 | 10 | import { 11 | PluginSettingTab, 12 | App, 13 | Setting, 14 | ButtonComponent, TextComponent 15 | } from "obsidian"; 16 | 17 | /////////////////////////////////////////// 18 | // Settings Enums and Interfaces 19 | /////////////////////////////////////////// 20 | 21 | 22 | /////////////////////////////////////////// 23 | // Settings Details 24 | /////////////////////////////////////////// 25 | 26 | export const NumberalsNumberFormatSettingsStrings = { 27 | [NumeralsNumberFormat.System]: `System Formatted: ${(100000.1).toLocaleString()}`, 28 | [NumeralsNumberFormat.Fixed]: "Fixed: 100000.1", 29 | [NumeralsNumberFormat.Exponential]: "Exponential: 1.000001e+5", 30 | [NumeralsNumberFormat.Engineering]: "Engineering: 100.0001e+3", 31 | [NumeralsNumberFormat.Format_CommaThousands_PeriodDecimal]: "Formatted: 100,000.1", 32 | [NumeralsNumberFormat.Format_PeriodThousands_CommaDecimal]: "Formatted: 100.000,1", 33 | [NumeralsNumberFormat.Format_SpaceThousands_CommaDecimal]: "Formatted: 100 000,1", 34 | [NumeralsNumberFormat.Format_Indian]: "Formatted: 1,00,000.1", 35 | } 36 | 37 | export const currencyCodesForDollarSign: {[key:string]: string} = { 38 | ARS: "Argentine Peso", 39 | AUD: "Australian Dollar", 40 | BBD: "Barbadian Dollar", 41 | BMD: "Bermudian Dollar", 42 | BND: "Brunei Dollar", 43 | BSD: "Bahamian Dollar", 44 | BZD: "Belize Dollar", 45 | CAD: "Canadian Dollar", 46 | CLP: "Chilean Peso", 47 | COP: "Colombian Peso", 48 | FJD: "Fijian Dollar", 49 | GYD: "Guyanese Dollar", 50 | HKD: "Hong Kong Dollar", 51 | JMD: "Jamaican Dollar", 52 | KYD: "Cayman Islands Dollar", 53 | LRD: "Liberian Dollar", 54 | MXN: "Mexican Peso", 55 | NAD: "Namibian Dollar", 56 | NZD: "New Zealand Dollar", 57 | SBD: "Solomon Islands Dollar", 58 | SGD: "Singapore Dollar", 59 | SRD: "Surinamese Dollar", 60 | TTD: "Trinidad and Tobago Dollar", 61 | TWD: "New Taiwan Dollar", 62 | USD: "United States Dollar", 63 | UYU: "Uruguayan Peso", 64 | XCD: "East Caribbean Dollar", 65 | }; 66 | 67 | export const currencyCodesForYenSign: {[key:string]: string} = { 68 | JPY: "Japanese Yen", 69 | CNY: "Chinese Yuan", 70 | KRW: "Korean Won", 71 | }; 72 | 73 | /////////////////////////////////////////// 74 | // Settings Tab 75 | /////////////////////////////////////////// 76 | 77 | /** 78 | * Settings Tab for the Numerals Plugin 79 | * 80 | * @export 81 | * @class NumeralsSettingTab 82 | * @extends {PluginSettingTab} 83 | * @property {NumeralsPlugin} plugin 84 | */ 85 | export class NumeralsSettingTab extends PluginSettingTab { 86 | plugin: NumeralsPlugin; 87 | 88 | constructor(app: App, plugin: NumeralsPlugin) { 89 | super(app, plugin); 90 | this.plugin = plugin; 91 | } 92 | 93 | display(): void { 94 | const {containerEl} = this; 95 | 96 | containerEl.empty(); 97 | 98 | containerEl.createEl('h1', {text: 'Numerals Plugin Settings'}); 99 | 100 | new Setting(containerEl) 101 | .setHeading() 102 | .setName('Layout and Render Settings'); 103 | 104 | new Setting(containerEl) 105 | .setName('Numerals Layout Style') 106 | .setDesc('Layout of math blocks in Live Preview and Reading mode') 107 | .addDropdown(dropDown => { 108 | dropDown.addOption(NumeralsLayout.TwoPanes, '2 Panes'); 109 | dropDown.addOption(NumeralsLayout.AnswerRight, 'Answer to the right'); 110 | dropDown.addOption(NumeralsLayout.AnswerBelow, 'Answer below each line'); 111 | dropDown.addOption(NumeralsLayout.AnswerInline, 'Answer inline, beside input'); 112 | dropDown.setValue(this.plugin.settings.layoutStyle); 113 | dropDown.onChange(async (value) => { 114 | const layoutStyleStr = value as keyof typeof NumeralsLayout; 115 | this.plugin.settings.layoutStyle = NumeralsLayout[layoutStyleStr]; 116 | await this.plugin.saveSettings(); 117 | }); 118 | }); 119 | 120 | new Setting(containerEl) 121 | .setName('Default Numerals Rendering Style') 122 | .setDesc('Choose how the input and results are rendered by default. Note that you can specify the rendering style on a per block basis, by using `math-plain`, ``math-tex``, or ``math-highlight``') 123 | .addDropdown(dropDown => { 124 | dropDown.addOption(NumeralsRenderStyle.Plain, 'Plain Text'); 125 | dropDown.addOption(NumeralsRenderStyle.TeX, 'TeX Style'); 126 | dropDown.addOption(NumeralsRenderStyle.SyntaxHighlight, 'Syntax Highlighting of Plain Text'); 127 | dropDown.setValue(this.plugin.settings.defaultRenderStyle); 128 | dropDown.onChange(async (value) => { 129 | const renderStyleStr = value as keyof typeof NumeralsRenderStyle; 130 | this.plugin.settings.defaultRenderStyle = NumeralsRenderStyle[renderStyleStr] 131 | await this.plugin.saveSettings(); 132 | }); 133 | }); 134 | 135 | new Setting(containerEl) 136 | .setHeading() 137 | .setName('Auto-Complete Suggestion Settings'); 138 | 139 | new Setting(containerEl) 140 | .setName('Provide Auto-Complete Suggestions') 141 | .setDesc('Enable auto-complete suggestions when inside a math codeblock. Will base suggestions on variables in current codeblock, as well as mathjs functions and constants if enabled below (Disabling requires restart to take effect)') 142 | .addToggle(toggle => toggle 143 | .setValue(this.plugin.settings.provideSuggestions) 144 | .onChange(async (value) => { 145 | this.plugin.settings.provideSuggestions = value; 146 | if (value) { 147 | this.plugin.registerEditorSuggest(new NumeralsSuggestor(this.plugin)); 148 | } 149 | await this.plugin.saveSettings(); 150 | })); 151 | new Setting(containerEl) 152 | .setName('Include Functions and Constants in Suggestions') 153 | .setDesc('Auto-complete suggestions will include mathjs functions, constants, and physical constants.') 154 | .addToggle(toggle => toggle 155 | .setValue(this.plugin.settings.suggestionsIncludeMathjsSymbols) 156 | .onChange(async (value) => { 157 | this.plugin.settings.suggestionsIncludeMathjsSymbols = value; 158 | await this.plugin.saveSettings(); 159 | })); 160 | new Setting(containerEl) 161 | .setName('Enable Greek Character Auto-Complete') 162 | .setDesc('Auto-complete suggestions for Greek characters by typing ":" and then greek letter name (e.g. `:alpha`).') 163 | .addToggle(toggle => toggle 164 | .setValue(this.plugin.settings.enableGreekAutoComplete) 165 | .onChange(async (value) => { 166 | this.plugin.settings.enableGreekAutoComplete = value; 167 | await this.plugin.saveSettings(); 168 | })); 169 | 170 | new Setting(containerEl) 171 | .setHeading() 172 | .setName('Styling Settings'); 173 | 174 | new Setting(containerEl) 175 | .setName('Result Indicator') 176 | .setDesc('String to show preceeding the calculation result') 177 | .addText(text => text 178 | .setPlaceholder('" → "') 179 | .setValue(this.plugin.settings.resultSeparator) 180 | .onChange(async (value) => { 181 | this.plugin.settings.resultSeparator = value; 182 | await this.plugin.saveSettings(); 183 | })); 184 | 185 | new Setting(containerEl) 186 | .setName('Alternating row color') 187 | .setDesc('Alternating rows are colored slightly differently to help differentiate between rows') 188 | .addToggle(toggle => toggle 189 | .setValue(this.plugin.settings.alternateRowColor) 190 | .onChange(async (value) => { 191 | this.plugin.settings.alternateRowColor = value; 192 | await this.plugin.saveSettings(); 193 | })); 194 | 195 | new Setting(containerEl) 196 | .setName('Hide Result on Lines without Result Annotation') 197 | .setDesc('If a math block uses result annotation (`=>`) on any line, hide the results for lines that are not annotated as a result. If off, results of non-annotated lines will be shown in faint text color.') 198 | .addToggle(toggle => toggle 199 | .setValue(this.plugin.settings.hideLinesWithoutMarkupWhenEmitting) 200 | .onChange(async (value) => { 201 | this.plugin.settings.hideLinesWithoutMarkupWhenEmitting = value; 202 | await this.plugin.saveSettings(); 203 | })); 204 | 205 | // create new document fragment to be mult-line property text seperated by
206 | const resultAnnotationMarkupDesc = document.createDocumentFragment(); 207 | resultAnnotationMarkupDesc.append('Result Annotation markup (`=>`) is used to indicate which line is the result of the calculation. It can be used on any line, and can be used multiple times in a single block. If used, the result of the last line with the markup will be shown in the result column. If not used, the result of the last line will be shown in the result column.'); 208 | 209 | new Setting(containerEl) 210 | .setName('Hide Result Annotation Markup in Input') 211 | .setDesc('Result Annotation markup (`=>`) will be hidden in the input when rendering the math block') 212 | .addToggle(toggle => toggle 213 | .setValue(this.plugin.settings.hideEmitterMarkupInInput) 214 | .onChange(async (value) => { 215 | this.plugin.settings.hideEmitterMarkupInInput = value; 216 | await this.plugin.saveSettings(); 217 | })); 218 | 219 | // containerEl.createEl('h2', {text: 'Number Formatting'}); 220 | // Dropdown for number formatting locale setting 221 | new Setting(containerEl) 222 | .setHeading() 223 | .setName("Number and Currency Formatting"); 224 | 225 | new Setting(containerEl) 226 | .setName('Rendered Number Format') 227 | .setDesc(htmlToElements(`Choose how to format numbers in the results.
` 228 | + `System Formatted: Use your local system settings for number formatting (Currently ${navigator.language})
` 229 | + `Fixed: No thousands seperator and full precision.
` 230 | + `Exponential: Always use exponential notation.
` 231 | + `Engineering: Exponential notation with exponent a multiple of 3.
` 232 | + `Formatted: Forces a specific type of formatted notation.

` 233 | + `Note: math-tex mode will always use period as decimal seperator, regardless of locale.
`)) 234 | .addDropdown(dropDown => { 235 | // addOption for every option in NumberalsNumberFormatSettingsStrings 236 | for (const settingName in NumberalsNumberFormatSettingsStrings) { 237 | dropDown.addOption(settingName, NumberalsNumberFormatSettingsStrings[settingName as NumeralsNumberFormat]); 238 | } 239 | 240 | dropDown.setValue(this.plugin.settings.numberFormat); 241 | dropDown.onChange(async (value) => { 242 | const formatStyleStr = value as keyof typeof NumeralsNumberFormat; 243 | this.plugin.settings.numberFormat = NumeralsNumberFormat[formatStyleStr]; 244 | await this.plugin.saveSettings(); 245 | this.plugin.updateLocale(); 246 | }); 247 | }) 248 | 249 | new Setting(containerEl) 250 | .setName('`$` symbol currency mapping') 251 | .setDesc('Choose the currency the `$` symbol maps to (requires Obsidian reload to take effect)') 252 | .addDropdown(dropDown => { 253 | // addOption for every currency in currencyCodesForDollarSign 254 | for (const currencyCode in currencyCodesForDollarSign) { 255 | dropDown.addOption(currencyCode, `${currencyCode} (${currencyCodesForDollarSign[currencyCode]})`); 256 | } 257 | dropDown.setValue(this.plugin.settings.dollarSymbolCurrency.currency); 258 | dropDown.onChange(async (value) => { 259 | this.plugin.settings.dollarSymbolCurrency.currency = value; 260 | await this.plugin.saveSettings(); 261 | }); 262 | }); 263 | 264 | new Setting(containerEl) 265 | .setName('`¥` symbol currency mapping') 266 | .setDesc('Choose the currency the `¥` symbol maps to (requires Obsidian reload to take effect)') 267 | .addDropdown(dropDown => { 268 | // addOption for every currency in currencyCodesForYenSign 269 | for (const currencyCode in currencyCodesForYenSign) { 270 | dropDown.addOption(currencyCode, `${currencyCode} (${currencyCodesForYenSign[currencyCode]})`); 271 | } 272 | dropDown.setValue(this.plugin.settings.yenSymbolCurrency.currency); 273 | dropDown.onChange(async (value) => { 274 | this.plugin.settings.yenSymbolCurrency.currency = value; 275 | await this.plugin.saveSettings(); 276 | }); 277 | }); 278 | 279 | let currencySaveButton: ButtonComponent | null; 280 | let currencySymbolInput: TextComponent | null; 281 | let currencyCodeInput: TextComponent | null; 282 | new Setting(containerEl) 283 | .setName('Custom currency mapping') 284 | .setDesc('Specify a custom currency. Note that this may be used for custom mapping of `$` and `¥`. Requires Obsidian reload to take effect') 285 | .addText(text => { text 286 | .setPlaceholder('symbol') 287 | .setValue(this.plugin.settings.customCurrencySymbol?.symbol ?? "") 288 | .onChange(async (value) => { 289 | if( 290 | ( 291 | (value.length == 0 && !currencyCodeInput?.getValue()) 292 | || 293 | (value.length >= 1 && currencyCodeInput?.getValue()) 294 | ) && currencySaveButton) { 295 | if (value.match(/^\p{Sc}$/u) || value.length == 0) { 296 | currencySaveButton.setDisabled(false); 297 | currencySaveButton.buttonEl.style.color = "var(--text-normal)"; 298 | currencySaveButton.setButtonText('Save'); 299 | } else { 300 | currencySaveButton.setDisabled(true); 301 | currencySaveButton.buttonEl.style.color = "var(--text-error)"; 302 | currencySaveButton.setButtonText('Error'); 303 | } 304 | } else if (currencySaveButton) { 305 | currencySaveButton.setDisabled(true); 306 | currencySaveButton.buttonEl.style.color = "var(--text-faint)"; 307 | currencySaveButton.setButtonText('Save'); 308 | } 309 | }); 310 | text.inputEl.setAttribute("maxlength", "1"); 311 | text.inputEl.style.width = "5em"; 312 | text.inputEl.style.textAlign = "center"; 313 | currencySymbolInput = text; 314 | }) 315 | .addText(text => { text 316 | .setPlaceholder('code') 317 | .setValue(this.plugin.settings.customCurrencySymbol?.currency ?? "") 318 | .onChange(async (value) => { 319 | if( 320 | ( 321 | (value.length == 0 && !currencySymbolInput?.getValue()) 322 | || 323 | (value.length >= 1 && currencySymbolInput?.getValue()) 324 | ) && currencySaveButton) { 325 | if (currencySymbolInput?.getValue().match(/^\p{Sc}$/u) || value.length == 0) { 326 | currencySaveButton.setDisabled(false); 327 | currencySaveButton.buttonEl.style.color = "var(--text-normal)"; 328 | currencySaveButton.setButtonText('Save'); 329 | } else { 330 | currencySaveButton.setDisabled(true); 331 | currencySaveButton.buttonEl.style.color = "var(--text-error)"; 332 | currencySaveButton.setButtonText('Error'); 333 | } 334 | } else if (currencySaveButton) { 335 | currencySaveButton.setDisabled(true); 336 | currencySaveButton.buttonEl.style.color = "var(--text-faint)"; 337 | currencySaveButton.setButtonText('Save'); 338 | } 339 | }); 340 | text.inputEl.setAttribute("maxlength", "3"); 341 | text.inputEl.style.width = "6em"; 342 | text.inputEl.style.textAlign = "center"; 343 | currencyCodeInput = text; 344 | }) 345 | .addButton(button => { button 346 | .setButtonText('Save') 347 | .setDisabled(true) 348 | .setTooltip('Save custom currency mapping') 349 | .onClick(async (evt) => { 350 | if (currencySymbolInput && currencyCodeInput) { 351 | const currencySymbol = currencySymbolInput.getValue(); 352 | const currencyCode = currencyCodeInput.getValue(); 353 | if(currencySymbol.match(/^\p{Sc}$/u)) { 354 | this.plugin.settings.customCurrencySymbol = { 355 | symbol: currencySymbol, 356 | currency: currencyCode, 357 | unicode: "x" + currencySymbol 358 | .charCodeAt(0) 359 | .toString(16) 360 | .toUpperCase() 361 | .padStart(4, '0'), 362 | name: "custom", 363 | } 364 | } else if (currencySymbol.length == 0) { 365 | this.plugin.settings.customCurrencySymbol = null; 366 | } 367 | await this.plugin.saveSettings(); 368 | console.log(this.plugin.settings.customCurrencySymbol); 369 | button.setDisabled(true); 370 | button.buttonEl.style.color = "var(--text-faint)"; 371 | button.setButtonText('✓'); 372 | setTimeout(() => { 373 | button.setButtonText('Save'); 374 | }, 1000); 375 | this.plugin.updateCurrencyMap(); 376 | } 377 | }); 378 | button.buttonEl.style.color = "var(--text-faint)"; 379 | button.buttonEl.style.width = "4em"; 380 | currencySaveButton = button; 381 | 382 | }); 383 | 384 | new Setting(containerEl) 385 | .setHeading() 386 | .setName('Obsidian Integration'); 387 | 388 | new Setting(containerEl) 389 | .setName('Always Process All Frontmatter') 390 | .setDesc(htmlToElements(`Always process all frontmatter values and make them available as variables in \`math\` blocks
` 391 | + `
Note: To process frontmatter values on a per file and/or per property basis, set a value for the \`numerals\` property in a file's frontmatter.` 392 | + ` Supported values are:
  • all
  • specific property to process
  • a list/array of properties to process

`)) 393 | .addToggle(toggle => toggle 394 | .setValue(this.plugin.settings.forceProcessAllFrontmatter) 395 | .onChange(async (value) => { 396 | this.plugin.settings.forceProcessAllFrontmatter = value; 397 | await this.plugin.saveSettings(); 398 | } 399 | )); 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /utilities/mathjs_symbol_parse.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 23, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "# source: https://mathjs.org/docs/reference/functions.html\n", 10 | "\n", 11 | "mathjs_function_list = \"\"\"\n", 12 | "Expression functions\n", 13 | "Function\tDescription\n", 14 | "math.compile(expr)\tParse and compile an expression.\n", 15 | "math.evaluate(expr [, scope])\tEvaluate an expression.\n", 16 | "math.help(search)\tRetrieve help on a function or data type.\n", 17 | "math.parser()\tCreate a parser.\n", 18 | "Algebra functions\n", 19 | "Function\tDescription\n", 20 | "derivative(expr, variable)\tTakes the derivative of an expression expressed in parser Nodes.\n", 21 | "leafCount(expr)\tGives the number of “leaf nodes” in the parse tree of the given expression A leaf node is one that has no subexpressions, essentially either a symbol or a constant.\n", 22 | "math.lsolve(L, b)\tFinds one solution of a linear equation system by forwards substitution.\n", 23 | "math.lsolveAll(L, b)\tFinds all solutions of a linear equation system by forwards substitution.\n", 24 | "math.lup(A)\tCalculate the Matrix LU decomposition with partial pivoting.\n", 25 | "math.lusolve(A, b)\tSolves the linear system A * x = b where A is an [n x n] matrix and b is a [n] column vector.\n", 26 | "math.lyap(A, Q)\tSolves the Continuous-time Lyapunov equation AP+PA’+Q=0 for P, where Q is an input matrix.\n", 27 | "polynomialRoot(constant, linearCoeff, quadraticCoeff, cubicCoeff)\tFinds the numerical values of the distinct roots of a polynomial with real or complex coefficients.\n", 28 | "math.qr(A)\tCalculate the Matrix QR decomposition.\n", 29 | "rationalize(expr)\tTransform a rationalizable expression in a rational fraction.\n", 30 | "resolve(expr, scope)\tresolve(expr, scope) replaces variable nodes with their scoped values.\n", 31 | "math.schur(A)\tPerforms a real Schur decomposition of the real matrix A = UTU’ where U is orthogonal and T is upper quasi-triangular.\n", 32 | "simplify(expr)\tSimplify an expression tree.\n", 33 | "simplifyConstant(expr)\tsimplifyConstant() takes a mathjs expression (either a Node representing a parse tree or a string which it parses to produce a node), and replaces any subexpression of it consisting entirely of constants with the computed value of that subexpression.\n", 34 | "simplifyCore(expr)\tsimplifyCore() performs single pass simplification suitable for applications requiring ultimate performance.\n", 35 | "math.slu(A, order, threshold)\tCalculate the Sparse Matrix LU decomposition with full pivoting.\n", 36 | "math.sylvester(A, B, C)\tSolves the real-valued Sylvester equation AX+XB=C for X, where A, B and C are matrices of appropriate dimensions, being A and B squared.\n", 37 | "symbolicEqual(expr1, expr2)\tAttempts to determine if two expressions are symbolically equal, i.\n", 38 | "math.usolve(U, b)\tFinds one solution of a linear equation system by backward substitution.\n", 39 | "math.usolveAll(U, b)\tFinds all solutions of a linear equation system by backward substitution.\n", 40 | "Arithmetic functions\n", 41 | "Function\tDescription\n", 42 | "math.abs(x)\tCalculate the absolute value of a number.\n", 43 | "math.add(x, y)\tAdd two or more values, x + y.\n", 44 | "math.cbrt(x [, allRoots])\tCalculate the cubic root of a value.\n", 45 | "math.ceil(x)\tRound a value towards plus infinity If x is complex, both real and imaginary part are rounded towards plus infinity.\n", 46 | "math.cube(x)\tCompute the cube of a value, x * x * x.\n", 47 | "math.divide(x, y)\tDivide two values, x / y.\n", 48 | "math.dotDivide(x, y)\tDivide two matrices element wise.\n", 49 | "math.dotMultiply(x, y)\tMultiply two matrices element wise.\n", 50 | "math.dotPow(x, y)\tCalculates the power of x to y element wise.\n", 51 | "math.exp(x)\tCalculate the exponential of a value.\n", 52 | "math.expm1(x)\tCalculate the value of subtracting 1 from the exponential value.\n", 53 | "math.fix(x)\tRound a value towards zero.\n", 54 | "math.floor(x)\tRound a value towards minus infinity.\n", 55 | "math.gcd(a, b)\tCalculate the greatest common divisor for two or more values or arrays.\n", 56 | "math.hypot(a, b, …)\tCalculate the hypotenusa of a list with values.\n", 57 | "math.invmod(a, b)\tCalculate the (modular) multiplicative inverse of a modulo b.\n", 58 | "math.lcm(a, b)\tCalculate the least common multiple for two or more values or arrays.\n", 59 | "math.log(x [, base])\tCalculate the logarithm of a value.\n", 60 | "math.log10(x)\tCalculate the 10-base logarithm of a value.\n", 61 | "math.log1p(x)\tCalculate the logarithm of a value+1.\n", 62 | "math.log2(x)\tCalculate the 2-base of a value.\n", 63 | "math.mod(x, y)\tCalculates the modulus, the remainder of an integer division.\n", 64 | "math.multiply(x, y)\tMultiply two or more values, x * y.\n", 65 | "math.norm(x [, p])\tCalculate the norm of a number, vector or matrix.\n", 66 | "math.nthRoot(a)\tCalculate the nth root of a value.\n", 67 | "math.nthRoots(x)\tCalculate the nth roots of a value.\n", 68 | "math.pow(x, y)\tCalculates the power of x to y, x ^ y.\n", 69 | "math.round(x [, n])\tRound a value towards the nearest integer.\n", 70 | "math.sign(x)\tCompute the sign of a value.\n", 71 | "math.sqrt(x)\tCalculate the square root of a value.\n", 72 | "math.square(x)\tCompute the square of a value, x * x.\n", 73 | "math.subtract(x, y)\tSubtract two values, x - y.\n", 74 | "math.unaryMinus(x)\tInverse the sign of a value, apply a unary minus operation.\n", 75 | "math.unaryPlus(x)\tUnary plus operation.\n", 76 | "math.xgcd(a, b)\tCalculate the extended greatest common divisor for two values.\n", 77 | "Bitwise functions\n", 78 | "Function\tDescription\n", 79 | "math.bitAnd(x, y)\tBitwise AND two values, x & y.\n", 80 | "math.bitNot(x)\tBitwise NOT value, ~x.\n", 81 | "math.bitOr(x, y)\tBitwise OR two values, x | y.\n", 82 | "math.bitXor(x, y)\tBitwise XOR two values, x ^ y.\n", 83 | "math.leftShift(x, y)\tBitwise left logical shift of a value x by y number of bits, x << y.\n", 84 | "math.rightArithShift(x, y)\tBitwise right arithmetic shift of a value x by y number of bits, x >> y.\n", 85 | "math.rightLogShift(x, y)\tBitwise right logical shift of value x by y number of bits, x >>> y.\n", 86 | "Combinatorics functions\n", 87 | "Function\tDescription\n", 88 | "math.bellNumbers(n)\tThe Bell Numbers count the number of partitions of a set.\n", 89 | "math.catalan(n)\tThe Catalan Numbers enumerate combinatorial structures of many different types.\n", 90 | "math.composition(n, k)\tThe composition counts of n into k parts.\n", 91 | "math.stirlingS2(n, k)\tThe Stirling numbers of the second kind, counts the number of ways to partition a set of n labelled objects into k nonempty unlabelled subsets.\n", 92 | "Complex functions\n", 93 | "Function\tDescription\n", 94 | "math.arg(x)\tCompute the argument of a complex value.\n", 95 | "math.conj(x)\tCompute the complex conjugate of a complex value.\n", 96 | "math.im(x)\tGet the imaginary part of a complex number.\n", 97 | "math.re(x)\tGet the real part of a complex number.\n", 98 | "Geometry functions\n", 99 | "Function\tDescription\n", 100 | "math.distance([x1, y1], [x2, y2])\tCalculates: The eucledian distance between two points in N-dimensional spaces.\n", 101 | "math.intersect(endPoint1Line1, endPoint2Line1, endPoint1Line2, endPoint2Line2)\tCalculates the point of intersection of two lines in two or three dimensions and of a line and a plane in three dimensions.\n", 102 | "Logical functions\n", 103 | "Function\tDescription\n", 104 | "math.and(x, y)\tLogical and.\n", 105 | "math.not(x)\tLogical not.\n", 106 | "math.or(x, y)\tLogical or.\n", 107 | "math.xor(x, y)\tLogical xor.\n", 108 | "Matrix functions\n", 109 | "Function\tDescription\n", 110 | "math.apply(A, dim, callback)\tApply a function that maps an array to a scalar along a given axis of a matrix or array.\n", 111 | "math.column(value, index)\tReturn a column from a Matrix.\n", 112 | "math.concat(a, b, c, … [, dim])\tConcatenate two or more matrices.\n", 113 | "math.count(x)\tCount the number of elements of a matrix, array or string.\n", 114 | "math.cross(x, y)\tCalculate the cross product for two vectors in three dimensional space.\n", 115 | "math.ctranspose(x)\tTranspose and complex conjugate a matrix.\n", 116 | "math.det(x)\tCalculate the determinant of a matrix.\n", 117 | "math.diag(X)\tCreate a diagonal matrix or retrieve the diagonal of a matrix When x is a vector, a matrix with vector x on the diagonal will be returned.\n", 118 | "math.diff(arr)\tCreate a new matrix or array of the difference between elements of the given array The optional dim parameter lets you specify the dimension to evaluate the difference of If no dimension parameter is passed it is assumed as dimension 0 Dimension is zero-based in javascript and one-based in the parser and can be a number or bignumber Arrays must be ‘rectangular’ meaning arrays like [1, 2] If something is passed as a matrix it will be returned as a matrix but other than that all matrices are converted to arrays.\n", 119 | "math.dot(x, y)\tCalculate the dot product of two vectors.\n", 120 | "math.eigs(x, [prec])\tCompute eigenvalues and eigenvectors of a matrix.\n", 121 | "math.expm(x)\tCompute the matrix exponential, expm(A) = e^A.\n", 122 | "math.fft(arr)\tCalculate N-dimensional fourier transform.\n", 123 | "math.filter(x, test)\tFilter the items in an array or one dimensional matrix.\n", 124 | "math.flatten(x)\tFlatten a multidimensional matrix into a single dimensional matrix.\n", 125 | "math.forEach(x, callback)\tIterate over all elements of a matrix/array, and executes the given callback function.\n", 126 | "math.getMatrixDataType(x)\tFind the data type of all elements in a matrix or array, for example ‘number’ if all items are a number and ‘Complex’ if all values are complex numbers.\n", 127 | "math.identity(n)\tCreate a 2-dimensional identity matrix with size m x n or n x n.\n", 128 | "math.ifft(arr)\tCalculate N-dimensional inverse fourier transform.\n", 129 | "math.inv(x)\tCalculate the inverse of a square matrix.\n", 130 | "math.kron(x, y)\tCalculates the kronecker product of 2 matrices or vectors.\n", 131 | "math.map(x, callback)\tCreate a new matrix or array with the results of a callback function executed on each entry of a given matrix/array.\n", 132 | "math.matrixFromColumns(…arr)\tCreate a dense matrix from vectors as individual columns.\n", 133 | "math.matrixFromFunction(size, fn)\tCreate a matrix by evaluating a generating function at each index.\n", 134 | "math.matrixFromRows(…arr)\tCreate a dense matrix from vectors as individual rows.\n", 135 | "math.ones(m, n, p, …)\tCreate a matrix filled with ones.\n", 136 | "math.partitionSelect(x, k)\tPartition-based selection of an array or 1D matrix.\n", 137 | "math.pinv(x)\tCalculate the Moore–Penrose inverse of a matrix.\n", 138 | "math.range(start, end [, step])\tCreate an array from a range.\n", 139 | "math.reshape(x, sizes)\tReshape a multi dimensional array to fit the specified dimensions.\n", 140 | "math.resize(x, size [, defaultValue])\tResize a matrix.\n", 141 | "math.rotate(w, theta)\tRotate a vector of size 1x2 counter-clockwise by a given angle Rotate a vector of size 1x3 counter-clockwise by a given angle around the given axis.\n", 142 | "math.rotationMatrix(theta)\tCreate a 2-dimensional counter-clockwise rotation matrix (2x2) for a given angle (expressed in radians).\n", 143 | "math.row(value, index)\tReturn a row from a Matrix.\n", 144 | "math.size(x)\tCalculate the size of a matrix or scalar.\n", 145 | "math.sort(x)\tSort the items in a matrix.\n", 146 | "X = math.sqrtm(A)\tCalculate the principal square root of a square matrix.\n", 147 | "math.squeeze(x)\tSqueeze a matrix, remove inner and outer singleton dimensions from a matrix.\n", 148 | "math.subset(x, index [, replacement])\tGet or set a subset of a matrix or string.\n", 149 | "math.trace(x)\tCalculate the trace of a matrix: the sum of the elements on the main diagonal of a square matrix.\n", 150 | "math.transpose(x)\tTranspose a matrix.\n", 151 | "math.zeros(m, n, p, …)\tCreate a matrix filled with zeros.\n", 152 | "Probability functions\n", 153 | "Function\tDescription\n", 154 | "math.combinations(n, k)\tCompute the number of ways of picking k unordered outcomes from n possibilities.\n", 155 | "math.combinationsWithRep(n, k)\tCompute the number of ways of picking k unordered outcomes from n possibilities, allowing individual outcomes to be repeated more than once.\n", 156 | "math.factorial(n)\tCompute the factorial of a value Factorial only supports an integer value as argument.\n", 157 | "math.gamma(n)\tCompute the gamma function of a value using Lanczos approximation for small values, and an extended Stirling approximation for large values.\n", 158 | "math.kldivergence(x, y)\tCalculate the Kullback-Leibler (KL) divergence between two distributions.\n", 159 | "math.lgamma(n)\tLogarithm of the gamma function for real, positive numbers and complex numbers, using Lanczos approximation for numbers and Stirling series for complex numbers.\n", 160 | "math.multinomial(a)\tMultinomial Coefficients compute the number of ways of picking a1, a2, .\n", 161 | "math.permutations(n [, k])\tCompute the number of ways of obtaining an ordered subset of k elements from a set of n elements.\n", 162 | "math.pickRandom(array)\tRandom pick one or more values from a one dimensional array.\n", 163 | "math.random([min, max])\tReturn a random number larger or equal to min and smaller than max using a uniform distribution.\n", 164 | "math.randomInt([min, max])\tReturn a random integer number larger or equal to min and smaller than max using a uniform distribution.\n", 165 | "Relational functions\n", 166 | "Function\tDescription\n", 167 | "math.compare(x, y)\tCompare two values.\n", 168 | "math.compareNatural(x, y)\tCompare two values of any type in a deterministic, natural way.\n", 169 | "math.compareText(x, y)\tCompare two strings lexically.\n", 170 | "math.deepEqual(x, y)\tTest element wise whether two matrices are equal.\n", 171 | "math.equal(x, y)\tTest whether two values are equal.\n", 172 | "math.equalText(x, y)\tCheck equality of two strings.\n", 173 | "math.larger(x, y)\tTest whether value x is larger than y.\n", 174 | "math.largerEq(x, y)\tTest whether value x is larger or equal to y.\n", 175 | "math.smaller(x, y)\tTest whether value x is smaller than y.\n", 176 | "math.smallerEq(x, y)\tTest whether value x is smaller or equal to y.\n", 177 | "math.unequal(x, y)\tTest whether two values are unequal.\n", 178 | "Set functions\n", 179 | "Function\tDescription\n", 180 | "math.setCartesian(set1, set2)\tCreate the cartesian product of two (multi)sets.\n", 181 | "math.setDifference(set1, set2)\tCreate the difference of two (multi)sets: every element of set1, that is not the element of set2.\n", 182 | "math.setDistinct(set)\tCollect the distinct elements of a multiset.\n", 183 | "math.setIntersect(set1, set2)\tCreate the intersection of two (multi)sets.\n", 184 | "math.setIsSubset(set1, set2)\tCheck whether a (multi)set is a subset of another (multi)set.\n", 185 | "math.setMultiplicity(element, set)\tCount the multiplicity of an element in a multiset.\n", 186 | "math.setPowerset(set)\tCreate the powerset of a (multi)set.\n", 187 | "math.setSize(set)\tCount the number of elements of a (multi)set.\n", 188 | "math.setSymDifference(set1, set2)\tCreate the symmetric difference of two (multi)sets.\n", 189 | "math.setUnion(set1, set2)\tCreate the union of two (multi)sets.\n", 190 | "Special functions\n", 191 | "Function\tDescription\n", 192 | "math.erf(x)\tCompute the erf function of a value using a rational Chebyshev approximations for different intervals of x.\n", 193 | "Statistics functions\n", 194 | "Function\tDescription\n", 195 | "math.cumsum(a, b, c, …)\tCompute the cumulative sum of a matrix or a list with values.\n", 196 | "math.mad(a, b, c, …)\tCompute the median absolute deviation of a matrix or a list with values.\n", 197 | "math.max(a, b, c, …)\tCompute the maximum value of a matrix or a list with values.\n", 198 | "math.mean(a, b, c, …)\tCompute the mean value of matrix or a list with values.\n", 199 | "math.median(a, b, c, …)\tCompute the median of a matrix or a list with values.\n", 200 | "math.min(a, b, c, …)\tCompute the minimum value of a matrix or a list of values.\n", 201 | "math.mode(a, b, c, …)\tComputes the mode of a set of numbers or a list with values(numbers or characters).\n", 202 | "math.prod(a, b, c, …)\tCompute the product of a matrix or a list with values.\n", 203 | "math.quantileSeq(A, prob[, sorted])\tCompute the prob order quantile of a matrix or a list with values.\n", 204 | "math.std(a, b, c, …)\tCompute the standard deviation of a matrix or a list with values.\n", 205 | "math.sum(a, b, c, …)\tCompute the sum of a matrix or a list with values.\n", 206 | "math.variance(a, b, c, …)\tCompute the variance of a matrix or a list with values.\n", 207 | "String functions\n", 208 | "Function\tDescription\n", 209 | "math.bin(value)\tFormat a number as binary.\n", 210 | "math.format(value [, precision])\tFormat a value of any type into a string.\n", 211 | "math.hex(value)\tFormat a number as hexadecimal.\n", 212 | "math.oct(value)\tFormat a number as octal.\n", 213 | "math.print(template, values [, precision])\tInterpolate values into a string template.\n", 214 | "Trigonometry functions\n", 215 | "Function\tDescription\n", 216 | "math.acos(x)\tCalculate the inverse cosine of a value.\n", 217 | "math.acosh(x)\tCalculate the hyperbolic arccos of a value, defined as acosh(x) = ln(sqrt(x^2 - 1) + x).\n", 218 | "math.acot(x)\tCalculate the inverse cotangent of a value, defined as acot(x) = atan(1/x).\n", 219 | "math.acoth(x)\tCalculate the hyperbolic arccotangent of a value, defined as acoth(x) = atanh(1/x) = (ln((x+1)/x) + ln(x/(x-1))) / 2.\n", 220 | "math.acsc(x)\tCalculate the inverse cosecant of a value, defined as acsc(x) = asin(1/x).\n", 221 | "math.acsch(x)\tCalculate the hyperbolic arccosecant of a value, defined as acsch(x) = asinh(1/x) = ln(1/x + sqrt(1/x^2 + 1)).\n", 222 | "math.asec(x)\tCalculate the inverse secant of a value.\n", 223 | "math.asech(x)\tCalculate the hyperbolic arcsecant of a value, defined as asech(x) = acosh(1/x) = ln(sqrt(1/x^2 - 1) + 1/x).\n", 224 | "math.asin(x)\tCalculate the inverse sine of a value.\n", 225 | "math.asinh(x)\tCalculate the hyperbolic arcsine of a value, defined as asinh(x) = ln(x + sqrt(x^2 + 1)).\n", 226 | "math.atan(x)\tCalculate the inverse tangent of a value.\n", 227 | "math.atan2(y, x)\tCalculate the inverse tangent function with two arguments, y/x.\n", 228 | "math.atanh(x)\tCalculate the hyperbolic arctangent of a value, defined as atanh(x) = ln((1 + x)/(1 - x)) / 2.\n", 229 | "math.cos(x)\tCalculate the cosine of a value.\n", 230 | "math.cosh(x)\tCalculate the hyperbolic cosine of a value, defined as cosh(x) = 1/2 * (exp(x) + exp(-x)).\n", 231 | "math.cot(x)\tCalculate the cotangent of a value.\n", 232 | "math.coth(x)\tCalculate the hyperbolic cotangent of a value, defined as coth(x) = 1 / tanh(x).\n", 233 | "math.csc(x)\tCalculate the cosecant of a value, defined as csc(x) = 1/sin(x).\n", 234 | "math.csch(x)\tCalculate the hyperbolic cosecant of a value, defined as csch(x) = 1 / sinh(x).\n", 235 | "math.sec(x)\tCalculate the secant of a value, defined as sec(x) = 1/cos(x).\n", 236 | "math.sech(x)\tCalculate the hyperbolic secant of a value, defined as sech(x) = 1 / cosh(x).\n", 237 | "math.sin(x)\tCalculate the sine of a value.\n", 238 | "math.sinh(x)\tCalculate the hyperbolic sine of a value, defined as sinh(x) = 1/2 * (exp(x) - exp(-x)).\n", 239 | "math.tan(x)\tCalculate the tangent of a value.\n", 240 | "math.tanh(x)\tCalculate the hyperbolic tangent of a value, defined as tanh(x) = (exp(2 * x) - 1) / (exp(2 * x) + 1).\n", 241 | "Unit functions\n", 242 | "Function\tDescription\n", 243 | "math.to(x, unit)\tChange the unit of a value.\n", 244 | "Utils functions\n", 245 | "Function\tDescription\n", 246 | "math.clone(x)\tClone an object.\n", 247 | "math.hasNumericValue(x)\tTest whether a value is an numeric value.\n", 248 | "math.isInteger(x)\tTest whether a value is an integer number.\n", 249 | "math.isNaN(x)\tTest whether a value is NaN (not a number).\n", 250 | "math.isNegative(x)\tTest whether a value is negative: smaller than zero.\n", 251 | "math.isNumeric(x)\tTest whether a value is an numeric value.\n", 252 | "math.isPositive(x)\tTest whether a value is positive: larger than zero.\n", 253 | "math.isPrime(x)\tTest whether a value is prime: has no divisors other than itself and one.\n", 254 | "math.isZero(x)\tTest whether a value is zero.\n", 255 | "math.numeric(x)\tConvert a numeric input to a specific numeric type: number, BigNumber, or Fraction.\n", 256 | "math.typeOf(x)\tDetermine the type of an entity.\n", 257 | "\"\"\"\n", 258 | "\n", 259 | "# source: https://mathjs.org/docs/reference/constants.html\n", 260 | "mathjs_constant_list = \\\n", 261 | "\"\"\"e, E\tEuler’s number, the base of the natural logarithm.\t2.718281828459045\n", 262 | "i\tImaginary unit, defined as i * i = -1. A complex number is described as a + b * i, where a is the real part, and b is the imaginary part.\tsqrt(-1)\n", 263 | "Infinity\tInfinity, a number which is larger than the maximum number that can be handled by a floating point number.\tInfinity\n", 264 | "LN2\tReturns the natural logarithm of 2.\t0.6931471805599453\n", 265 | "LN10\tReturns the natural logarithm of 10.\t2.302585092994046\n", 266 | "LOG2E\tReturns the base-2 logarithm of E.\t1.4426950408889634\n", 267 | "LOG10E\tReturns the base-10 logarithm of E.\t0.4342944819032518\n", 268 | "NaN\tNot a number.\tNaN\n", 269 | "null\tValue null.\tnull\n", 270 | "phi\tPhi is the golden ratio. Two quantities are in the golden ratio if their ratio is the same as the ratio of their sum to the larger of the two quantities. Phi is defined as (1 + sqrt(5)) / 2\t1.618033988749895\n", 271 | "pi, PI\tThe number pi is a mathematical constant that is the ratio of a circle's circumference to its diameter.\t3.141592653589793\n", 272 | "SQRT1_2\tReturns the square root of 1/2.\t0.7071067811865476\n", 273 | "SQRT2\tReturns the square root of 2.\t1.4142135623730951\n", 274 | "tau\tTau is the ratio constant of a circle's circumference to radius, equal to 2 * pi.\t6.283185307179586\n", 275 | "\"\"\"\n", 276 | "\n", 277 | "mathjs_physical_constant_list = \\\n", 278 | "\"\"\"speedOfLight\"\"\" " 279 | ] 280 | }, 281 | { 282 | "cell_type": "code", 283 | "execution_count": 3, 284 | "metadata": {}, 285 | "outputs": [], 286 | "source": [ 287 | "import re" 288 | ] 289 | }, 290 | { 291 | "cell_type": "code", 292 | "execution_count": 37, 293 | "metadata": {}, 294 | "outputs": [ 295 | { 296 | "name": "stdout", 297 | "output_type": "stream", 298 | "text": [ 299 | "['c|e', 'c|i', 'c|Infinity', 'c|LN2', 'c|LN10', 'c|LOG2E', 'c|LOG10E', 'c|NaN', 'c|null', 'c|phi', 'c|pi', 'c|SQRT1_2', 'c|SQRT2', 'c|tau']\n" 300 | ] 301 | } 302 | ], 303 | "source": [ 304 | "mathjs_constant_lines = mathjs_constant_list.splitlines();\n", 305 | "mathjs_constant_names = [line.split('\\t', maxsplit=1)[0].split(',', maxsplit=1)[0] for line in mathjs_constant_lines];\n", 306 | "# mathjs_symbol_names.sort(key=lambda x: x.lower());\n", 307 | "\n", 308 | "mathjs_constant_suggestions = [f\"c|{name}\" for name in mathjs_constant_names];\n", 309 | "# print(mathjs_constant_suggestions);\n", 310 | "\n" 311 | ] 312 | }, 313 | { 314 | "cell_type": "code", 315 | "execution_count": 27, 316 | "metadata": {}, 317 | "outputs": [], 318 | "source": [ 319 | "# Function Parsing\n", 320 | "\n", 321 | "# split mathjs_function_list into a list of lines\n", 322 | "mathjs_function_lines = mathjs_function_list.splitlines();\n", 323 | "# remove all lines from mathjs_function_lines that contain only the words \"Function\" and \"Description\"\n", 324 | "mathjs_function_lines = [line for line in mathjs_function_lines if not re.match(r'^\\s*Function\\s+Description\\s*$', line)]\n", 325 | "\n", 326 | "# remove any lines from mathjs_function_lines that don't contain a tab character\n", 327 | "mathjs_function_lines = [line for line in mathjs_function_lines if re.search(r'\\t', line)]\n", 328 | "\n", 329 | "# make a new list of function names from mathjs_function_lines\n", 330 | "mathjs_function_names = [re.sub(r'\\(.*$', '', line.split('\\t')[0]) for line in mathjs_function_lines]\n", 331 | "\n", 332 | "# if function name contains an = sign, remove everything before and including the = sign (and any whitespace after the = sign)\n", 333 | "mathjs_function_names = [re.sub(r'^.*\\s*=\\s*', '', name) for name in mathjs_function_names]\n", 334 | "\n", 335 | "# remove \"math.\" from the beginning of a function name\n", 336 | "mathjs_function_names = [re.sub(r'^math\\.', '', name) for name in mathjs_function_names]\n", 337 | "\n", 338 | "mathjs_function_suggestions = [f\"f|{name}()\" for name in mathjs_function_names];\n", 339 | "\n", 340 | "# print(mathjs_function_suggestions);\n", 341 | "\n" 342 | ] 343 | }, 344 | { 345 | "cell_type": "code", 346 | "execution_count": 28, 347 | "metadata": {}, 348 | "outputs": [ 349 | { 350 | "name": "stdout", 351 | "output_type": "stream", 352 | "text": [ 353 | "['p|speedOfLight']\n" 354 | ] 355 | } 356 | ], 357 | "source": [ 358 | "mathjs_physical_constant_lines = mathjs_physical_constant_list.splitlines();\n", 359 | "mathjs_physical_constant_names = mathjs_physical_constant_lines;\n", 360 | "mathjs_physical_constant_suggestions = [f\"p|{name}\" for name in mathjs_physical_constant_names];\n", 361 | "# print(mathjs_physical_constant_suggestions);" 362 | ] 363 | }, 364 | { 365 | "cell_type": "code", 366 | "execution_count": 35, 367 | "metadata": {}, 368 | "outputs": [ 369 | { 370 | "name": "stdout", 371 | "output_type": "stream", 372 | "text": [ 373 | "const mathjsBuiltInSymbols: string[] = [\n", 374 | "\t'f|abs()', 'f|acos()', 'f|acosh()', 'f|acot()', 'f|acoth()',\n", 375 | "\t'f|acsc()', 'f|acsch()', 'f|add()', 'f|and()', 'f|apply()',\n", 376 | "\t'f|arg()', 'f|asec()', 'f|asech()', 'f|asin()', 'f|asinh()',\n", 377 | "\t'f|atan()', 'f|atan2()', 'f|atanh()', 'f|bellNumbers()', 'f|bin()',\n", 378 | "\t'f|bitAnd()', 'f|bitNot()', 'f|bitOr()', 'f|bitXor()', 'f|catalan()',\n", 379 | "\t'f|cbrt()', 'f|ceil()', 'f|clone()', 'f|column()', 'f|combinations()',\n", 380 | "\t'f|combinationsWithRep()', 'f|compare()', 'f|compareNatural()', 'f|compareText()', 'f|compile()',\n", 381 | "\t'f|composition()', 'f|concat()', 'f|conj()', 'f|cos()', 'f|cosh()',\n", 382 | "\t'f|cot()', 'f|coth()', 'f|count()', 'f|cross()', 'f|csc()',\n", 383 | "\t'f|csch()', 'f|ctranspose()', 'f|cube()', 'f|cumsum()', 'f|deepEqual()',\n", 384 | "\t'f|derivative()', 'f|det()', 'f|diag()', 'f|diff()', 'f|distance()',\n", 385 | "\t'f|divide()', 'f|dot()', 'f|dotDivide()', 'f|dotMultiply()', 'f|dotPow()',\n", 386 | "\t'f|eigs()', 'f|equal()', 'f|equalText()', 'f|erf()', 'f|evaluate()',\n", 387 | "\t'f|exp()', 'f|expm()', 'f|expm1()', 'f|factorial()', 'f|fft()',\n", 388 | "\t'f|filter()', 'f|fix()', 'f|flatten()', 'f|floor()', 'f|forEach()',\n", 389 | "\t'f|format()', 'f|gamma()', 'f|gcd()', 'f|getMatrixDataType()', 'f|hasNumericValue()',\n", 390 | "\t'f|help()', 'f|hex()', 'f|hypot()', 'c|i', 'f|identity()',\n", 391 | "\t'f|ifft()', 'f|im()', 'c|Infinity', 'f|intersect()', 'f|inv()',\n", 392 | "\t'f|invmod()', 'f|isInteger()', 'f|isNaN()', 'f|isNegative()', 'f|isNumeric()',\n", 393 | "\t'f|isPositive()', 'f|isPrime()', 'f|isZero()', 'f|kldivergence()', 'f|kron()',\n", 394 | "\t'f|larger()', 'f|largerEq()', 'f|lcm()', 'f|leafCount()', 'f|leftShift()',\n", 395 | "\t'f|lgamma()', 'c|LN10', 'c|LN2', 'f|log()', 'f|log10()',\n", 396 | "\t'c|LOG10E', 'f|log1p()', 'f|log2()', 'c|LOG2E', 'f|lsolve()',\n", 397 | "\t'f|lsolveAll()', 'f|lup()', 'f|lusolve()', 'f|lyap()', 'f|mad()',\n", 398 | "\t'f|map()', 'f|matrixFromColumns()', 'f|matrixFromFunction()', 'f|matrixFromRows()', 'f|max()',\n", 399 | "\t'f|mean()', 'f|median()', 'f|min()', 'f|mod()', 'f|mode()',\n", 400 | "\t'f|multinomial()', 'f|multiply()', 'c|NaN', 'f|norm()', 'f|not()',\n", 401 | "\t'f|nthRoot()', 'f|nthRoots()', 'c|null', 'f|numeric()', 'f|oct()',\n", 402 | "\t'f|ones()', 'f|or()', 'f|parser()', 'f|partitionSelect()', 'f|permutations()',\n", 403 | "\t'c|phi', 'c|pi', 'f|pickRandom()', 'f|pinv()', 'f|polynomialRoot()',\n", 404 | "\t'f|pow()', 'f|print()', 'f|prod()', 'f|qr()', 'f|quantileSeq()',\n", 405 | "\t'f|random()', 'f|randomInt()', 'f|range()', 'f|rationalize()', 'f|re()',\n", 406 | "\t'f|reshape()', 'f|resize()', 'f|resolve()', 'f|rightArithShift()', 'f|rightLogShift()',\n", 407 | "\t'f|rotate()', 'f|rotationMatrix()', 'f|round()', 'f|row()', 'f|schur()',\n", 408 | "\t'f|sec()', 'f|sech()', 'f|setCartesian()', 'f|setDifference()', 'f|setDistinct()',\n", 409 | "\t'f|setIntersect()', 'f|setIsSubset()', 'f|setMultiplicity()', 'f|setPowerset()', 'f|setSize()',\n", 410 | "\t'f|setSymDifference()', 'f|setUnion()', 'f|sign()', 'f|simplify()', 'f|simplifyConstant()',\n", 411 | "\t'f|simplifyCore()', 'f|sin()', 'f|sinh()', 'f|size()', 'f|slu()',\n", 412 | "\t'f|smaller()', 'f|smallerEq()', 'f|sort()', 'p|speedOfLight', 'f|sqrt()',\n", 413 | "\t'c|SQRT1_2', 'c|SQRT2', 'f|sqrtm()', 'f|square()', 'f|squeeze()',\n", 414 | "\t'f|std()', 'f|stirlingS2()', 'f|subset()', 'f|subtract()', 'f|sum()',\n", 415 | "\t'f|sylvester()', 'f|symbolicEqual()', 'f|tan()', 'f|tanh()', 'c|tau',\n", 416 | "\t'f|to()', 'f|trace()', 'f|transpose()', 'f|typeOf()', 'f|unaryMinus()',\n", 417 | "\t'f|unaryPlus()', 'f|unequal()', 'f|usolve()', 'f|usolveAll()', 'f|variance()',\n", 418 | "\t'f|xgcd()', 'f|xor()', 'f|zeros()',\n", 419 | "];\n" 420 | ] 421 | } 422 | ], 423 | "source": [ 424 | "mathjs_symbol_suggestions = mathjs_constant_suggestions + mathjs_function_suggestions + mathjs_physical_constant_suggestions;\n", 425 | "mathjs_symbol_suggestions.sort(key=lambda symbol_str: symbol_str[2:].lower())\n", 426 | "\n", 427 | "# print the list of symbol names as a typescript array of strings, with 5 names per line\n", 428 | "print('const mathjsBuiltInSymbols: string[] = [')\n", 429 | "for i in range(0, len(mathjs_symbol_suggestions), 5):\n", 430 | " print('\\t' + ', '.join([repr(name) for name in mathjs_symbol_suggestions[i:i+5]]) + ',')\n", 431 | "print('];')\n" 432 | ] 433 | } 434 | ], 435 | "metadata": { 436 | "kernelspec": { 437 | "display_name": "Python 3", 438 | "language": "python", 439 | "name": "python3" 440 | }, 441 | "language_info": { 442 | "codemirror_mode": { 443 | "name": "ipython", 444 | "version": 3 445 | }, 446 | "file_extension": ".py", 447 | "mimetype": "text/x-python", 448 | "name": "python", 449 | "nbconvert_exporter": "python", 450 | "pygments_lexer": "ipython3", 451 | "version": "3.9.10" 452 | }, 453 | "orig_nbformat": 4, 454 | "vscode": { 455 | "interpreter": { 456 | "hash": "94dec495c9e0fd32acf4d73adf2b7ea06657921ac2b54f7227d3a080313ef8c5" 457 | } 458 | } 459 | }, 460 | "nbformat": 4, 461 | "nbformat_minor": 2 462 | } 463 | -------------------------------------------------------------------------------- /src/numeralsUtilities.ts: -------------------------------------------------------------------------------- 1 | import * as math from 'mathjs'; 2 | import { getAPI } from 'obsidian-dataview'; 3 | import { App, TFile, finishRenderMath, renderMath, sanitizeHTMLToDom, MarkdownPostProcessorContext, MarkdownView } from 'obsidian'; 4 | import { NumeralsLayout, NumeralsRenderStyle, NumeralsSettings, CurrencyType, mathjsFormat, NumeralsScope, numeralsBlockInfo } from './numerals.types'; 5 | 6 | // TODO: Addition of variables not adding up 7 | 8 | /** 9 | * Process frontmatter and return updated scope object 10 | * - Numbers are converted to mathjs numbers. Strings are processed as mathjs expressions. 11 | * - Objects are ignored 12 | * - Frontmatter key `numerals` sets which frontmatter keys are processed (none is default)): 13 | * - `numerals: all` processes all frontmatter keys 14 | * - `numerals: none` processes no frontmatter keys 15 | * - `numerals: key1` processes only the frontmatter key `key1` 16 | * - `numerals: [key1, key2, ...]` processes only the listed frontmatter keys 17 | * * 18 | * @param scope Numerals scope object (Map) 19 | * @param frontmatter Frontmatter object 20 | * @returns Updated scope object 21 | */ 22 | export function getScopeFromFrontmatter( 23 | frontmatter: { [key: string]: unknown } | undefined, 24 | scope: NumeralsScope|undefined, 25 | forceAll=false, 26 | stringReplaceMap: StringReplaceMap[] = [], 27 | keysOnly=false 28 | ): NumeralsScope { 29 | 30 | if (!scope) { 31 | scope = new NumeralsScope(); 32 | } 33 | 34 | if (frontmatter && typeof frontmatter === "object") { 35 | let frontmatter_process:{ [key: string]: unknown } = {} 36 | 37 | // Determine which metadata keys to process 38 | if (frontmatter.hasOwnProperty("numerals")) { 39 | if (frontmatter["numerals"] === "none") { 40 | frontmatter_process = {}; 41 | } else if (frontmatter.hasOwnProperty("numerals") && frontmatter["numerals"] === "all") { 42 | // Build frontmatter_process from all keys in frontmatter 43 | for (const [key, value] of Object.entries(frontmatter)) { 44 | if (key !== "numerals") { 45 | frontmatter_process[key] = value; 46 | } 47 | } 48 | } else if (typeof frontmatter["numerals"] === "string") { 49 | if (frontmatter.hasOwnProperty(frontmatter["numerals"])) { 50 | frontmatter_process[frontmatter["numerals"]] = frontmatter[frontmatter["numerals"]]; 51 | } 52 | } else if (Array.isArray(frontmatter["numerals"])) { 53 | for (const key of frontmatter["numerals"]) { 54 | if (frontmatter.hasOwnProperty(key)) { 55 | frontmatter_process[key] = frontmatter[key]; 56 | } 57 | } 58 | } 59 | } else if (forceAll) { 60 | frontmatter_process = frontmatter; 61 | } 62 | 63 | // Iterate through frontmatter and add any key/value pair to frontmatter_process if the key starts with `$` 64 | // These keys are assumed to be numerals globals that are to be added to the scope regardless of the `numerals` key 65 | for (const [key, value] of Object.entries(frontmatter)) { 66 | if (key.startsWith('$')) { 67 | frontmatter_process[key] = value; 68 | } 69 | } 70 | 71 | // if keysOnly is true, only add keys to scope. Otherwise, add values to scope 72 | if (keysOnly === false) { 73 | for (const [key, rawValue] of Object.entries(frontmatter_process)) { 74 | let value = rawValue; 75 | 76 | // If value is a mathjs unit, convert to string representation 77 | value = math.isUnit(value) ? value.valueOf() : value; 78 | 79 | // if processedValue is array-like, take the last element. For inline dataview fields, this generally means the most recent line will be used 80 | if (Array.isArray(value)) { 81 | value = value[value.length - 1]; 82 | } 83 | 84 | if (typeof value === "number") { 85 | scope.set(key, math.number(value)); 86 | } else if (typeof value === "string") { 87 | const processedValue = replaceStringsInTextFromMap(value, stringReplaceMap); 88 | 89 | // Check if the key contains function assignment syntax (e.g., "$v(x)" or "f(x, y)") 90 | const functionAssignmentMatch = key.match(/^([^(]+)\(([^)]*)\)$/); 91 | 92 | if (functionAssignmentMatch) { 93 | // This is a function assignment like "$v(x)" with value "x + $b - $a" 94 | const functionName = functionAssignmentMatch[1]; 95 | const parameters = functionAssignmentMatch[2]; 96 | const fullExpression = `${functionName}(${parameters}) = ${processedValue}`; 97 | 98 | try { 99 | // Evaluate the complete function assignment expression 100 | const evaluatedFunction = math.evaluate(fullExpression, scope); 101 | // Store the function under the function name (without parentheses) 102 | scope.set(functionName, evaluatedFunction); 103 | } catch (error) { 104 | console.error(`Error evaluating function assignment for key ${key}: ${error}`); 105 | } 106 | } else { 107 | // Regular variable assignment 108 | let evaluatedValue; 109 | try { 110 | evaluatedValue = math.evaluate(processedValue, scope); 111 | } catch (error) { 112 | console.error(`Error evaluating frontmatter value for key ${key}: ${error}`); 113 | evaluatedValue = undefined; 114 | } 115 | if (evaluatedValue !== undefined) { 116 | scope.set(key, evaluatedValue); 117 | } 118 | } 119 | } else if (typeof value === "function") { 120 | // Functions (like those cached from previous evaluations) should be stored directly 121 | scope.set(key, value); 122 | } else if (typeof value === "object") { // TODO this is only a problem with Dataview. Can we only use dataview for inline? 123 | // ignore objects 124 | 125 | // TODO. RIght now this means data objects just get dropped. If we could instead use the data from obsidian we could handle it 126 | console.error(`Frontmatter value for key ${key} is an object and will be ignored. ` + 127 | `Considering surrounding the value with quotes (eg \`${key}: "value"\`) to treat it as a string.`); 128 | } 129 | } 130 | } else { 131 | for (const key of Object.keys(frontmatter_process)) { 132 | scope.set(key, undefined); 133 | } 134 | } 135 | 136 | return scope; 137 | } else { 138 | return scope; 139 | } 140 | } 141 | 142 | /** 143 | * Add globals from a scope to the Numerals page cache 144 | * 145 | * Globals are keys in the scope Map that start with `$` 146 | * @param sourcePath Path of the source file 147 | * @param scope Scope object 148 | * @returns void 149 | */ 150 | export function addGobalsFromScopeToPageCache(sourcePath: string, scope: NumeralsScope, scopeCache: Map) { 151 | for (const [key, value] of scope.entries()) { 152 | if (key.startsWith('$')) { 153 | if (scopeCache.has(sourcePath)) { 154 | scopeCache.get(sourcePath)?.set(key, value); 155 | } else { 156 | const newScope = new NumeralsScope(); 157 | newScope.set(key, value); 158 | scopeCache.set(sourcePath, newScope); 159 | } 160 | } 161 | } 162 | } 163 | 164 | export interface StringReplaceMap { 165 | regex: RegExp; 166 | replaceStr: string; 167 | } 168 | 169 | /** 170 | * Process a block of text to convert from Numerals syntax to MathJax syntax 171 | * @param text Text to process 172 | * @param stringReplaceMap Array of StringReplaceMap objects to use for replacement 173 | * @returns Processed text 174 | */ 175 | export function replaceStringsInTextFromMap(text: string, stringReplaceMap: StringReplaceMap[]): string { 176 | for (const processor of stringReplaceMap ) { 177 | text = text.replace(processor.regex, processor.replaceStr) 178 | } 179 | return text; 180 | } 181 | 182 | /** 183 | * Retrieves metadata for a file at the specified path. 184 | * 185 | * This function takes a source path as input and retrieves the metadata associated with the file at that path. 186 | * It first checks the metadata cache for the file and retrieves the frontmatter. 187 | * If the file is a Dataview file, it also retrieves the Dataview metadata. 188 | * The function then combines the frontmatter and Dataview metadata, with the Dataview metadata taking precedence. 189 | * 190 | * @param sourcePath - The path of the file for which to retrieve metadata. 191 | * @param app - The Obsidian App instance. 192 | * @param scopeCache - A Map containing NumeralsScope objects for each file path. 193 | * @returns The metadata for the file, including both frontmatter and Dataview metadata. 194 | */ 195 | export function getMetadataForFileAtPath( 196 | sourcePath: string, 197 | app: App, 198 | scopeCache: Map 199 | ): {[key: string]: unknown} | undefined { 200 | const f_path:string = sourcePath; 201 | const handle = app.vault.getAbstractFileByPath(f_path); 202 | const f_handle = (handle instanceof TFile) ? handle : undefined; 203 | const f_cache = f_handle ? app.metadataCache.getFileCache(f_handle as TFile) : undefined; 204 | const frontmatter:{[key: string]: unknown} | undefined = {...(f_cache?.frontmatter), position: undefined}; 205 | 206 | const dataviewAPI = getAPI(); 207 | let dataviewMetadata:{[key: string]: unknown} | undefined; 208 | if (dataviewAPI) { 209 | const dataviewPage = dataviewAPI.page(f_path) 210 | dataviewMetadata = {...dataviewPage, file: undefined, position: undefined} 211 | } 212 | 213 | const numeralsPageScope = scopeCache.get(f_path); 214 | const numeralsPageScopeMetadata:{[key: string]: unknown} = numeralsPageScope ? Object.fromEntries(numeralsPageScope) : {}; 215 | 216 | // combine frontmatter and dataview metadata, with dataview metadata taking precedence and numerals scope taking precedence over both 217 | const metadata = {...frontmatter, ...dataviewMetadata, ...numeralsPageScopeMetadata}; 218 | return metadata; 219 | } 220 | 221 | /** 222 | * Renders a Numerals block from a given source string, using provided metadata and settings. 223 | * 224 | * This function takes a source string, which represents a block of Numerals code, and processes it 225 | * to generate a rendered Numerals block. The block is appended to a given HTML element. The function 226 | * also uses provided metadata and settings to control the rendering process. 227 | * 228 | * @param el - The HTML element to which the rendered Numerals block is appended. 229 | * @param source - The source string representing the Numerals block to be rendered. 230 | * @param metadata - An object containing metadata that is used during the rendering process. This 231 | * metadata can include information about the Numerals block, such as frontmatter keys and values. 232 | * @param type - A NumeralsRenderStyle value that specifies the rendering style to be used for the 233 | * Numerals block. 234 | * @param settings - A NumeralsSettings object that provides settings for the rendering process. These 235 | * settings can control aspects such as the layout style, whether to alternate row colors, and whether 236 | * to hide lines without markup when emitting. 237 | * @param numberFormat - A mathjsFormat function that is used to format numbers in the Numerals block. 238 | * @param preProcessors - An array of StringReplaceMap objects that specify text replacements to be 239 | * made in the source string before it is processed. 240 | * @param app - The Obsidian App instance. 241 | * 242 | * @returns void 243 | * 244 | */ 245 | export function processAndRenderNumeralsBlockFromSource( 246 | el: HTMLElement, 247 | source: string, 248 | ctx: MarkdownPostProcessorContext, 249 | metadata: {[key: string]: unknown} | undefined, 250 | type: NumeralsRenderStyle, 251 | settings: NumeralsSettings, 252 | numberFormat: mathjsFormat, 253 | preProcessors: StringReplaceMap[], 254 | app: App 255 | ): NumeralsScope { 256 | 257 | const blockRenderStyle: NumeralsRenderStyle = type 258 | ? type 259 | : settings.defaultRenderStyle; 260 | 261 | 262 | const { rawRows, processedSource, blockInfo } = 263 | preProcessBlockForNumeralsDirectives(source, preProcessors); 264 | 265 | const { 266 | emitter_lines, 267 | insertion_lines, 268 | hidden_lines, 269 | shouldHideNonEmitterLines 270 | } = blockInfo; 271 | 272 | applyBlockStyles({ 273 | el, 274 | settings, 275 | blockRenderStyle, 276 | hasEmitters: emitter_lines.length > 0, 277 | }); 278 | 279 | const scope = getScopeFromFrontmatter( 280 | metadata, 281 | undefined, 282 | settings.forceProcessAllFrontmatter, 283 | preProcessors 284 | ); 285 | 286 | const { results, inputs, errorMsg, errorInput } = evaluateMathFromSourceStrings( 287 | processedSource, 288 | scope 289 | ); 290 | 291 | // Render each line 292 | for (let i = 0; i < inputs.length; i++) { 293 | 294 | // TODO - extract this into a separate function 295 | if (insertion_lines.includes(i)) { 296 | const sectionInfo = ctx.getSectionInfo(el); 297 | const lineStart = sectionInfo?.lineStart; 298 | 299 | if (lineStart !== undefined) { 300 | const editor = app.workspace.getActiveViewOfType(MarkdownView)?.editor; 301 | const curLine = lineStart + i + 1; 302 | const sourceLine = editor?.getLine(curLine); 303 | const insertionValue = math.format(results[i], numberFormat); 304 | const modifiedSource = sourceLine?.replace(/(@\s*\[)([^\]:]+)(::([^\]]*))?(\].*)$/gm, `$1$2::${insertionValue}$5`) 305 | if (modifiedSource && modifiedSource !== sourceLine) { 306 | setTimeout(() => { 307 | editor?.setLine(curLine, modifiedSource) 308 | }, 0); 309 | } 310 | } 311 | } 312 | 313 | if ( 314 | hidden_lines.includes(i) || 315 | (shouldHideNonEmitterLines && !emitter_lines.includes(i)) 316 | ) { 317 | continue; 318 | } 319 | 320 | const line = el.createEl("div", {cls: "numerals-line"}); 321 | const emptyLine = (results[i] === undefined) 322 | 323 | // if line is an emitter lines, add numerals-emitter class 324 | if (emitter_lines.includes(i)) { 325 | line.toggleClass("numerals-emitter", true); 326 | } 327 | 328 | // if hideEmitters setting is true, remove => from the raw text (already removed from processed text) 329 | if (settings.hideEmitterMarkupInInput) { 330 | rawRows[i] = rawRows[i].replace(/^([^#\r\n]*?)([\t ]*=>[\t ]*)(\$\{.*\})?(.*)$/gm,"$1$4") 331 | } 332 | 333 | // Remove result insertion directive `@[variable::result]` from raw text, and only show the variable 334 | rawRows[i] = rawRows[i].replace(/@\s*\[([^\]:]+)(::[^\]]*)?\](.*)$/gm, "$1$3") 335 | 336 | let inputElement: HTMLElement, resultElement: HTMLElement; 337 | switch(blockRenderStyle) { 338 | case NumeralsRenderStyle.Plain: { 339 | const rawInputSansComment = rawRows[i].replace(/#.+$/, "") 340 | const inputText = emptyLine ? rawRows[i] : rawInputSansComment; 341 | 342 | if (/@sum|@total/i.test(inputText)) { 343 | const parts = inputText.match(/([^\r\n]*?)(@sum|@total)([^\r\n]*?)$/i) || [inputText, "", ""]; 344 | inputElement = line.createEl("span", {cls: "numerals-input"}); 345 | inputElement.createEl("span", {text: parts[1]}); 346 | inputElement.createEl("span", {text: parts[2], cls: "numerals-sum"}); 347 | inputElement.createEl("span", {text: parts[3]}); 348 | } else { 349 | inputElement = line.createEl("span", { text: inputText, cls: "numerals-input"}); 350 | } 351 | 352 | 353 | const formattedResult = !emptyLine ? settings.resultSeparator + math.format(results[i], numberFormat) : '\xa0'; 354 | resultElement = line.createEl("span", { text: formattedResult, cls: "numerals-result" }); 355 | 356 | break; 357 | } case NumeralsRenderStyle.TeX: { 358 | const inputText = emptyLine ? rawRows[i] : ""; // show comments from raw text if no other input 359 | inputElement = line.createEl("span", {text: inputText, cls: "numerals-input"}); 360 | const resultContent = !emptyLine ? "" : '\xa0'; 361 | resultElement = line.createEl("span", { text: resultContent, cls: "numerals-result" }); 362 | if (!emptyLine) { 363 | // Input to Tex 364 | 365 | const preprocess_input_tex:string = math.parse(inputs[i]).toTex(); 366 | 367 | let input_tex:string; 368 | input_tex = replaceSumMagicVariableInProcessedWithSumDirectiveFromRaw(preprocess_input_tex, rawRows[i], "@Sum()"); 369 | input_tex = unescapeSubscripts(input_tex); 370 | input_tex = texCurrencyReplacement(input_tex); 371 | 372 | const inputTexElement = inputElement.createEl("span", {cls: "numerals-tex"}) 373 | mathjaxLoop(inputTexElement, input_tex); 374 | 375 | // Result to Tex 376 | const resultTexElement = resultElement.createEl("span", {cls: "numerals-tex"}) 377 | 378 | // format result to string to get reasonable precision. Commas will be stripped 379 | let processedResult:string = math.format(results[i], getLocaleFormatter('en-US', {useGrouping: false})); 380 | for (const processor of preProcessors ) { 381 | processedResult = processedResult.replace(processor.regex, processor.replaceStr) 382 | } 383 | let texResult = math.parse(processedResult).toTex() // TODO: Add custom handler for numbers to get good localeString formatting 384 | texResult = texCurrencyReplacement(texResult); 385 | mathjaxLoop(resultTexElement, texResult); 386 | } 387 | break; 388 | } case NumeralsRenderStyle.SyntaxHighlight: { 389 | const inputText = emptyLine ? rawRows[i] : ""; // show comments from raw text if no other input 390 | inputElement = line.createEl("span", {text: inputText, cls: "numerals-input"}); 391 | if (!emptyLine) { 392 | const input_html = math.parse(inputs[i]).toHTML(); 393 | const input_elements: DocumentFragment = htmlToElements( 394 | replaceSumMagicVariableInProcessedWithSumDirectiveFromRaw( 395 | input_html, 396 | rawRows[i] 397 | ) 398 | ); 399 | inputElement.appendChild(input_elements); 400 | } 401 | 402 | const formattedResult = !emptyLine ? settings.resultSeparator + math.format(results[i], numberFormat) : '\xa0'; 403 | resultElement = line.createEl("span", { text: formattedResult, cls: "numerals-result" }); 404 | 405 | break; 406 | } 407 | } 408 | 409 | if (!emptyLine) { 410 | const inlineComment = rawRows[i].match(/#.+$/); 411 | if (inlineComment){ 412 | inputElement.createEl("span", {cls: "numerals-inline-comment", text:inlineComment[0]}) 413 | } 414 | } else { 415 | resultElement.toggleClass("numerals-empty", true); 416 | inputElement.toggleClass("numerals-empty", true); 417 | resultElement.setText('\xa0'); 418 | } 419 | } 420 | 421 | 422 | if (errorMsg) { 423 | const line = el.createEl("div", {cls: ["numerals-error-line", "numerals-line"]}); 424 | line.createEl("span", { text: errorInput, cls: "numerals-input"}); 425 | const resultElement = line.createEl("span", {cls: "numerals-result" }); 426 | resultElement.createEl("span", {cls:"numerals-error-name", text: errorMsg.name + ":"}); 427 | resultElement.createEl("span", {cls:"numerals-error-message", text: errorMsg.message}); 428 | } 429 | 430 | return scope; 431 | 432 | } 433 | 434 | /** 435 | * Regular expression for matching variables with subscript notation 436 | * using `\_`. 437 | */ 438 | const subscriptRegex = /(?[\p{L}\p{Nl}_$])(?[\p{L}\p{Nl}_$\u00C0-\u02AF\u0370-\u03FF\u2100-\u214F\u{1D400}-\u{1D7FF}\d]*)(\\_)(?[\p{L}\p{Nl}_$\u00C0-\u02AF\u0370-\u03FF\u2100-\u214F\u{1D400}-\u{1D7FF}\d]+)/gu; 439 | 440 | /** 441 | * Replaces the magic variable for sum in the processed string with either a specified replacement string or the first matching directive from the raw string. 442 | * 443 | * This function searches for occurrences of the magic variable `__total` in the `processedString` and replaces them with either a specified `replacement` string or the first matching sum directive (e.g., `sum` or `total`) found in the `rawString`. If no replacement is specified and no matching directives are found, the magic variable is removed. 444 | * 445 | * @param processedString - The string after initial processing, where the magic variable `__total` needs to be replaced. 446 | * @param rawString - The original raw string, which is searched for sum directives. 447 | * @param replacement - An optional string to replace the magic variable with. If not provided, the function uses the first matching directive from the raw string. 448 | * @returns The `processedString` with the magic variable `__total` replaced as described. 449 | * 450 | * @example 451 | * ```typescript 452 | * const processed = "profit = __total"; 453 | * const raw = "profit = @sum"; 454 | * const output = replaceSumMagicVariableInProcessedWithSumDirectiveFromRaw(processed, raw); 455 | * console.log(output); // "profit = @sum" 456 | */ 457 | export function replaceSumMagicVariableInProcessedWithSumDirectiveFromRaw(processedString:string, rawString: string, replacement:string|undefined = undefined): string { 458 | const directiveRegex = /@(sum|total)\b/g; 459 | const directiveMatches = rawString.match(directiveRegex); 460 | 461 | let restoredInput; 462 | if (replacement) { 463 | restoredInput = processedString.replace(/(__total|\\_\\_total)\b/g, replacement); 464 | } else { 465 | const defaultReplacementDirective = "@Sum"; 466 | restoredInput = processedString.replace(/(__total|\\_\\_total)\b/g, (match) => directiveMatches?.shift() ?? defaultReplacementDirective); 467 | } 468 | 469 | return restoredInput; 470 | } 471 | 472 | /** 473 | * Transforms a given string by unescaping and reformatting subscript notation. 474 | * 475 | * This function takes a string that contains variables with subscript notation, 476 | * where the subscript is written as `\_` followed by the subscript characters 477 | * (e.g. `var\_subscript`), and reformat it to use underscore and curly braces 478 | * (e.g. `var_{subscript}`). 479 | * 480 | * The function is useful for processing strings that represent mathematical 481 | * notation or code, and need to be reformatted into a more standardized or 482 | * readable subscript notation. 483 | * 484 | * @param input - A string potentially containing variables with subscript 485 | * notation using `\_`. 486 | * 487 | * @returns The input string with the subscript notation reformatted, where each 488 | * `var\_subscript` is replaced with `var_{subscript}`. 489 | * 490 | * @example 491 | * ```typescript 492 | * const input = "a\_1 + b\_2 = c\_3"; 493 | * const output = unescapeSubscripts(input); 494 | * console.log(output); // "a_{1} + b_{2} = c_{3}" 495 | * ``` 496 | */ 497 | export function unescapeSubscripts(input: string): string { 498 | const output = input.replace(subscriptRegex, (match, varStart, varBody, _, varEnd) => { 499 | return `${varStart}${varBody}_{${varEnd}}`; 500 | }); 501 | 502 | return output; 503 | } 504 | 505 | 506 | // TODO: Add a switch for only rendering input 507 | 508 | export const defaultCurrencyMap: CurrencyType[] = [ 509 | { symbol: "$", unicode: "x024", name: "dollar", currency: "USD"}, 510 | { symbol: "€", unicode: "x20AC", name: "euro", currency: "EUR"}, 511 | { symbol: "£", unicode: "x00A3", name: "pound", currency: "GBP"}, 512 | { symbol: "¥", unicode: "x00A5", name: "yen", currency: "JPY"}, 513 | { symbol: "₹", unicode: "x20B9", name: "rupee", currency: "INR"} 514 | ]; 515 | 516 | // TODO: see if would be faster to return a single set of RegEx to get executed, rather than re-computing regex each time 517 | /** 518 | * Replaces currency symbols in a given TeX string with their corresponding TeX command. 519 | * 520 | * This function takes a TeX string as input, and replaces all occurrences of currency symbols 521 | * (e.g., "$", "€", "£", "¥", "₹") with their corresponding TeX command (e.g., "\dollar", "\euro", 522 | * "\pound", "\yen", "\rupee"). The mapping between symbols and commands is defined by the 523 | * `defaultCurrencyMap` array. 524 | * 525 | * @param input_tex - The input TeX string, potentially containing currency symbols. 526 | * 527 | * @returns The input string with all currency symbols replaced with their corresponding TeX command. 528 | */ 529 | function texCurrencyReplacement(input_tex:string) { 530 | for (const symbolType of defaultCurrencyMap) { 531 | input_tex = input_tex.replace(RegExp("\\\\*\\"+symbolType.symbol,'g'),"\\" + symbolType.name + " "); 532 | } 533 | return input_tex 534 | } 535 | 536 | 537 | /** 538 | * Converts a string of HTML into a DocumentFragment continaing a sanitized collection array of DOM elements. 539 | * 540 | * @param html The HTML string to convert. 541 | * @returns A DocumentFragment contaning DOM elements. 542 | */ 543 | export function htmlToElements(html: string): DocumentFragment { 544 | const sanitizedHTML = sanitizeHTMLToDom(html); 545 | return sanitizedHTML; 546 | } 547 | 548 | async function mathjaxLoop(container: HTMLElement, value: string) { 549 | const html = renderMath(value, true); 550 | await finishRenderMath() 551 | 552 | // container.empty(); 553 | container.append(html); 554 | } 555 | 556 | /** 557 | * Return a function that formats a number according to the given locale 558 | * @param locale Locale to use 559 | * @param options Options to use (see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat) 560 | * @returns Function that calls toLocaleString with given locale 561 | */ 562 | export function getLocaleFormatter( 563 | locale: Intl.LocalesArgument | undefined = undefined, 564 | options: Intl.NumberFormatOptions | undefined = undefined 565 | ): (value: number) => string { 566 | if (locale === undefined) { 567 | return (value: number): string => value.toLocaleString(); 568 | } else if (options === undefined) { 569 | return (value: number): string => value.toLocaleString(locale); 570 | } else { 571 | return (value: number): string => value.toLocaleString(locale, options); 572 | } 573 | } 574 | 575 | export const numeralsLayoutClasses = { 576 | [NumeralsLayout.TwoPanes]: "numerals-panes", 577 | [NumeralsLayout.AnswerRight]: "numerals-answer-right", 578 | [NumeralsLayout.AnswerBelow]: "numerals-answer-below", 579 | [NumeralsLayout.AnswerInline]: "numerals-answer-inline", 580 | } 581 | 582 | export const numeralsRenderStyleClasses = { 583 | [NumeralsRenderStyle.Plain]: "numerals-plain", 584 | [NumeralsRenderStyle.TeX]: "numerals-tex", 585 | [NumeralsRenderStyle.SyntaxHighlight]: "numerals-syntax", 586 | } 587 | 588 | /** 589 | * Applies the styles specified in the given settings to the given HTML element. 590 | * 591 | * This function takes an HTML element and a NumeralsSettings object, and applies the styles 592 | * specified in the settings to the element. The function modifies the element's class list to 593 | * add or remove classes based on the settings. 594 | * 595 | * @param el - The HTML element to which to apply the styles. 596 | * @param settings - A NumeralsSettings object 597 | * @param blockRenderStyle - A NumeralsRenderStyle value that specifies the rendering style to be used for the 598 | * Numerals block. 599 | */ 600 | export function applyBlockStyles({ 601 | el, 602 | settings, 603 | blockRenderStyle, 604 | hasEmitters = false 605 | }: { 606 | el: HTMLElement, 607 | settings: NumeralsSettings, 608 | blockRenderStyle: NumeralsRenderStyle, 609 | hasEmitters?: boolean 610 | }) { 611 | el.toggleClass("numerals-block", true); 612 | el.toggleClass(numeralsLayoutClasses[settings.layoutStyle], true); 613 | el.toggleClass(numeralsRenderStyleClasses[blockRenderStyle], true); 614 | el.toggleClass("numerals-alt-row-color", settings.alternateRowColor) 615 | 616 | if (hasEmitters) { 617 | el.toggleClass("numerals-emitters-present", true); 618 | el.toggleClass("numerals-hide-non-emitters", settings.hideLinesWithoutMarkupWhenEmitting); 619 | } 620 | } 621 | 622 | /** 623 | * Pre-processes a block of text to apply and remove Numerals directives and apply any pre-processors. 624 | * Source should be ready to be processed directly by mathjs after this function. 625 | * 626 | * @param source - The source string to process. 627 | * @param preProcessors - An array of StringReplaceMap objects that specify text replacements to be 628 | * made in the source string before it is processed. 629 | * @returns An object containing the processed source string, the emitter lines, and the result 630 | * insertion lines. 631 | */ 632 | export function preProcessBlockForNumeralsDirectives( 633 | source: string, 634 | preProcessors: StringReplaceMap[] | undefined, 635 | ): { 636 | rawRows: string[], 637 | processedSource: string, 638 | blockInfo: numeralsBlockInfo 639 | } { 640 | 641 | const rawRows: string[] = source.split("\n"); 642 | let processedSource:string = source; 643 | 644 | const emitter_lines: number[] = []; 645 | const insertion_lines: number[] = []; 646 | const hidden_lines: number[] = []; 647 | let shouldHideNonEmitterLines = false; 648 | 649 | // Find emitter and result insertion lines before modifying source 650 | for (let i = 0; i < rawRows.length; i++) { 651 | 652 | // Find emitter lines (lines that end with `=>`) 653 | if (rawRows[i].match(/^[^#\r\n]*=>.*$/)) { 654 | emitter_lines.push(i); 655 | } 656 | 657 | // Find result insertion lines (lines that match `@[variable::result]`) 658 | const insertionMatch = rawRows[i].match(/@\s*\[([^\]:]+)(::)?([^\]]*)\].*$/); 659 | if (insertionMatch) { 660 | insertion_lines.push(i) 661 | } 662 | 663 | // Find hideRows directives (starts with @hideRows, ignoring whitespace) 664 | if (rawRows[i].match(/^\s*@hideRows\s*$/)) { 665 | hidden_lines.push(i); 666 | shouldHideNonEmitterLines = true; 667 | } 668 | 669 | // Find @createUnit directives (starts with @createUnit, ignoring whitespace) 670 | if (rawRows[i].match(/^\s*@createUnit\s*$/)) { 671 | hidden_lines.push(i); 672 | } 673 | } 674 | 675 | // remove `=>` at the end of lines, but preserve comments. 676 | processedSource = processedSource.replace(/^([^#\r\n]*?)([\t ]*=>[\t ]*)(\$\{.*\})?(.*)$/gm,"$1") 677 | 678 | // Replace Directives 679 | // Replace result insertion directive `@[variable::result]` with only the variable 680 | processedSource = processedSource.replace(/@\s*\[([^\]:]+)(::[^\]]*)?\](.*)$/gm, "$1$3") 681 | 682 | // Replace sum and prev directives 683 | processedSource = processedSource.replace(/@sum/gi, "__total"); 684 | processedSource = processedSource.replace(/@total/gi, "__total"); 685 | processedSource = processedSource.replace(/@prev/gi, "__prev"); 686 | 687 | // Remove @hideRows directive 688 | processedSource = processedSource.replace(/^\s*@hideRows/gim, ""); 689 | 690 | // Apply any pre-processors (e.g. currency replacement, thousands separator replacement, etc.) 691 | if (preProcessors && preProcessors.length > 0) { 692 | processedSource = replaceStringsInTextFromMap(processedSource, preProcessors); 693 | } 694 | 695 | return { 696 | rawRows, 697 | processedSource, 698 | blockInfo: { 699 | emitter_lines, 700 | insertion_lines, 701 | hidden_lines, 702 | shouldHideNonEmitterLines 703 | } 704 | } 705 | } 706 | 707 | /** 708 | * Evaluates a block of math expressions and returns the results. Each row is evaluated separately 709 | * and the results are returned in an array. If an error occurs, the error message and the input that 710 | * caused the error are returned. 711 | * 712 | * @remarks 713 | * This function uses the mathjs library to evaluate the expressions. The scope parameter is used to 714 | * provide variables and functions that can be used in the expressions. The scope is a Map object 715 | * where the keys are the variable names and the values are the variable values. 716 | * 717 | * All Numerals directive must be removed from the source before calling this function as it is processed 718 | * directly by mathjs. 719 | * 720 | * @param processedSource The source string to evaluate 721 | * @param scope The scope object to use for the evaluation 722 | * @returns An object containing the results of the evaluation, the inputs that were evaluated, and 723 | * any error message and input that caused the error. 724 | */ 725 | export function evaluateMathFromSourceStrings( 726 | processedSource: string, 727 | scope: NumeralsScope 728 | ): { 729 | results: unknown[]; 730 | inputs: string[]; 731 | errorMsg: Error | null; 732 | errorInput: string; 733 | } { 734 | let errorMsg = null; 735 | let errorInput = ""; 736 | 737 | const rows: string[] = processedSource.split("\n"); 738 | const results: unknown[] = []; 739 | const inputs: string[] = []; 740 | 741 | // Last row is empty in reader view, so ignore it if empty 742 | const isLastRowEmpty = rows.slice(-1)[0] === ""; 743 | const rowsToProcess = isLastRowEmpty ? rows.slice(0, -1) : rows; 744 | 745 | for (const [index, row] of rowsToProcess.entries()) { 746 | const lastUndefinedRowIndex = results.slice(0, index).lastIndexOf(undefined); 747 | 748 | try { 749 | if (index > 0 && results.length > 0) { 750 | const prevResult = results[results.length - 1]; 751 | scope.set("__prev", prevResult); 752 | } else { 753 | scope.set("__prev", undefined); 754 | if (/__prev/i.test(row)) { 755 | errorMsg = {name: "Previous Value Error", message: 'Error evaluating @prev directive. There is no previous result.'}; 756 | errorInput = row; 757 | break; 758 | } 759 | } 760 | 761 | const partialResults = results.slice(lastUndefinedRowIndex+1, index).filter(result => result !== undefined); 762 | if (partialResults.length > 1) { 763 | try { 764 | // eslint-disable-next-line prefer-spread 765 | const rollingSum = math.add.apply(math, partialResults); 766 | scope.set("__total", rollingSum); 767 | } catch (error) { 768 | scope.set("__total", undefined); 769 | // TODO consider doing this check before evaluating 770 | if (/__total/i.test(row)) { 771 | errorMsg = {name: "Summing Error", message: 'Error evaluating @sum or @total directive. Previous lines may not be summable.'}; 772 | errorInput = row; 773 | break; 774 | } 775 | } 776 | 777 | } else if (partialResults.length === 1) { 778 | scope.set("__total", partialResults[0]); 779 | } else { 780 | scope.set("__total", undefined); 781 | } 782 | results.push(math.evaluate(row, scope)); 783 | inputs.push(row); // Only pushes if evaluate is successful 784 | } catch (error) { 785 | errorMsg = error; 786 | errorInput = row; 787 | break; 788 | } 789 | } 790 | 791 | return { results, inputs, errorMsg, errorInput }; 792 | } 793 | 794 | -------------------------------------------------------------------------------- /tests/numeralsUtilities.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock( 2 | "obsidian-dataview", 3 | () => { 4 | return { 5 | getAPI: () => () => {}, 6 | }; 7 | }, 8 | { virtual: true } 9 | ); 10 | 11 | // Mock App object for tests 12 | const mockApp = { 13 | workspace: { 14 | getActiveViewOfType: jest.fn(() => ({ 15 | editor: { 16 | getLine: jest.fn(), 17 | setLine: jest.fn() 18 | } 19 | })) 20 | } 21 | } as any; 22 | 23 | import { 24 | StringReplaceMap, 25 | applyBlockStyles, 26 | evaluateMathFromSourceStrings, 27 | getLocaleFormatter, 28 | getScopeFromFrontmatter, 29 | numeralsLayoutClasses, 30 | numeralsRenderStyleClasses, 31 | preProcessBlockForNumeralsDirectives, 32 | processAndRenderNumeralsBlockFromSource, 33 | replaceSumMagicVariableInProcessedWithSumDirectiveFromRaw, 34 | } from "../src/numeralsUtilities"; 35 | import { 36 | NumeralsSettings, 37 | NumeralsRenderStyle, 38 | NumeralsLayout, 39 | NumeralsScope, 40 | mathjsFormat, 41 | } from "../src/numerals.types"; 42 | import { DEFAULT_SETTINGS } from "../src/numerals.types"; 43 | 44 | // jest.mock('obsidian-dataview'); 45 | 46 | import * as math from 'mathjs'; 47 | import { defaultCurrencyMap } from "../src/numeralsUtilities"; 48 | import { MarkdownPostProcessorContext } from "obsidian"; 49 | const currencyPreProcessors = defaultCurrencyMap.map(m => { 50 | return {regex: RegExp('\\' + m.symbol + '([\\d\\.]+)','g'), replaceStr: '$1 ' + m.currency} 51 | }) 52 | const preProcessors = [ 53 | // {regex: /\$((\d|\.|(,\d{3}))+)/g, replace: '$1 USD'}, // Use this if commas haven't been removed already 54 | {regex: /,(\d{3})/g, replaceStr: '$1'}, // remove thousands seperators. Will be wrong for add(100,100) 55 | ...currencyPreProcessors 56 | ]; 57 | 58 | for (const moneyType of defaultCurrencyMap) { 59 | if (moneyType.currency != '') { 60 | math.createUnit(moneyType.currency, {aliases:[moneyType.currency.toLowerCase(), moneyType.symbol]}); 61 | } 62 | } 63 | 64 | describe("numeralsUtilities: applyBlockStyles()", () => { 65 | let el: HTMLElement; 66 | let settings: NumeralsSettings; 67 | let blockRenderStyle: NumeralsRenderStyle; 68 | 69 | beforeEach(() => { 70 | el = document.createElement("div"); 71 | Object.defineProperty(HTMLElement.prototype, 'toggleClass', { 72 | value: function(className: string, value: boolean) { 73 | if (value) this.classList.add(className); 74 | else this.classList.remove(className); 75 | }, 76 | writable: true, 77 | configurable: true 78 | }); 79 | settings = { 80 | ...DEFAULT_SETTINGS, 81 | }; 82 | blockRenderStyle = NumeralsRenderStyle.Plain; 83 | }); 84 | 85 | it("apply block styles with some settings and without emitters", () => { 86 | settings = { 87 | ...settings, 88 | layoutStyle: NumeralsLayout.TwoPanes, 89 | alternateRowColor: false, 90 | hideLinesWithoutMarkupWhenEmitting: false, 91 | }; 92 | 93 | applyBlockStyles({ el, settings, blockRenderStyle }); 94 | 95 | expect(el.classList.contains("numerals-block")).toBe(true); 96 | expect( 97 | el.classList.contains( 98 | numeralsLayoutClasses[NumeralsLayout.TwoPanes] 99 | ) 100 | ).toBe(true); 101 | expect( 102 | el.classList.contains( 103 | numeralsRenderStyleClasses[NumeralsRenderStyle.Plain] 104 | ) 105 | ).toBe(true); 106 | expect(el.classList.contains("numerals-alt-row-color")).toBe(false); 107 | 108 | for (const layoutClass of Object.values(numeralsLayoutClasses)) { 109 | if ( 110 | layoutClass !== numeralsLayoutClasses[NumeralsLayout.TwoPanes] 111 | ) { 112 | expect(el.classList.contains(layoutClass)).toBe(false); 113 | } 114 | } 115 | 116 | for (const renderStyleClass of Object.values( 117 | numeralsRenderStyleClasses 118 | )) { 119 | if ( 120 | renderStyleClass !== 121 | numeralsRenderStyleClasses[NumeralsRenderStyle.Plain] 122 | ) { 123 | expect(el.classList.contains(renderStyleClass)).toBe(false); 124 | } 125 | } 126 | 127 | expect(el.classList.contains("numerals-emitters-present")).toBe(false); 128 | expect(el.classList.contains("numerals-hide-non-emitters")).toBe(false); 129 | }); 130 | 131 | it("should apply block styles with default styles and with emitters", () => { 132 | applyBlockStyles({ el, settings, blockRenderStyle, hasEmitters: true }); 133 | 134 | expect(el.classList.contains("numerals-emitters-present")).toBe(true); 135 | expect(el.classList.contains("numerals-hide-non-emitters")).toBe(true); 136 | }); 137 | 138 | it("should apply block styles with default settings and showing now-emitters with emitters", () => { 139 | const settingsTest = { 140 | ...settings, 141 | hideLinesWithoutMarkupWhenEmitting: false, 142 | }; 143 | applyBlockStyles({ 144 | el, 145 | settings: settingsTest, 146 | blockRenderStyle, 147 | hasEmitters: true, 148 | }); 149 | 150 | expect(el.classList.contains("numerals-emitters-present")).toBe(true); 151 | expect(el.classList.contains("numerals-hide-non-emitters")).toBe(false); 152 | }); 153 | }); 154 | 155 | describe("numeralsUtilities: preProcessBlockForNumeralsDirectives", () => { 156 | it("Correctly processes block with emitters and insertion directives", () => { 157 | const sampleBlock = `# comment 1 158 | apples = 2 159 | 2 + 3 => 160 | @[$result::5]`; 161 | 162 | const preProcessors = [{ regex: /apples/g, replaceStr: "3" }]; 163 | const result = preProcessBlockForNumeralsDirectives( 164 | sampleBlock, 165 | preProcessors 166 | ); 167 | 168 | expect(result.rawRows).toEqual([ 169 | "# comment 1", 170 | "apples = 2", 171 | "2 + 3 =>", 172 | "@[$result::5]", 173 | ]); 174 | expect(result.processedSource).toEqual( 175 | "# comment 1\n3 = 2\n2 + 3\n$result" 176 | ); 177 | expect(result.blockInfo.emitter_lines).toEqual([2]); 178 | expect(result.blockInfo.insertion_lines).toEqual([3]); 179 | }); 180 | 181 | it("Correctly processes block with insertion directives inline", () => { 182 | const sampleBlock = `# comment 1 183 | a = 2 184 | b = 3 185 | @[result::5] = a + b`; 186 | 187 | const preProcessors = [{ regex: /apples/g, replaceStr: "3" }]; 188 | const result = preProcessBlockForNumeralsDirectives( 189 | sampleBlock, 190 | preProcessors 191 | ); 192 | 193 | expect(result.rawRows).toEqual([ 194 | "# comment 1", 195 | "a = 2", 196 | "b = 3", 197 | "@[result::5] = a + b", 198 | ]); 199 | expect(result.processedSource).toEqual("# comment 1\na = 2\nb = 3\nresult = a + b"); 200 | expect(result.blockInfo.emitter_lines).toEqual([]); 201 | expect(result.blockInfo.insertion_lines).toEqual([3]); 202 | }); 203 | 204 | it("Processes block without emitters or insertion directives", () => { 205 | const sampleBlock = `# Simple math 206 | 1 + 1 207 | 2 * 2`; 208 | 209 | const preProcessors = undefined; 210 | const result = preProcessBlockForNumeralsDirectives( 211 | sampleBlock, 212 | preProcessors 213 | ); 214 | 215 | expect(result.rawRows).toEqual(["# Simple math", "1 + 1", "2 * 2"]); 216 | expect(result.processedSource).toEqual("# Simple math\n1 + 1\n2 * 2"); 217 | expect(result.blockInfo.emitter_lines).toEqual([]); 218 | expect(result.blockInfo.insertion_lines).toEqual([]); 219 | }); 220 | 221 | it("Correctly processes block with @prev directive", () => { 222 | const sampleBlock = `value = 10 223 | doubled = @prev * 2 224 | tripled = @Prev * 1.5`; 225 | 226 | const preProcessors = undefined; 227 | const result = preProcessBlockForNumeralsDirectives( 228 | sampleBlock, 229 | preProcessors 230 | ); 231 | 232 | expect(result.rawRows).toEqual([ 233 | "value = 10", 234 | "doubled = @prev * 2", 235 | "tripled = @Prev * 1.5" 236 | ]); 237 | expect(result.processedSource).toEqual("value = 10\ndoubled = __prev * 2\ntripled = __prev * 1.5"); 238 | expect(result.blockInfo.emitter_lines).toEqual([]); 239 | expect(result.blockInfo.insertion_lines).toEqual([]); 240 | }); 241 | 242 | it("Handles multiple emitters and insertion directives", () => { 243 | const sampleBlock = `# Multiple directives 244 | 5 + 5 => 245 | 10 - 2 => 246 | @[$firstResult::10] 247 | @[$secondResult::8]`; 248 | 249 | const preProcessors = undefined; 250 | const result = preProcessBlockForNumeralsDirectives( 251 | sampleBlock, 252 | preProcessors 253 | ); 254 | 255 | expect(result.rawRows).toEqual([ 256 | "# Multiple directives", 257 | "5 + 5 =>", 258 | "10 - 2 =>", 259 | "@[$firstResult::10]", 260 | "@[$secondResult::8]", 261 | ]); 262 | expect(result.processedSource).toEqual( 263 | "# Multiple directives\n5 + 5\n10 - 2\n$firstResult\n$secondResult" 264 | ); 265 | expect(result.blockInfo.emitter_lines).toEqual([1, 2]); 266 | expect(result.blockInfo.insertion_lines).toEqual([3, 4]); 267 | }); 268 | 269 | it("Correctly applies preProcessors to the source", () => { 270 | const sampleBlock = `# Preprocessor test 271 | apples + oranges 272 | @[$totalFruits::apples + oranges]`; 273 | 274 | const preProcessors = [ 275 | { regex: /apples/g, replaceStr: "3" }, 276 | { regex: /oranges/g, replaceStr: "5" }, 277 | ]; 278 | const result = preProcessBlockForNumeralsDirectives( 279 | sampleBlock, 280 | preProcessors 281 | ); 282 | 283 | expect(result.rawRows).toEqual([ 284 | "# Preprocessor test", 285 | "apples + oranges", 286 | "@[$totalFruits::apples + oranges]", 287 | ]); 288 | expect(result.processedSource).toEqual( 289 | "# Preprocessor test\n3 + 5\n$totalFruits" 290 | ); 291 | expect(result.blockInfo.emitter_lines).toEqual([]); 292 | expect(result.blockInfo.insertion_lines).toEqual([2]); 293 | }); 294 | 295 | it("Correctly hides rows without result annotation when @hideRows is used", () => { 296 | const sampleBlock = `# Test hideRows 297 | @hideRows 298 | apples = 2 299 | 2 + 3 => 300 | @[$result::5]`; 301 | 302 | const result = preProcessBlockForNumeralsDirectives(sampleBlock, undefined); 303 | 304 | expect(result.rawRows).toEqual([ 305 | "# Test hideRows", 306 | "@hideRows", 307 | "apples = 2", 308 | "2 + 3 =>", 309 | "@[$result::5]", 310 | ]); 311 | expect(result.processedSource).toEqual( 312 | "# Test hideRows\n\napples = 2\n2 + 3\n$result" 313 | ); 314 | expect(result.blockInfo.hidden_lines).toEqual([1]); 315 | expect(result.blockInfo.shouldHideNonEmitterLines).toBe(true); 316 | }); 317 | 318 | it("Does not hide rows when @hideRows is not used", () => { 319 | const sampleBlock = `# Test without hideRows 320 | apples = 2 321 | 2 + 3 => 322 | @[$result::5]`; 323 | 324 | const result = preProcessBlockForNumeralsDirectives(sampleBlock, undefined); 325 | 326 | expect(result.rawRows).toEqual([ 327 | "# Test without hideRows", 328 | "apples = 2", 329 | "2 + 3 =>", 330 | "@[$result::5]", 331 | ]); 332 | expect(result.processedSource).toEqual( 333 | "# Test without hideRows\napples = 2\n2 + 3\n$result" 334 | ); 335 | expect(result.blockInfo.hidden_lines).toEqual([]); 336 | expect(result.blockInfo.shouldHideNonEmitterLines).toBe(false); 337 | }); 338 | }); 339 | 340 | /** 341 | * Unit tests for numeralsUtilities: getScopeFromFrontmatter 342 | * 343 | * These tests verify the functionality of getScopeFromFrontmatter, ensuring it correctly processes 344 | * frontmatter data under various conditions. The tests cover scenarios including undefined frontmatter, 345 | * processing all keys, ignoring objects unless keysOnly is true, and processing specific keys when 346 | * 'numerals' is an array. Each test sets up the necessary environment and asserts the expected outcomes 347 | * for the scope object after processing the frontmatter. 348 | */ 349 | describe("numeralsUtilities: getScopeFromFrontmatter", () => { 350 | let scope: NumeralsScope; 351 | let frontmatter: { [key: string]: unknown }; 352 | let forceAll: boolean; 353 | let stringReplaceMap: StringReplaceMap[]; 354 | let keysOnly: boolean; 355 | 356 | beforeEach(() => { 357 | scope = new NumeralsScope(); 358 | frontmatter = {}; 359 | forceAll = false; 360 | stringReplaceMap = []; 361 | keysOnly = false; 362 | }); 363 | 364 | it("should return an empty scope for undefined frontmatter", () => { 365 | const result = getScopeFromFrontmatter(undefined, scope, forceAll, stringReplaceMap, keysOnly); 366 | expect(result.size).toBe(0); 367 | }); 368 | 369 | it("should process 'numerals: all' correctly", () => { 370 | frontmatter = { 371 | numerals: "all", 372 | count: 5, 373 | speed: "5 m/s" 374 | }; 375 | const result = getScopeFromFrontmatter(frontmatter, scope, forceAll, stringReplaceMap, keysOnly); 376 | expect(result.get("count")).toBe(5); 377 | expect(result.get("title")).toBe(undefined); // title = "test", but no "title" key in scope 378 | expect(result.get("speed")).toEqual(math.evaluate("5 m/s")); 379 | }); 380 | 381 | it("should ignore objects unless keysOnly is true", () => { 382 | frontmatter = { 383 | objectKey: { nested: "value" } 384 | }; 385 | let result = getScopeFromFrontmatter(frontmatter, scope, forceAll, stringReplaceMap, keysOnly); 386 | expect(result.has("objectKey")).toBe(false); 387 | 388 | keysOnly = true; 389 | result = getScopeFromFrontmatter(frontmatter, scope, forceAll, stringReplaceMap, keysOnly); 390 | expect(result.get("objectKey")).toBeUndefined(); 391 | }); 392 | 393 | it("should process specific keys when 'numerals' is an array", () => { 394 | frontmatter = { 395 | numerals: ["selectedKey"], 396 | selectedKey: 10, 397 | ignoredKey: "ignored" 398 | }; 399 | const result = getScopeFromFrontmatter(frontmatter, scope, forceAll, stringReplaceMap, keysOnly); 400 | expect(result.get("selectedKey")).toBe(10); 401 | expect(result.has("ignoredKey")).toBe(false); 402 | }); 403 | 404 | it("should process string values as mathjs expressions", () => { 405 | frontmatter = { 406 | numerals: "expression", 407 | expression: "2 + 2" 408 | }; 409 | const result = getScopeFromFrontmatter(frontmatter, scope, forceAll, stringReplaceMap, keysOnly); 410 | expect(result.get("expression")).toEqual(math.evaluate("2 + 2")); 411 | }); 412 | 413 | it("should handle missing scope values gracefully", () => { 414 | frontmatter = { 415 | numerals: "missingValue", 416 | missingValue: "missing" 417 | }; 418 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 419 | const result = getScopeFromFrontmatter(frontmatter, scope, forceAll, stringReplaceMap, keysOnly); 420 | expect(result.get("missingValue")).toBeUndefined(); 421 | expect(consoleSpy).toHaveBeenCalled(); 422 | consoleSpy.mockRestore(); 423 | }); 424 | 425 | 426 | it("should handle errors in mathjs expression evaluation gracefully", () => { 427 | frontmatter = { 428 | numerals: "badExpression", 429 | badExpression: "2 +" 430 | }; 431 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 432 | const result = getScopeFromFrontmatter(frontmatter, scope, forceAll, stringReplaceMap, keysOnly); 433 | expect(consoleSpy).toHaveBeenCalled(); 434 | expect(result.has("badExpression")).toBe(false); 435 | consoleSpy.mockRestore(); 436 | }); 437 | 438 | it("should respect the forceAll parameter", () => { 439 | frontmatter = { 440 | key1: "2", 441 | key2: "2+2" 442 | }; 443 | forceAll = true; 444 | const result = getScopeFromFrontmatter(frontmatter, scope, forceAll, stringReplaceMap, keysOnly); 445 | expect(result.get("key1")).toBe(2); 446 | expect(result.get("key2")).toEqual(math.evaluate("2+2")); 447 | }); 448 | 449 | it("should apply string replacements from stringReplaceMap including currency support", () => { 450 | 451 | frontmatter = { 452 | numerals: "all", 453 | cost: "100 USD", 454 | dollarCost: "$100", 455 | currencyCost: "$100,000" 456 | }; 457 | const result = getScopeFromFrontmatter(frontmatter, scope, forceAll, preProcessors, keysOnly); 458 | expect(result.get("cost")).toEqual(math.evaluate("100 USD")); 459 | expect(result.get("dollarCost")).toEqual(math.evaluate("100 USD")); 460 | expect(result.get("currencyCost")).toEqual(math.evaluate("100000 USD")); 461 | }); 462 | 463 | it("should process global functions (prefixed with $) from frontmatter", () => { 464 | frontmatter = { 465 | "$a": 2, 466 | "$b": 5, 467 | "$v(x)": "x + $b - $a", // Use function assignment syntax instead of JavaScript syntax 468 | }; 469 | const result = getScopeFromFrontmatter(frontmatter, scope, forceAll, stringReplaceMap, keysOnly); 470 | expect(result.get("$a")).toBe(2); 471 | expect(result.get("$b")).toBe(5); 472 | 473 | // Test that the function was created and stored under the function name (without parentheses) 474 | const func = result.get("$v") as any; 475 | expect(typeof func).toBe("function"); 476 | expect(func(0)).toBe(3); // 0 + 5 - 2 = 3 477 | }); 478 | 479 | it("should process global function definitions using function assignment syntax", () => { 480 | frontmatter = { 481 | "$a": 2, 482 | "$b": 5, 483 | "$v(x)": "x + $b - $a", 484 | }; 485 | const result = getScopeFromFrontmatter(frontmatter, scope, forceAll, stringReplaceMap, keysOnly); 486 | expect(result.get("$a")).toBe(2); 487 | expect(result.get("$b")).toBe(5); 488 | 489 | // Check if the function was created and stored under the function name (without parentheses) 490 | const func = result.get("$v") as any; 491 | expect(typeof func).toBe("function"); 492 | expect(func(0)).toBe(3); // 0 + 5 - 2 = 3 493 | expect(func(10)).toBe(13); // 10 + 5 - 2 = 13 494 | }); 495 | 496 | it("should handle global functions with multiple parameters", () => { 497 | frontmatter = { 498 | "$multiply(x, y)": "x * y", 499 | "$add(a, b, c)": "a + b + c" 500 | }; 501 | const result = getScopeFromFrontmatter(frontmatter, scope, forceAll, stringReplaceMap, keysOnly); 502 | 503 | const multiplyFunc = result.get("$multiply") as any; 504 | const addFunc = result.get("$add") as any; 505 | 506 | expect(typeof multiplyFunc).toBe("function"); 507 | expect(typeof addFunc).toBe("function"); 508 | 509 | expect(multiplyFunc(3, 4)).toBe(12); 510 | expect(addFunc(1, 2, 3)).toBe(6); 511 | }); 512 | 513 | it("should handle global functions defined in math block (from the GitHub issue #101)", () => { 514 | // First, simulate the first math block defining globals 515 | const firstBlockFrontmatter = { 516 | "$a": 2, 517 | "$b": 5, 518 | "$v(x)": "x + $b - $a" 519 | }; 520 | 521 | const firstScope = getScopeFromFrontmatter(firstBlockFrontmatter, new NumeralsScope(), forceAll, stringReplaceMap, keysOnly); 522 | 523 | // Verify globals are created correctly 524 | expect(firstScope.get("$a")).toBe(2); 525 | expect(firstScope.get("$b")).toBe(5); 526 | 527 | const vFunc = firstScope.get("$v") as any; 528 | expect(typeof vFunc).toBe("function"); 529 | expect(vFunc(0)).toBe(3); // 0 + 5 - 2 = 3 530 | 531 | // Now simulate that these globals are cached and retrieved in a second block 532 | // This simulates what getMetadataForFileAtPath would return 533 | const secondBlockFrontmatter = { 534 | "$a": 2, 535 | "$b": 5, 536 | "$v": vFunc // Function object as it would be stored in cache 537 | }; 538 | 539 | const secondScope = getScopeFromFrontmatter(secondBlockFrontmatter, new NumeralsScope(), forceAll, stringReplaceMap, keysOnly); 540 | 541 | // Verify that globals work in the second block 542 | expect(secondScope.get("$a")).toBe(2); 543 | expect(secondScope.get("$b")).toBe(5); 544 | 545 | const vFunc2 = secondScope.get("$v") as any; 546 | expect(typeof vFunc2).toBe("function"); 547 | expect(vFunc2(0)).toBe(3); // Global function should work across blocks! 548 | }); 549 | }); 550 | describe("numeralsUtilities: evaluateMathFromSourceStrings", () => { 551 | let scope: NumeralsScope; 552 | let processedSource: string; 553 | 554 | beforeEach(() => { 555 | scope = new NumeralsScope(); 556 | processedSource = ""; 557 | }); 558 | 559 | it("should evaluate simple math expressions correctly", () => { 560 | processedSource = "2 + 2\n5 * 5"; 561 | const { results, inputs, errorMsg, errorInput } = evaluateMathFromSourceStrings(processedSource, scope); 562 | 563 | expect(results).toEqual([4, 25]); 564 | expect(inputs).toEqual(["2 + 2", "5 * 5"]); 565 | expect(errorMsg).toBeNull(); 566 | expect(errorInput).toBe(""); 567 | }); 568 | 569 | it("should handle variables in scope correctly", () => { 570 | scope.set("x", 10); 571 | processedSource = "x * 2\nx + 5"; 572 | const { results, inputs } = evaluateMathFromSourceStrings(processedSource, scope); 573 | 574 | expect(results).toEqual([20, 15]); 575 | expect(inputs).toEqual(["x * 2", "x + 5"]); 576 | }); 577 | 578 | it("should return an error for invalid expressions", () => { 579 | processedSource = "2 +\n5 * 5"; 580 | const { errorMsg, errorInput } = evaluateMathFromSourceStrings(processedSource, scope); 581 | 582 | expect(errorMsg).not.toBeNull(); 583 | expect(errorInput).toBe("2 +"); 584 | }); 585 | 586 | it("should ignore empty last row in processed source", () => { 587 | processedSource = "2 + 2\n5 * 5\n"; 588 | const { results, inputs } = evaluateMathFromSourceStrings(processedSource, scope); 589 | 590 | expect(results).toEqual([4, 25]); 591 | expect(inputs).toEqual(["2 + 2", "5 * 5"]); 592 | }); 593 | 594 | it("should process expressions with mathjs functions", () => { 595 | processedSource = "sqrt(16)\nlog(100, 10)"; 596 | const { results, inputs } = evaluateMathFromSourceStrings(processedSource, scope); 597 | 598 | expect(results).toEqual([4, 2]); 599 | expect(inputs).toEqual(["sqrt(16)", "log(100, 10)"]); 600 | }); 601 | 602 | it("should handle scope updates within the source", () => { 603 | processedSource = "x = 5\nx * 2"; 604 | const { results, inputs } = evaluateMathFromSourceStrings(processedSource, scope); 605 | 606 | expect(results).toEqual([5, 10]); 607 | expect(inputs).toEqual(["x = 5", "x * 2"]); 608 | expect(scope.get("x")).toBe(5); 609 | }); 610 | 611 | it("should correctly handle expressions with units", () => { 612 | processedSource = "5 m + 10 m\n100 kg - 50 kg"; 613 | const { results, inputs } = evaluateMathFromSourceStrings(processedSource, scope); 614 | 615 | expect(results).toEqual([math.unit(15, 'm'), math.unit(50, 'kg')]); 616 | expect(inputs).toEqual(["5 m + 10 m", "100 kg - 50 kg"]); 617 | }); 618 | 619 | it("should return an error for incompatible unit operations", () => { 620 | processedSource = "10 m + 5 kg"; 621 | const { errorMsg, errorInput } = evaluateMathFromSourceStrings(processedSource, scope); 622 | 623 | expect(errorMsg).not.toBeNull(); 624 | expect(errorInput).toBe("10 m + 5 kg"); 625 | }); 626 | 627 | it("should handle rolling totals and sums", () => { 628 | processedSource = "a = 1\nb = 2\n__total"; 629 | const { results, inputs } = evaluateMathFromSourceStrings(processedSource, scope); 630 | expect(results).toEqual([1, 2, 3]); 631 | expect(inputs).toEqual(["a = 1", "b = 2", "__total"]); 632 | }); 633 | 634 | it("should handle rolling totals and sums", () => { 635 | processedSource = `# Fruit 636 | apples = 3 637 | pears = 4 638 | grapes = 10 639 | fruit = __total 640 | 641 | monday = 10 USD 642 | tuesday = 20 USD 643 | wednesday = 30 USD 644 | profit = __total`; 645 | const { results, inputs } = evaluateMathFromSourceStrings(processedSource, scope); 646 | expect(results).toEqual([undefined,3, 4, 10, 17, undefined, math.unit(10, 'USD'), math.unit(20, 'USD'), math.unit(30, 'USD'), math.unit(60, 'USD')]); 647 | expect(inputs).toEqual(["# Fruit","apples = 3", "pears = 4", "grapes = 10", "fruit = __total", "", "monday = 10 USD", "tuesday = 20 USD", "wednesday = 30 USD", "profit = __total"]); 648 | }); 649 | 650 | it("should handle @prev directive to use previous line's value", () => { 651 | processedSource = `value = 10 652 | doubled = __prev * 2 653 | tripled = __prev * 1.5 654 | result = __prev + 5`; 655 | const { results, inputs } = evaluateMathFromSourceStrings(processedSource, scope); 656 | expect(results).toEqual([10, 20, 30, 35]); 657 | expect(inputs).toEqual(["value = 10", "doubled = __prev * 2", "tripled = __prev * 1.5", "result = __prev + 5"]); 658 | }); 659 | 660 | it("should handle error when @prev is used on the first line", () => { 661 | processedSource = `__prev + 5 662 | value = 10`; 663 | const { errorMsg, errorInput } = evaluateMathFromSourceStrings(processedSource, scope); 664 | expect(errorMsg).not.toBeNull(); 665 | expect(errorInput).toBe("__prev + 5"); 666 | expect(errorMsg?.name).toBe("Previous Value Error"); 667 | }); 668 | }); 669 | 670 | describe("numeralsUtilities: replaceSumMagicVariableInProcessedWithSumDirectiveFromRaw", () => { 671 | it("replaces __total with @sum directive from raw string", () => { 672 | const processedString = "profit = __total"; 673 | const rawString = "profit = @sum"; 674 | const result = replaceSumMagicVariableInProcessedWithSumDirectiveFromRaw(processedString, rawString); 675 | expect(result).toBe("profit = @sum"); 676 | }); 677 | 678 | it("replaces __total with @total directive from raw string", () => { 679 | const processedString = "totalCost = __total"; 680 | const rawString = "totalCost = @total"; 681 | const result = replaceSumMagicVariableInProcessedWithSumDirectiveFromRaw(processedString, rawString); 682 | expect(result).toBe("totalCost = @total"); 683 | }); 684 | 685 | it("replaces multiple occurrences of __total with corresponding directives", () => { 686 | const processedString = "profit = __total and totalCost = __total"; 687 | const rawString = "profit = @sum and totalCost = @total"; 688 | const result = replaceSumMagicVariableInProcessedWithSumDirectiveFromRaw(processedString, rawString); 689 | expect(result).toBe("profit = @sum and totalCost = @total"); 690 | }); 691 | 692 | it("replaces __total with @Sum if no matching directive is found", () => { 693 | const processedString = "profit = __total"; 694 | const rawString = "profit = 100"; 695 | const result = replaceSumMagicVariableInProcessedWithSumDirectiveFromRaw(processedString, rawString); 696 | expect(result).toBe("profit = @Sum"); 697 | }); 698 | 699 | it("uses provided replacement string if specified", () => { 700 | const processedString = "profit = __total"; 701 | const rawString = "profit = @sum"; 702 | const replacement = "@customSum"; 703 | const result = replaceSumMagicVariableInProcessedWithSumDirectiveFromRaw(processedString, rawString, replacement); 704 | expect(result).toBe("profit = @customSum"); 705 | }); 706 | 707 | it("handles case where multiple __total need replacement but only one directive is available", () => { 708 | const processedString = "profit = __total and totalCost = __total"; 709 | const rawString = "profit = @sum"; 710 | const result = replaceSumMagicVariableInProcessedWithSumDirectiveFromRaw(processedString, rawString); 711 | // Expect the first __total to be replaced with @sum, and the second to be replaced with @Sum as no more specific directives are available 712 | expect(result).toBe("profit = @sum and totalCost = @Sum"); 713 | }); 714 | 715 | it("does not replace __total if it is part of a larger word", () => { 716 | const processedString = "profitability = __totalStuff"; 717 | const rawString = "profitability = @sum"; 718 | const result = replaceSumMagicVariableInProcessedWithSumDirectiveFromRaw(processedString, rawString); 719 | // Expect no replacement as __total is part of a larger word and not a standalone variable 720 | expect(result).toBe("profitability = __totalStuff"); 721 | }); 722 | }); 723 | 724 | describe("numeralsUtilities: processAndRenderNumeralsBlockFromSource end-to-end tests", () => { 725 | let el: HTMLElement; 726 | let source: string; 727 | let ctx: MarkdownPostProcessorContext; 728 | let metadata: { [key: string]: unknown }; 729 | let type: NumeralsRenderStyle; 730 | let settings: NumeralsSettings; 731 | let numberFormat: mathjsFormat; 732 | 733 | beforeEach(() => { 734 | el = document.createElement("div"); 735 | Object.defineProperty(HTMLElement.prototype, 'toggleClass', { 736 | value: function(className: string, value: boolean) { 737 | if (value) this.classList.add(className); 738 | else this.classList.remove(className); 739 | }, 740 | writable: true, 741 | configurable: true 742 | }); 743 | Object.defineProperty(HTMLElement.prototype, 'createEl', { 744 | value: jest.fn(function(this: HTMLElement, tag, options, callback) { 745 | const element = document.createElement(tag); 746 | if (typeof options === 'string') { 747 | element.className = options; 748 | } else if (options) { 749 | if (options.cls) { 750 | const classes = Array.isArray(options.cls) ? options.cls : [options.cls]; 751 | classes.forEach((cls: string) => element.classList.add(cls)); 752 | } 753 | if (options.text) element.textContent = String(options.text); 754 | if (options.attr) { 755 | Object.entries(options.attr).forEach(([key, value]) => { 756 | element.setAttribute(key, String(value)); 757 | }); 758 | } 759 | if (options.title) element.title = options.title; 760 | } 761 | if (callback) callback(element); 762 | 763 | // Append the created element to the parent element ('this'), with type assertion 764 | this.appendChild(element); 765 | 766 | return element; 767 | }), 768 | writable: true, 769 | configurable: true 770 | }); 771 | Object.defineProperty(HTMLElement.prototype, 'setText', { 772 | value: function(text: string) { 773 | this.textContent = text; 774 | }, 775 | writable: true, 776 | configurable: true 777 | }); 778 | source = ""; 779 | ctx = { getSectionInfo: jest.fn() } as unknown as MarkdownPostProcessorContext; 780 | metadata = {}; 781 | type = NumeralsRenderStyle.Plain; 782 | settings = { ...DEFAULT_SETTINGS }; 783 | numberFormat = getLocaleFormatter(); 784 | }); 785 | 786 | const resultSeparator = DEFAULT_SETTINGS.resultSeparator; 787 | 788 | it("renders a simple math block correctly", () => { 789 | source = "1 + 1\n2 * 2"; 790 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 791 | 792 | const lines = el.querySelectorAll(".numerals-line"); 793 | expect(lines.length).toBe(2); 794 | expect(lines[0].textContent).toContain(`1 + 1${resultSeparator}2`); 795 | expect(lines[1].textContent).toContain(`2 * 2${resultSeparator}4`); 796 | }); 797 | 798 | it("renders a block with emitter lines correctly", () => { 799 | source = "1 + 1 =>\n2 * 2 =>"; 800 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 801 | 802 | const emitterLines = el.querySelectorAll(".numerals-emitter"); 803 | expect(emitterLines.length).toBe(2); 804 | expect(emitterLines[0].textContent).toContain(`1 + 1${resultSeparator}2`); 805 | expect(emitterLines[1].textContent).toContain(`2 * 2${resultSeparator}4`); 806 | }); 807 | 808 | it("renders a block with insertion directives correctly", () => { 809 | metadata = { numerals: "all", result1: 1, result2: 2, result3: 3 }; 810 | source = "@[result1]\n@[result2::2]\n@[result3::4]"; 811 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 812 | 813 | const insertionLines = el.querySelectorAll(".numerals-line"); 814 | // const children = Array.from(el.children); 815 | expect(insertionLines.length).toBe(3); 816 | expect(insertionLines[0].textContent).toContain(`result1${resultSeparator}1`); 817 | expect(insertionLines[1].textContent).toContain(`result2${resultSeparator}2`); 818 | expect(insertionLines[2].textContent).toContain(`result3${resultSeparator}3`); 819 | }); 820 | 821 | it("applies preProcessors correctly", () => { 822 | source = "$100 + $1,000"; 823 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 824 | 825 | const lines = el.querySelectorAll(".numerals-line"); 826 | expect(lines.length).toBe(1); 827 | expect(lines[0].textContent).toContain(`$100 + $1,000${resultSeparator}1,100 USD`); 828 | }); 829 | 830 | it("handles errors in math expressions gracefully", () => { 831 | source = "1 +\n2 * 2"; 832 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 833 | 834 | const errorLine = el.querySelector(".numerals-error-line"); 835 | expect(errorLine).not.toBeNull(); 836 | expect(errorLine?.textContent).toContain("SyntaxError:"); 837 | }); 838 | 839 | it("calculates with variables correctly", () => { 840 | source = "lemons = 20\napples = 10\nfruit = lemons + apples"; 841 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 842 | 843 | const lines = el.querySelectorAll(".numerals-line"); 844 | expect(lines.length).toBe(3); 845 | expect(lines[0].textContent).toContain(`lemons = 20${resultSeparator}20`); 846 | expect(lines[1].textContent).toContain(`apples = 10${resultSeparator}10`); 847 | expect(lines[2].textContent).toContain(`fruit = lemons + apples${resultSeparator}30`); 848 | }); 849 | 850 | it('simple math block with currency and emitter with snapshot', () => { 851 | source = "amount = 100 USD + $1,000\ntax = 10% * amount =>"; 852 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 853 | const lines = el.querySelectorAll(".numerals-line"); 854 | expect(lines.length).toBe(2); 855 | expect(lines[0].textContent).toContain(`amount = 100 USD + $1,000${resultSeparator}1,100 USD`); 856 | expect(lines[1].textContent).toContain(`tax = 10% * amount${resultSeparator}110 USD`); 857 | 858 | expect(el).toMatchSnapshot(); 859 | }); 860 | 861 | it('Simple math with rolling sum', () => { 862 | source = `# Fruit 863 | apples = 3 864 | pears = 4 865 | grapes = 10 866 | fruit = @sum 867 | # Money 868 | monday = $10 869 | tuesday = $20 870 | wednesday = $30 871 | profit = @total`; 872 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 873 | const lines = el.querySelectorAll(".numerals-line"); 874 | expect(lines.length).toBe(10); 875 | expect(lines[0].textContent).toContain(`# Fruit`); 876 | expect(lines[1].textContent).toContain(`apples = 3${resultSeparator}3`); 877 | expect(lines[2].textContent).toContain(`pears = 4${resultSeparator}4`); 878 | expect(lines[3].textContent).toContain(`grapes = 10${resultSeparator}10`); 879 | expect(lines[4].textContent).toContain(`fruit = @sum${resultSeparator}17`); 880 | expect(lines[5].textContent).toContain(`# Money`); 881 | expect(lines[6].textContent).toContain(`monday = $10${resultSeparator}10 USD`); 882 | expect(lines[7].textContent).toContain(`tuesday = $20${resultSeparator}20 USD`); 883 | expect(lines[8].textContent).toContain(`wednesday = $30${resultSeparator}30 USD`); 884 | expect(lines[9].textContent).toContain(`profit = @total${resultSeparator}60 USD`); 885 | }); 886 | 887 | it("renders only result-annotated rows when @hideRows is used", () => { 888 | const source = `# Test hideRows 889 | @hideRows 890 | apples = 2 891 | 2 + 3 => 892 | @[$result::5]`; 893 | 894 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 895 | 896 | const lines = el.querySelectorAll(".numerals-line"); 897 | // expect(lines.length).toBe(3); // Only 3 lines should be rendered 898 | expect(lines[0].textContent).toContain("2 + 3"); 899 | }); 900 | 901 | it("renders all rows when @hideRows is not used", () => { 902 | const source = `# Test without hideRows 903 | apples = 2 904 | 2 + 3 => 905 | @[$result::5]`; 906 | 907 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 908 | 909 | const lines = el.querySelectorAll(".numerals-line"); 910 | expect(lines.length).toBe(4); // All 4 lines should be rendered 911 | expect(lines[0].textContent).toContain("# Test without hideRows"); 912 | expect(lines[1].textContent).toContain("apples = 2"); 913 | expect(lines[2].textContent).toContain("2 + 3"); 914 | expect(lines[3].textContent).toContain("$result"); 915 | }); 916 | 917 | it('Snapshot 1: simple math block with units, emitters, result insertion', () => { 918 | source = `# Physics Calculation 919 | speed = 5 m/s 920 | distance = 100 m 921 | time = distance / speed => 922 | @[time]`; 923 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 924 | expect(el).toMatchSnapshot(); 925 | }); 926 | 927 | it('Snapshot 2: simple math block with test of locale formatting', () => { 928 | source = `# Locale Test 929 | 1000 930 | 3.14 931 | lambda=780.246021 nanometer 932 | nu=speedOfLight/lambda 933 | pi+1`; 934 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 935 | expect(el).toMatchSnapshot(); 936 | }); 937 | 938 | const extendedSource = `# Sum and Total 939 | apples = 3 940 | pears = 4 941 | grapes = 10 942 | fruit = @Sum 943 | 944 | monday = $10 945 | tuesday = $20 946 | wednesday = $30 947 | profit = @Total 948 | 949 | # Sums to empty line or header 950 | elementary = 5 years 951 | middle = 3 years 952 | high = 4 years 953 | school = @total 954 | 955 | # Emitters 956 | test = 1+1 957 | b = 32 958 | b + test + 6 => 959 | test + 3 => #this is the main thing 960 | # this is a comment => # ignore it 961 | test + 4 => # this is after comment => 962 | 963 | income = $100,000 964 | tax_rate = 20% 965 | taxes = income * tax_rate => 966 | 967 | distance = 10miles 968 | speed = 20 m/s 969 | time = distance / speed => 970 | 971 | b + test + 2 => => 972 | 3+2 973 | 974 | # Currency 975 | # Currency and Units 976 | Lacroix_amazon = $5.99 / (12*12 floz) 977 | Lacroix_safeway = $12 / (3*8*12 floz) 978 | 979 | Sodastream_CO2 = $64.99 / (120L in floz) 980 | Sodastream_machine = $89.99 981 | 982 | can = 12floz 983 | savings_per_can = (Lacroix_safeway - Sodastream_CO2) * can 984 | 985 | breakeven = Sodastream_machine / savings_per_can 986 | days_breakeven = breakeven / (5 / day) 987 | 988 | # Result Insertion 989 | lemons = 10 + 3*20 990 | @[lemons::70] = 80 991 | lemons 992 | lemons = 80 993 | $100 + $1,000 994 | a = 1 995 | $b = 4 996 | @[$result::5] = a + $b => 997 | `; 998 | 999 | it('Extended Snapshot 1: Default Settings', () => { 1000 | source = extendedSource; 1001 | settings = { ...DEFAULT_SETTINGS }; 1002 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 1003 | expect(el).toMatchSnapshot(); 1004 | }); 1005 | 1006 | it('Extended Snapshot 2: Answer Right', () => { 1007 | source = extendedSource; 1008 | settings = { ...DEFAULT_SETTINGS, layoutStyle: NumeralsLayout.AnswerRight }; 1009 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 1010 | expect(el).toMatchSnapshot(); 1011 | }); 1012 | it('Extended Snapshot 3: Answer Below', () => { 1013 | source = extendedSource; 1014 | settings = { ...DEFAULT_SETTINGS, layoutStyle: NumeralsLayout.AnswerBelow }; 1015 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 1016 | expect(el).toMatchSnapshot(); 1017 | }); 1018 | it('Extended Snapshot 4: Answer Inline', () => { 1019 | source = extendedSource; 1020 | settings = { ...DEFAULT_SETTINGS, layoutStyle: NumeralsLayout.AnswerInline }; 1021 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 1022 | expect(el).toMatchSnapshot(); 1023 | }); 1024 | it('Extended Snapshot 5: Mixed Settings', () => { 1025 | source = extendedSource; 1026 | settings = { 1027 | ...DEFAULT_SETTINGS, 1028 | alternateRowColor: false, 1029 | hideLinesWithoutMarkupWhenEmitting: false, 1030 | layoutStyle: NumeralsLayout.AnswerRight, 1031 | hideEmitterMarkupInInput: false 1032 | }; 1033 | processAndRenderNumeralsBlockFromSource(el, source, ctx, metadata, type, settings, numberFormat, preProcessors, mockApp); 1034 | expect(el).toMatchSnapshot(); 1035 | }); 1036 | }); 1037 | --------------------------------------------------------------------------------