.pragma library // Unit conversion. Linear units map to a base via { dim, f }; temperature // uses explicit to/from-base helpers to handle the offset. const TABLE = { // length (base: meter) "m": { dim: "len", f: 1 }, "meter": { dim: "len", f: 1 }, "meters": { dim: "len", f: 1 }, "km": { dim: "len", f: 1000 }, "cm": { dim: "len", f: 0.01 }, "mm": { dim: "len", f: 0.001 }, "um": { dim: "len", f: 1e-6 }, "nm": { dim: "len", f: 1e-9 }, "in": { dim: "len", f: 0.0254 }, "inch": { dim: "len", f: 0.0254 }, "inches": { dim: "len", f: 0.0254 }, "ft": { dim: "len", f: 0.3048 }, "foot": { dim: "len", f: 0.3048 }, "feet": { dim: "len", f: 0.3048 }, "yd": { dim: "len", f: 0.9144 }, "yard": { dim: "len", f: 0.9144 }, "yards": { dim: "len", f: 0.9144 }, "mi": { dim: "len", f: 1609.344 }, "mile": { dim: "len", f: 1609.344 }, "miles": { dim: "len", f: 1609.344 }, "nmi": { dim: "len", f: 1852 }, // mass (base: gram) "g": { dim: "mass", f: 1 }, "gram": { dim: "mass", f: 1 }, "grams": { dim: "mass", f: 1 }, "kg": { dim: "mass", f: 1000 }, "mg": { dim: "mass", f: 0.001 }, "lb": { dim: "mass", f: 453.59237 }, "lbs": { dim: "mass", f: 453.59237 }, "oz": { dim: "mass", f: 28.349523125 }, "ton": { dim: "mass", f: 1e6 }, "tonne": { dim: "mass", f: 1e6 }, "t": { dim: "mass", f: 1e6 }, // volume (base: liter) "l": { dim: "vol", f: 1 }, "liter": { dim: "vol", f: 1 }, "liters": { dim: "vol", f: 1 }, "ml": { dim: "vol", f: 0.001 }, "cl": { dim: "vol", f: 0.01 }, "gal": { dim: "vol", f: 3.785411784 }, "gallon": { dim: "vol", f: 3.785411784 }, "qt": { dim: "vol", f: 0.946352946 }, "pt": { dim: "vol", f: 0.473176473 }, "cup": { dim: "vol", f: 0.2365882365 }, "cups": { dim: "vol", f: 0.2365882365 }, "floz": { dim: "vol", f: 0.0295735295625 }, "tbsp": { dim: "vol", f: 0.01478676478125 }, "tsp": { dim: "vol", f: 0.00492892159375 }, // time (base: second) "s": { dim: "time", f: 1 }, "sec": { dim: "time", f: 1 }, "secs": { dim: "time", f: 1 }, "ms": { dim: "time", f: 0.001 }, "us": { dim: "time", f: 1e-6 }, "min": { dim: "time", f: 60 }, "mins": { dim: "time", f: 60 }, "h": { dim: "time", f: 3600 }, "hr": { dim: "time", f: 3600 }, "hrs": { dim: "time", f: 3600 }, "hour": { dim: "time", f: 3600 }, "hours": { dim: "time", f: 3600 }, "day": { dim: "time", f: 86400 }, "days": { dim: "time", f: 86400 }, "week": { dim: "time", f: 604800 }, "weeks": { dim: "time", f: 604800 }, "year": { dim: "time", f: 31557600 }, "years": { dim: "time", f: 31557600 }, // data (base: byte; SI and binary) "b": { dim: "data", f: 1 }, "byte": { dim: "data", f: 1 }, "bytes": { dim: "data", f: 1 }, "kb": { dim: "data", f: 1e3 }, "mb": { dim: "data", f: 1e6 }, "gb": { dim: "data", f: 1e9 }, "tb": { dim: "data", f: 1e12 }, "pb": { dim: "data", f: 1e15 }, "kib": { dim: "data", f: 1024 }, "mib": { dim: "data", f: 1048576 }, "gib": { dim: "data", f: 1073741824 }, "tib": { dim: "data", f: 1099511627776 }, "bit": { dim: "data", f: 0.125 }, "bits": { dim: "data", f: 0.125 }, // speed (base: m/s) "mps": { dim: "speed", f: 1 }, "kph": { dim: "speed", f: 1 / 3.6 }, "kmh": { dim: "speed", f: 1 / 3.6 }, "mph": { dim: "speed", f: 0.44704 }, "knot": { dim: "speed", f: 0.514444 }, "knots": { dim: "speed", f: 0.514444 }, // angle (base: radian) "rad": { dim: "angle", f: 1 }, "deg": { dim: "angle", f: Math.PI / 180 }, "turn": { dim: "angle", f: 2 * Math.PI }, "grad": { dim: "angle", f: Math.PI / 200 }, // temperature (offset; handled specially) "c": { dim: "temp" }, "celsius": { dim: "temp" }, "f": { dim: "temp" }, "fahrenheit": { dim: "temp" }, "k": { dim: "temp" }, "kelvin": { dim: "temp" } }; function tempToK(v, u) { if (u === "c" || u === "celsius") return v + 273.15; if (u === "f" || u === "fahrenheit") return (v - 32) * 5 / 9 + 273.15; return v; } function tempFromK(v, u) { if (u === "c" || u === "celsius") return v - 273.15; if (u === "f" || u === "fahrenheit") return (v - 273.15) * 9 / 5 + 32; return v; } // ISO codes Frankfurter supports. Used to detect currency queries before we // have rates cached (rates are fetched lazily and cached per-day). const CURRENCIES = ["AUD","BGN","BRL","CAD","CHF","CNY","CZK","DKK","EUR","GBP", "HKD","HUF","IDR","ILS","INR","ISK","JPY","KRW","MXN","NOK", "NZD","PHP","PLN","RON","SEK","SGD","THB","TRY","USD","ZAR"]; const CURRENCY_SET = (function() { const s = {}; for (const c of CURRENCIES) s[c] = 1; return s; })(); function isCurrency(code) { return !!CURRENCY_SET[code.toUpperCase()]; } // Parse the " to[ ]" shape used by both unit and // currency conversion. Returns null if it doesn't match. function parseQuery(expr) { const m = expr.match(/^(-?\d+(?:\.\d+)?(?:e-?\d+)?)\s*([a-zA-Z]+)\s+(?:to|in|->)(?:\s+([a-zA-Z]*))?$/i); if (!m) return null; return { amount: parseFloat(m[1]), from: m[2].toLowerCase(), partial: (m[3] || "").toLowerCase() }; } function applyConversion(v, fu, tu) { const fd = TABLE[fu], td = TABLE[tu]; if (fd.dim === "temp") return tempFromK(tempToK(v, fu), tu); return v * fd.f / td.f; } // Parse " to|in|-> " and return { value, from, to } or null. function convert(expr) { const m = expr.match(/^(-?\d+(?:\.\d+)?(?:e-?\d+)?)\s*([a-zA-Z]+)\s+(?:to|in|->)\s+([a-zA-Z]+)$/i); if (!m) return null; const v = parseFloat(m[1]); const fu = m[2].toLowerCase(); const tu = m[3].toLowerCase(); const fd = TABLE[fu], td = TABLE[tu]; if (!fd || !td || fd.dim !== td.dim) return null; return { value: applyConversion(v, fu, tu), from: fu, to: tu }; } // Build, per dimension, the list of canonical unit names — deduped so e.g. // "m"/"meter"/"meters" don't all appear; prefer the shortest alias. const CANONICAL_BY_DIM = (function() { const byDim = {}; const tempCanon = { "c": 1, "f": 1, "k": 1 }; for (const name in TABLE) { const td = TABLE[name]; if (!byDim[td.dim]) byDim[td.dim] = {}; const bucket = byDim[td.dim]; const key = td.dim === "temp" ? name : String(td.f); if (td.dim === "temp" && !tempCanon[name]) continue; if (bucket[key] === undefined || name.length < bucket[key].length) { bucket[key] = name; } } const out = {}; for (const dim in byDim) out[dim] = Object.values(byDim[dim]); return out; })(); // Given a fetched-rate table { TGT: rate, ... } where amount-of-base * rate = // amount-of-target, return ordered { value, from, to } entries filtered by // `partial`. Mirrors suggest() for units. function currencyConvert(amount, base, partial, rates) { const baseU = base.toUpperCase(); const candidates = Object.keys(rates).filter(c => c !== baseU); let ordered; if (!partial) { ordered = candidates.slice().sort(); } else { const p = partial.toUpperCase(); const scored = []; for (const c of candidates) { const s = scoreFuzzy(p, c); if (s > 0) scored.push({ c, s }); } scored.sort((a, b) => b.s - a.s); ordered = scored.map(x => x.c); } return ordered.map(c => ({ value: amount * rates[c], from: baseU, to: c })); } function scoreFuzzy(q, name) { const idx = name.indexOf(q); if (idx === 0) return 1000 - name.length; if (idx > 0) return 500 - name.length; let qi = 0; for (let i = 0; i < name.length && qi < q.length; i++) { if (name[i] === q[qi]) qi++; } return qi === q.length ? 100 - name.length : 0; } // Parse " to|in|->[ ]" and return an array of // { value, from, to } for every unit in 's dimension that fuzzy-matches // (or all of them if is empty). Returns null if the input // doesn't fit the "awaiting target unit" shape. function suggest(expr) { const m = expr.match(/^(-?\d+(?:\.\d+)?(?:e-?\d+)?)\s*([a-zA-Z]+)\s+(?:to|in|->)(?:\s+([a-zA-Z]*))?$/i); if (!m) return null; const v = parseFloat(m[1]); const fu = m[2].toLowerCase(); const partial = (m[3] || "").toLowerCase(); const fd = TABLE[fu]; if (!fd) return null; const candidates = (CANONICAL_BY_DIM[fd.dim] || []).filter(u => u !== fu); let ordered; if (!partial) { ordered = candidates.slice().sort(); } else { const scored = []; for (const u of candidates) { const s = scoreFuzzy(partial, u); if (s > 0) scored.push({ u, s }); } scored.sort((a, b) => b.s - a.s); ordered = scored.map(x => x.u); } return ordered.map(tu => ({ value: applyConversion(v, fu, tu), from: fu, to: tu })); }