diff --git a/README.md b/README.md index 3c92d32..4ce9ef9 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Use the following syntax within your notes to perform inline conversion: - [Autosuggest](#autosuggest) - [Convert units command](#convert-units-command) +- [Custom units](#custom-units) ### Autosuggest @@ -28,33 +29,41 @@ You can open a modal to perform a unit conversion. This is mostly done to improv convert units command example +### Custom units + +You can add custom units in settings. Each custom unit belongs to an existing measure and is defined as a positive factor of a built-in anchor unit. + ## Supported Units -**Length**: mm, cm, m, in, ft-us, ft, mi +**Length**: mm, cm, m, km, in, yd, ft-us, ft, mi -**Area**: mm2 cm2, m2, ha, km2, in2, ft2, ac, mi2 +**Area**: mm2, cm2, m2, ha, km2, in2, yd2, ft2, ac, mi2 -**Mass**: mcg, mg, g, kg, oz, lb, mt, t +**Mass**: mcg, mg, g, kg, mt, oz, lb, t -**Volume**: mm3, cm3, ml, l, kl, m3, km3, tsp, Tbs, in3, fl-oz, cup, pnt, qt, gal, ft3, yd3 +**Volume**: mm3, cm3, ml, cl, dl, l, kl, m3, km3, krm, tsk, msk, kkp, glas, kanna, tsp, Tbs, in3, fl-oz, cup, pnt, qt, gal, ft3, yd3 -**Volume Flow Rate**: mm3/s, cm3/s, ml/s, cl/s, dl/s, l/s, l/min, l/h, kl/s, kl/min, kl/h, m3/s, m3/min, m3/h, km3/s, tsp/s, Tbs/s, in3/s, in3/min, in3/h, fl-oz/s, fl-oz/min, fl-oz/h, cup/s, pnt/s, pnt/min, pnt/h, qt/s, gal/s, gal/min, gal/h, ft3/s, ft3/min, ft3/h, yd3/s, yd3/min, yd3/h' +**Volume Flow Rate**: mm3/s, cm3/s, ml/s, cl/s, dl/s, l/s, l/min, l/h, kl/s, kl/min, kl/h, m3/s, m3/min, m3/h, km3/s, tsp/s, Tbs/s, in3/s, in3/min, in3/h, fl-oz/s, fl-oz/min, fl-oz/h, cup/s, pnt/s, pnt/min, pnt/h, qt/s, gal/s, gal/min, gal/h, ft3/s, ft3/min, ft3/h, yd3/s, yd3/min, yd3/h -**Temperature**: C, F, K, R +**Each**: ea, dz + +**Temperature**: C, K, F, R **Time**: ns, mu, ms, s, min, h, d, week, month, year -**Frequency**: Hz, mHz, kHz, MHz, GHz, THz, rpm, deg/s, rad/s +**Frequency**: mHz, Hz, kHz, MHz, GHz, THz, rpm, deg/s, rad/s + +**Parts Per**: ppm, ppb, ppt, ppq **Speed**: m/s, km/h, m/h, knot, ft/s -**Pace**: s/m, min/km, s/ft, min/km +**Pace**: min/km, s/m, min/mi, s/ft -**Pressure**: Pa, hPa, kPa, MPa, bar, torr, psi, ksi +**Pressure**: Pa, kPa, MPa, hPa, bar, torr, psi, ksi **Digital**: b, Kb, Mb, Gb, Tb, B, KB, MB, GB, TB -**Illuminance**: lx, ft-cd, Parts-Per, ppm, ppb, ppt, ppq +**Illuminance**: lx, ft-cd **Voltage**: V, mV, kV @@ -70,4 +79,4 @@ You can open a modal to perform a unit conversion. This is mostly done to improv **Reactive Energy**: VARh, mVARh, kVARh, MVARh, GVARh -**Angle**: deg, rad, grad, arcmin, arcsec +**Angle**: rad, deg, grad, arcmin, arcsec diff --git a/package.json b/package.json index 5cc9171..72d33d1 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "dev": "rolldown --config rolldown.config.js -w", "build": "rolldown --config rolldown.config.js", "lint": "oxlint", + "test": "vitest run", + "typecheck": "tsc --noEmit", "format": "oxfmt", "version": "node version-bump.mjs && git add manifest.json versions.json" }, @@ -27,7 +29,8 @@ "oxfmt": "^0.49.0", "oxlint": "^1.64.0", "rolldown": "1.0.1", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.6" }, "packageManager": "pnpm@11.1.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93b0541..ce3d4a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: ^4.1.6 + version: 4.1.6(vite@8.0.13) packages: @@ -57,6 +60,9 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} @@ -411,31 +417,182 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/codemirror@5.60.8': resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==} '@types/convert-units@2.3.12': resolution: {integrity: sha512-4EaowCOJMy8Cb2nB8XSN61U82VV4qFsFYY2WhKIwXy936aBTp2yWKvMpTCt1g76MDmd+DmVi9YxKSYVrfCQ/yw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/tern@0.23.9': resolution: {integrity: sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==} + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convert-units@2.3.4: resolution: {integrity: sha512-ERHfdA0UhHJp1IpwE6PnFJx8LqG7B1ZjJ20UvVCmopEnVCfER68Tbe3kvN63dLbYXDA2xFWRE6zd4Wsf0w7POg==} crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + globals@17.6.0: resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} engines: {node: '>=18'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lodash._basebind@2.3.0: resolution: {integrity: sha512-SHqM7YCuJ+BeGTs7lqpWnmdHEeF4MWxS3dksJctHFNxR81FXPOzA4bS5Vs5CpcGTkBpM8FCl+YEbQEblRw8ABg==} @@ -493,15 +650,26 @@ packages: lodash.support@2.3.0: resolution: {integrity: sha512-etc7VWbB0U3Iya8ixj2xy4sDBN3jvPX7ODi8iXtn4KkkjNpdngrdc7Vlt5jub/Vgqx6/dWtp7Ml9awhCQPYKGQ==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + moment@2.29.4: resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + obsidian@1.12.3: resolution: {integrity: sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==} peerDependencies: '@codemirror/state': 6.5.0 '@codemirror/view': 6.38.6 + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + oxfmt@0.49.0: resolution: {integrity: sha512-IAHFMdlJSWe+oAr65dx22UvjCtV9DBMisAuLnKpDqMQrctzCkGnj3QRwNHm0d+uwSWPalsDF8ZYLz9rh6nH2IQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -522,18 +690,60 @@ packages: oxlint-tsgolint: optional: true + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + rolldown@1.0.1: resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + tinypool@2.1.0: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -542,9 +752,98 @@ packages: engines: {node: '>=14.17'} hasBin: true + vite@8.0.13: + resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + snapshots: '@codemirror/state@6.6.0': @@ -574,6 +873,8 @@ snapshots: tslib: 2.8.1 optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} + '@marijn/find-cluster-break@1.0.2': {} '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': @@ -750,23 +1051,79 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@standard-schema/spec@1.1.0': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/codemirror@5.60.8': dependencies: '@types/tern': 0.23.9 '@types/convert-units@2.3.12': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} '@types/tern@0.23.9': dependencies: '@types/estree': 1.0.9 + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.6(vite@8.0.13)': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.13 + + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.6': {} + + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + convert-units@2.3.4: dependencies: lodash.foreach: 2.3.0 @@ -774,8 +1131,74 @@ snapshots: crelt@1.0.6: {} + detect-libc@2.1.2: {} + + es-module-lexer@2.1.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + globals@17.6.0: {} + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lodash._basebind@2.3.0: dependencies: lodash._basecreate: 2.3.0 @@ -860,8 +1283,14 @@ snapshots: dependencies: lodash._renative: 2.3.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + moment@2.29.4: {} + nanoid@3.3.12: {} + obsidian@1.12.3(@codemirror/state@6.6.0)(@codemirror/view@6.43.0): dependencies: '@codemirror/state': 6.6.0 @@ -869,6 +1298,8 @@ snapshots: '@types/codemirror': 5.60.8 moment: 2.29.4 + obug@2.1.1: {} + oxfmt@0.49.0: dependencies: tinypool: 2.1.0 @@ -915,6 +1346,18 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.64.0 '@oxlint/binding-win32-x64-msvc': 1.64.0 + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + rolldown@1.0.1: dependencies: '@oxc-project/types': 0.130.0 @@ -936,13 +1379,72 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.1 '@rolldown/binding-win32-x64-msvc': 1.0.1 + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + style-mod@4.1.3: {} + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinypool@2.1.0: {} + tinyrainbow@3.1.0: {} + tslib@2.8.1: optional: true typescript@6.0.3: {} + vite@8.0.13: + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.1 + tinyglobby: 0.2.16 + optionalDependencies: + fsevents: 2.3.3 + + vitest@4.1.6(vite@8.0.13): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.13) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.13 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + w3c-keyname@2.2.8: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/src/editor/extension.test.ts b/src/editor/extension.test.ts new file mode 100644 index 0000000..e04ca93 --- /dev/null +++ b/src/editor/extension.test.ts @@ -0,0 +1,39 @@ +import { Text } from "@codemirror/state"; +import { describe, expect, it, vi } from "vitest"; +import { isIgnoredMarkdownPosition } from "./extension"; + +vi.mock("obsidian", () => ({ + MarkdownView: class {}, +})); + +function positionOf(doc: Text, text: string): number { + const position = doc.toString().indexOf(text); + if (position === -1) { + throw new Error(`Could not find ${text}`); + } + + return position; +} + +describe("isIgnoredMarkdownPosition", () => { + it("allows normal markdown text", () => { + const doc = Text.of(["normal [1m|cm]"]); + expect(isIgnoredMarkdownPosition(doc, positionOf(doc, "[1m|cm]"))).toBe(false); + }); + + it("skips inline code", () => { + const doc = Text.of(["normal `[1m|cm]`"]); + expect(isIgnoredMarkdownPosition(doc, positionOf(doc, "[1m|cm]"))).toBe(true); + }); + + it("skips fenced code blocks", () => { + const doc = Text.of(["```", "[1m|cm]", "```"]); + expect(isIgnoredMarkdownPosition(doc, positionOf(doc, "[1m|cm]"))).toBe(true); + }); + + it("skips frontmatter", () => { + const doc = Text.of(["---", "distance: [1m|cm]", "---", "body [2m|cm]"]); + expect(isIgnoredMarkdownPosition(doc, positionOf(doc, "[1m|cm]"))).toBe(true); + expect(isIgnoredMarkdownPosition(doc, positionOf(doc, "[2m|cm]"))).toBe(false); + }); +}); diff --git a/src/editor/extension.ts b/src/editor/extension.ts index 143a5c0..d1197c3 100644 --- a/src/editor/extension.ts +++ b/src/editor/extension.ts @@ -7,11 +7,65 @@ import { WidgetType, } from "@codemirror/view"; import { RangeSetBuilder } from "@codemirror/state"; +import type { Text } from "@codemirror/state"; import UnitConverterPlugin from "../main"; import { convertValue, formatConversion, CONVERSION_REGEX } from "../utils/conversion"; import type { UnitCode } from "../utils/conversion"; import { MarkdownView } from "obsidian"; +function isFenceLine(line: string): boolean { + return /^\s{0,3}(```|~~~)/.test(line); +} + +function isInFrontmatter(doc: Text, lineNumber: number): boolean { + if (doc.lines === 0 || doc.line(1).text.trim() !== "---") { + return false; + } + + for (let currentLine = 2; currentLine <= doc.lines; currentLine++) { + if (doc.line(currentLine).text.trim() === "---") { + return lineNumber <= currentLine; + } + } + + return true; +} + +function isInFencedCode(doc: Text, lineNumber: number): boolean { + let isInFence = false; + + for (let currentLine = 1; currentLine <= lineNumber; currentLine++) { + const line = doc.line(currentLine).text; + if (!isFenceLine(line)) { + continue; + } + + if (currentLine === lineNumber) { + return true; + } + + isInFence = !isInFence; + } + + return isInFence; +} + +function isInInlineCode(line: string, offset: number): boolean { + const textBeforeMatch = line.slice(0, offset); + const unescapedBackticks = textBeforeMatch.match(/(?(); - const cursorPos = view.state.selection.main.head; const cursorLine = view.state.doc.lineAt(cursorPos).number; @@ -105,13 +162,17 @@ export function createUnitConversionExtension(plugin: UnitConverterPlugin) { const text = view.state.doc.sliceString(from, to); let match; + CONVERSION_REGEX.lastIndex = 0; while ((match = CONVERSION_REGEX.exec(text)) !== null) { const start = from + match.index; const end = start + match[0].length; const lineAtMatch = view.state.doc.lineAt(start).number; // Apply decoration if we're not on the active line - if (lineAtMatch !== cursorLine) { + if ( + lineAtMatch !== cursorLine && + !isIgnoredMarkdownPosition(view.state.doc, start) + ) { const value = parseFloat(match[1]); const fromUnit = match[2]; const toUnit = match[3]; diff --git a/src/editor/md-post-processor.test.ts b/src/editor/md-post-processor.test.ts new file mode 100644 index 0000000..1ef6a24 --- /dev/null +++ b/src/editor/md-post-processor.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { isWithinIgnoredMarkdownElement } from "./md-post-processor"; + +interface FakeElement { + tagName: string; + classList: { + contains: (className: string) => boolean; + }; + parentElement: FakeElement | null; +} + +function createNode(tagName: string, classNames: string[] = [], parentElement: FakeElement | null = null) { + const element: FakeElement = { + tagName, + classList: { + contains: (className: string) => classNames.includes(className), + }, + parentElement, + }; + + return { parentElement: element } as unknown as Node; +} + +describe("isWithinIgnoredMarkdownElement", () => { + it("skips text inside code and pre elements", () => { + expect(isWithinIgnoredMarkdownElement(createNode("CODE"))).toBe(true); + expect(isWithinIgnoredMarkdownElement(createNode("PRE"))).toBe(true); + }); + + it("skips text inside Obsidian code-related containers", () => { + expect(isWithinIgnoredMarkdownElement(createNode("SPAN", ["cm-inline-code"]))).toBe(true); + expect(isWithinIgnoredMarkdownElement(createNode("DIV", ["HyperMD-codeblock"]))).toBe(true); + }); + + it("allows normal markdown text", () => { + expect(isWithinIgnoredMarkdownElement(createNode("P"))).toBe(false); + }); +}); diff --git a/src/editor/md-post-processor.ts b/src/editor/md-post-processor.ts index 25cc66f..a82f3bc 100644 --- a/src/editor/md-post-processor.ts +++ b/src/editor/md-post-processor.ts @@ -1,10 +1,30 @@ -import { MarkdownPostProcessor } from "obsidian"; -import type { UnitConverterSettings } from "../settings"; +import type { MarkdownPostProcessor } from "obsidian"; +import type UnitConverterPlugin from "../main"; import { CONVERSION_REGEX, formatConversion } from "../utils/conversion"; -export function createMarkdownPostProcessor( - settings: UnitConverterSettings, -): MarkdownPostProcessor { +export function isWithinIgnoredMarkdownElement(node: Node): boolean { + let element = node.parentElement; + + while (element) { + if (element.tagName === "CODE" || element.tagName === "PRE") { + return true; + } + + if ( + element.classList.contains("HyperMD-codeblock") || + element.classList.contains("cm-inline-code") || + element.classList.contains("frontmatter") + ) { + return true; + } + + element = element.parentElement; + } + + return false; +} + +export function createMarkdownPostProcessor(plugin: UnitConverterPlugin): MarkdownPostProcessor { return (element: HTMLElement) => { const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null); let node = walker.nextNode(); @@ -12,11 +32,12 @@ export function createMarkdownPostProcessor( while (node) { const text = node.nodeValue; - if (!text) { + if (!text || isWithinIgnoredMarkdownElement(node)) { node = walker.nextNode(); continue; } + CONVERSION_REGEX.lastIndex = 0; const newText = text.replace( CONVERSION_REGEX, (match: string, valueStr: string, fromUnit: string, toUnit: string) => { @@ -25,9 +46,9 @@ export function createMarkdownPostProcessor( value, fromUnit, toUnit, - settings.useDescriptiveNames, - settings.showOriginalUnits, - settings.customUnits, + plugin.settings.useDescriptiveNames, + plugin.settings.showOriginalUnits, + plugin.settings.customUnits, ); }, ); diff --git a/src/main.ts b/src/main.ts index 6c1ada5..b408c5a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { Plugin, Editor } from "obsidian"; +import { Editor, MarkdownView, Plugin } from "obsidian"; import { DEFAULT_SETTINGS, UnitConverterSettings, UnitConverterSettingTab } from "./settings"; import DestinationUnitSuggest from "./suggest/to-unit-suggest"; import { ConversionModal } from "./modal/conversion-modal"; @@ -17,11 +17,9 @@ export default class UnitConverterPlugin extends Plugin { this.addSettingTab(new UnitConverterSettingTab(this)); - if (this.settings.isAutosuggestEnabled) { - this.registerEditorSuggest(new DestinationUnitSuggest(this)); - } + this.registerEditorSuggest(new DestinationUnitSuggest(this)); - this.registerMarkdownPostProcessor(createMarkdownPostProcessor(this.settings)); + this.registerMarkdownPostProcessor(createMarkdownPostProcessor(this)); this.registerEditorExtension(createUnitConversionExtension(this)); @@ -48,5 +46,19 @@ export default class UnitConverterPlugin extends Plugin { async saveSettings() { await this.saveData(this.settings); + this.refreshMarkdownViews(); + } + + refreshMarkdownViews() { + this.app.workspace.iterateAllLeaves((leaf) => { + if (!(leaf.view instanceof MarkdownView)) { + return; + } + + leaf.view.editor?.refresh(); + if (leaf.view.getMode() === "preview") { + leaf.view.previewMode.rerender(true); + } + }); } } diff --git a/src/settings.ts b/src/settings.ts index 7a81aaa..fe3e711 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -226,7 +226,7 @@ export class UnitConverterSettingTab extends PluginSettingTab { .addText((text) => { text.setPlaceholder("60").setValue(draft.factor.toString()); text.inputEl.setAttribute("type", "number"); - text.inputEl.setAttribute("min", "0"); + text.inputEl.setAttribute("min", "0.0000000001"); text.inputEl.setAttribute("step", "any"); text.onChange((value) => { draft.factor = Number(value); diff --git a/src/suggest/to-unit-suggest.test.ts b/src/suggest/to-unit-suggest.test.ts new file mode 100644 index 0000000..ce21720 --- /dev/null +++ b/src/suggest/to-unit-suggest.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import DestinationUnitSuggest from "./to-unit-suggest"; +import type { UnitConverterSettings } from "../settings"; + +vi.mock("obsidian", () => ({ + EditorSuggest: class { + context = null; + + constructor() {} + }, +})); + +function createSuggest(settings: Partial = {}) { + return new DestinationUnitSuggest({ + app: {}, + settings: { + useDescriptiveNames: false, + isAutosuggestEnabled: true, + showOriginalUnits: false, + customUnits: [], + ...settings, + }, + } as never); +} + +describe("DestinationUnitSuggest", () => { + it("does not trigger when autosuggest is disabled", () => { + const suggest = createSuggest({ isAutosuggestEnabled: false }); + const editor = { getLine: () => "[1m|" }; + + expect(suggest.onTrigger({ line: 0, ch: 4 }, editor as never)).toBeNull(); + }); + + it("uses the nearest unfinished conversion before the cursor", () => { + const suggest = createSuggest(); + const line = "[1m|cm] [2kg|"; + const editor = { getLine: () => line }; + + const trigger = suggest.onTrigger({ line: 0, ch: line.length }, editor as never); + + expect(trigger).toEqual({ + start: { line: 0, ch: 13 }, + end: { line: 0, ch: 13 }, + query: "kg", + }); + }); +}); diff --git a/src/suggest/to-unit-suggest.ts b/src/suggest/to-unit-suggest.ts index 100b830..9bd1f72 100644 --- a/src/suggest/to-unit-suggest.ts +++ b/src/suggest/to-unit-suggest.ts @@ -6,7 +6,7 @@ import { EditorSuggestContext, EditorSuggestTriggerInfo, } from "obsidian"; -import { getCompatibleUnits, isBuiltInUnit } from "../utils/conversion"; +import { getCompatibleUnits, isBuiltInUnit, PARTIAL_CONVERSION_REGEX } from "../utils/conversion"; interface DestinationUnitCompletion { label: string; @@ -19,11 +19,14 @@ export default class DestinationUnitSuggest extends EditorSuggest { + it("matches common numeric syntax", () => { + expect(matchConversion("[-3m|cm]")?.[1]).toBe("-3"); + expect(matchConversion("[.5m|cm]")?.[1]).toBe(".5"); + expect(matchConversion("[1.2m|cm]")?.[1]).toBe("1.2"); + expect(matchConversion("[1e3m|km]")).toMatchObject({ + 1: "1e3", + 2: "m", + 3: "km", + }); + }); + + it("rejects malformed numeric syntax", () => { + expect(matchConversion("[1.2.3m|cm]")).toBeNull(); + }); +}); + +describe("custom units", () => { + const customUnits: CustomUnitDefinition[] = [ + { + abbr: "step", + singular: "step", + plural: "steps", + measure: "length", + anchorUnit: "cm", + factor: 60, + }, + ]; + + it("requires a positive finite factor", () => { + expect(validateCustomUnit({ ...customUnits[0], factor: 0 }).isValid).toBe(false); + expect(validateCustomUnit({ ...customUnits[0], factor: -1 }).isValid).toBe(false); + expect(validateCustomUnit({ ...customUnits[0], factor: Number.POSITIVE_INFINITY }).isValid).toBe( + false, + ); + }); + + it("converts custom units through built-in anchor units", () => { + expect(convertValue(2, "step", "m", customUnits)).toBeCloseTo(1.2); + expect(convertValue(120, "cm", "step", customUnits)).toBeCloseTo(2); + }); +}); diff --git a/src/utils/conversion.ts b/src/utils/conversion.ts index 7c4e85e..ec936a0 100644 --- a/src/utils/conversion.ts +++ b/src/utils/conversion.ts @@ -27,7 +27,10 @@ export interface CustomUnitValidationResult { message?: string; } -const UNIT_CODE_REGEX = /^[a-zA-Z0-9\-/]+$/; +const UNIT_CODE_PATTERN = "[a-zA-Z0-9\\-/]+"; +const PARTIAL_UNIT_CODE_PATTERN = "[a-zA-Z0-9\\-/]*"; +const NUMBER_PATTERN = "-?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?"; +const UNIT_CODE_REGEX = new RegExp(`^${UNIT_CODE_PATTERN}$`); function getCustomUnit( unit: UnitCode, @@ -174,7 +177,7 @@ export function getDisplayUnit( return (value === 1 ? measure.singular : measure.plural).toLowerCase(); } } catch (e) { - console.error("Error getting descriptive name:", e); + console.debug("Error getting descriptive name:", e); } return unit.toString(); @@ -207,7 +210,7 @@ export function formatConversion( const originalUnit = getDisplayUnit(value, fromUnit, useDescriptiveNames, customUnits); return `${value} ${originalUnit} (${convertedValue.toFixed(precision)} ${displayUnit})`; } catch (e) { - console.error("Conversion error:", e); + console.debug("Conversion error:", e); return `[${value}${fromUnit}|${toUnit}]`; // Return original format on error } } @@ -237,8 +240,8 @@ export function validateCustomUnit( return { isValid: false, message: "Add singular and plural names." }; } - if (!Number.isFinite(unit.factor) || unit.factor < 0) { - return { isValid: false, message: "Factor must be 0 or greater." }; + if (!Number.isFinite(unit.factor) || unit.factor <= 0) { + return { isValid: false, message: "Factor must be greater than 0." }; } if (!isBuiltInUnit(anchorUnit)) { @@ -274,6 +277,16 @@ export function validateCustomUnit( } /** - * Regular expression to match unit conversion syntax + * Regular expression to match unit conversion syntax. */ -export const CONVERSION_REGEX = /\[([\d.]+)([a-zA-Z0-9\-/]+)\|([a-zA-Z0-9\-/]+)\]/g; +export const CONVERSION_REGEX = new RegExp( + `\\[(${NUMBER_PATTERN})(${UNIT_CODE_PATTERN})\\|(${UNIT_CODE_PATTERN})\\]`, + "g", +); + +/** + * Regular expression to match an unfinished conversion before the cursor. + */ +export const PARTIAL_CONVERSION_REGEX = new RegExp( + `\\[(${NUMBER_PATTERN})(${UNIT_CODE_PATTERN})\\|(${PARTIAL_UNIT_CODE_PATTERN})$`, +);