{
  "format" : 1,
  "manifest" : {
    "accent" : "orange",
    "capabilities" : [
      {
        "network" : {
          "hosts" : [
            "api.frankfurter.dev"
          ]
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Calculator",
    "id" : "io.navbytes.stackbar.plugins.calculator",
    "minimumStackbar" : "0.21.0",
    "name" : "Calculator",
    "summary" : "Sums, units, currencies and time zones answered right in the command palette.",
    "surfaces" : [
      "panel",
      "settings"
    ],
    "symbol" : "equal.square.fill",
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "every" : {
          "seconds" : 21600
        }
      },
      {
        "wake" : {

        }
      }
    ],
    "version" : "1.6.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "TgCtYmwQsfgCXryWHlwKH/cPiQCywVzRM/Dga2rBqQzGYR9p8K6Kc0IYjTBF6Auf3WpATfLJ2D/nAJlI6ykQAg==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// Sums, unit and currency conversions and times across zones right in the\n/// palette, as Raycast does: type `12*(3+4)`, `2^10`, `15% * 80`, `sqrt(2)`,\n/// `3h in min`, `72 f to c`, `5 km in mi`, `100 usd in eur` or\n/// `5pm london in tokyo` and the answer leads the results; Return copies it.\n/// Plain numbers and words get no answer, so it never crowds a normal search.\n@main\nfinal class Calculator: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.calculator\",\n        name: \"Calculator\",\n        version: \"1.6.0\",\n        summary: \"Sums, units, currencies and time zones answered right in the command palette.\",\n        surfaces: [.panel, .settings],\n        // Exchange rates are fetched at launch, on wake and every six hours\n        // after (and retried with a growing pause when a fetch fails), unless currency conversion is turned off in Settings.\n        triggers: [.launch, .every(.seconds(6 * 3600)), .wake],\n        capabilities: [.network(\"api.frankfurter.dev\")],\n        minimumStackbar: \"0.21.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Calculator\",\n        accent: .orange,\n        symbol: \"equal.square.fill\"\n    )\n\n    static let previews: [PreviewScene<Calculator>] = [\n        PreviewScene(\"hero\", \"Answers as you type\") { plugin in\n            plugin.history = [\n                Entry(expression: \"12*(3+4)\", result: \"84\", display: \"84\"),\n                Entry(expression: \"5 km → mi\", result: \"3.106855961\", display: \"3.106855961 mi\"),\n                Entry(expression: \"72 °F → °C\", result: \"22.22222222\", display: \"22.22222222 °C\"),\n            ]\n        },\n        PreviewScene(\"conversions\", \"Currencies and time zones too\") { plugin in\n            plugin.history = [\n                Entry(expression: \"100 USD → EUR · rates of 2026-11-06\", result: \"89.57\", display: \"89.57 EUR\"),\n                Entry(expression: \"5:00 PM London → Tokyo (next day)\", result: \"2:00 AM\", display: \"2:00 AM in Tokyo\"),\n                Entry(expression: \"60 mph → km/h\", result: \"96.56064\", display: \"96.56064 km/h\"),\n            ]\n        },\n    ]\n\n    let theme = Theme.orange\n\n    struct Entry: Codable, Equatable, Identifiable {\n        var expression: String\n        var result: String\n        var display: String\n        var id: String { expression }\n    }\n\n    /// The last answers copied, newest first.\n    @Stored(\"history\") var history: [Entry] = []\n\n    /// The European Central Bank's reference rates, from Frankfurter (no\n    /// key, about 30 currencies), kept so the palette never waits on them.\n    @Stored(\"rates\") var rates: Math.Rates? = nil\n\n    /// Off: no exchange rates are fetched, so the plugin makes no network\n    /// requests at all, and currency sums get no answer.\n    @Stored(\"currency\") var currency = true\n\n    func refresh() async throws {\n        guard currency else { return }\n        if let rates, Date().timeIntervalSince(rates.fetched) < 6 * 3600 { return }\n        do {\n            try await fetchRates()\n        } catch {\n            retryRates()\n            throw error\n        }\n    }\n\n    func fetchRates() async throws {\n        struct Latest: Decodable { var base: String; var date: String; var rates: [String: Double] }\n        let latest: Latest = try await Stackbar.getJSON(\"https://api.frankfurter.dev/v1/latest?base=EUR\")\n        var table = latest.rates\n        table[latest.base] = 1\n        rates = Math.Rates(perEuro: table, date: latest.date, fetched: Date())\n    }\n\n    private var retrying: Task<Void, Never>?\n\n    /// After a failed fetch (offline at launch, say), tries again after 30\n    /// seconds, 2, 10 and 30 minutes instead of waiting for the next six-hour tick.\n    private func retryRates() {\n        guard retrying == nil else { return }\n        retrying = Task {\n            for pause in [30, 120, 600, 1800] {\n                try? await Task.sleep(for: .seconds(pause))\n                guard !Task.isCancelled, self.currency else { break }\n                if (try? await self.fetchRates()) != nil { break }\n            }\n            self.retrying = nil\n        }\n    }\n\n    func copy(_ answer: Math.Answer) {\n        Stackbar.copy(answer.result)\n        let entry = Entry(expression: answer.expression, result: answer.result, display: answer.display)\n        history = Array(([entry] + history.filter { $0.expression != entry.expression }).prefix(10))\n    }\n\n    // MARK: Commands\n\n    var commands: [Command] {\n        Command(\"calculate\", title: \"Calculate\", subtitle: \"Sums, units, currencies and time zones\", symbol: \"equal.square\", keywords: [\"math\", \"sum\", \"convert\", \"units\", \"currency\", \"time zone\"])\n            .view { invocation in\n                let typed = invocation.text.trimmingCharacters(in: .whitespaces)\n                if let answer = Math.answer(invocation.text, rates: self.currency ? self.rates : nil) {\n                    List {\n                        // The title starts with what was typed, so the\n                        // answer leads the palette's results.\n                        Row(\"\\(typed) = \\(answer.display)\", subtitle: answer.expression, symbol: \"equal\", key: \"answer\")\n                            .action(\"Copy Answer\") { self.copy(answer) }\n                    }\n                } else if (self.rates == nil || !self.currency) && Math.Rates.looksLikeMoney(invocation.text) {\n                    ContentUnavailableView(\"No exchange rates\", symbol: \"dollarsign.arrow.circlepath\", message: self.currency ? \"Calculator couldn't reach api.frankfurter.dev yet. It tries again in a few minutes.\" : \"Currency conversion is off in Calculator's settings.\")\n                } else if Math.undefined(invocation.text) {\n                    ContentUnavailableView(\"No answer\", symbol: \"divide\", message: \"That divides by zero or has no real answer.\")\n                } else if typed.isEmpty, !self.history.isEmpty {\n                    List {\n                        Section(\"Recent answers\") {\n                            ForEach(self.history) { entry in\n                                Row(entry.display, subtitle: entry.expression, symbol: \"clock.arrow.circlepath\", key: \"history-\" + entry.expression)\n                                    .action(\"Copy Answer\") { Stackbar.copy(entry.result) }\n                            }\n                        }\n                    }\n                } else {\n                    ContentUnavailableView(\"Type a sum\", symbol: \"equal.square\", message: \"Like 12*(3+4), 15% * 80, 3h in min, 100 usd in eur or 5pm london in tokyo.\")\n                }\n            }\n            // Only text that can have an answer (a number, or `now in\n            // tokyo`) reaches it. A matched pattern also puts the answer\n            // above everything else, so a past answer in the panel, which\n            // the palette finds too, never sits on top of the live one.\n            .rootSearch(section: \"Calculator\", matching: #\"(?is).*(\\p{N}| in | to ).*\"#)\n    }\n\n    // MARK: Panel\n\n    var panel: ViewContent {\n        PanelHeader(\"Calculator\", subtitle: \"Type a sum in the palette\", symbol: \"equal.square.fill\", theme: theme)\n        if history.isEmpty {\n            EmptyState(\"No answers yet\", symbol: \"equal.square\", message: \"Open the palette and type a sum or a conversion, like 3h in min. The answer comes first; Return copies it.\", theme: theme)\n        } else {\n            Card(\"Recent answers\", spacing: 0, padding: Space.s) {\n                ForEach(history) { entry in\n                    Row(entry.display, subtitle: entry.expression, symbol: \"equal\", key: entry.id)\n                        .action(\"Copy\") { Stackbar.copy(entry.result) }\n                }\n            }\n        }\n    }\n\n    // MARK: Settings\n\n    var settings: ViewContent {\n        Section(\"Currencies\") {\n            Toggle(\"Convert currencies\", isOn: currency) { on in\n                self.currency = on\n                if on {\n                    try await self.refresh()\n                } else {\n                    self.rates = nil\n                }\n            }\n            Text(currency\n                ? \"Exchange rates come from api.frankfurter.dev (the European Central Bank's reference rates), fetched at launch and every six hours.\"\n                : \"Off: Calculator makes no network requests. Sums, units and time zones still work.\").role(.secondary)\n        }\n    }\n\n    // MARK: Math\n\n    /// The parser and the unit table, with no host calls, so it is quick\n    /// enough to run on every keystroke.\n    enum Math {\n        struct Answer: Equatable, Sendable {\n            /// What was understood, like `3 h → min`.\n            var expression: String\n            /// The answer as shown and copied, like `180`.\n            var result: String\n            /// With its unit, like `180 min`; the same as `result` for a sum.\n            var display: String\n        }\n\n        static func answer(_ query: String, rates: Rates? = nil, now: Date = Date(), here: TimeZone = .current) -> Answer? {\n            let text = query.trimmingCharacters(in: .whitespaces)\n            guard text.count <= 200 else { return nil }\n            if let time = Times.answer(text, now: now, here: here) { return time }\n            guard text.contains(where: \\.isNumber) else { return nil }\n            if let conversion = convert(text) { return conversion }\n            if let rates, let money = rates.convert(text) { return money }\n            var parser = Parser(text)\n            guard let value = parser.parse(), parser.sawOperator, value.isFinite else { return nil }\n            let result = format(value)\n            return Answer(expression: text, result: result, display: result)\n        }\n\n        /// A sum that has an operator but no real answer, like `1/0`.\n        static func undefined(_ query: String) -> Bool {\n            let text = query.trimmingCharacters(in: .whitespaces)\n            var parser = Parser(text)\n            guard let value = parser.parse(), parser.sawOperator else { return false }\n            return !value.isFinite\n        }\n\n        // MARK: Numbers\n\n        /// Up to ten significant digits, no trailing zeros, no exponent for\n        /// everyday sizes.\n        static func format(_ value: Double, decimalComma: Bool = Math.localeUsesDecimalComma) -> String {\n            let text = point(value)\n            return decimalComma && !text.contains(\"e\") ? text.replacingOccurrences(of: \".\", with: \",\") : text\n        }\n\n        /// `format` with a point as the decimal mark.\n        private static func point(_ value: Double) -> String {\n            let value = value == 0 ? 0 : value // no \"-0\"\n            if value.rounded() == value, abs(value) < 1e15 { return String(Int64(value)) }\n            var text = String(format: \"%.10g\", value)\n            if text.contains(\"e\") { return text }\n            if text.contains(\".\") {\n                while text.hasSuffix(\"0\") { text.removeLast() }\n                if text.hasSuffix(\".\") { text.removeLast() }\n            }\n            return text\n        }\n\n        /// Whether this Mac writes 2,5 for two and a half.\n        static let localeUsesDecimalComma = Locale.current.decimalSeparator == \",\"\n\n        /// A number as typed, with `.` or `,` as the decimal mark or as\n        /// thousands separators:\n        /// - `1,234.56` and `1.234,56` have both, so the last one is the decimal mark;\n        /// - `1,234,567` is a thousands grouping, and `2,5` or `0,500` cannot be one,\n        ///   so a comma there is a decimal comma;\n        /// - a single separator before exactly three digits (`1,000`) is a\n        ///   thousands separator, unless this Mac's decimal mark is that very\n        ///   character (a German Mac reads `1,000` as 1 and `1.000` as 1000);\n        /// - commas in the Indian style (`1,00,000`, `12,34,567`: a last group\n        ///   of three, earlier groups of two) are thousands grouping too;\n        /// - a lone `.` or `,` is not a number.\n        static func number(_ token: String, decimalComma: Bool) -> Double? {\n            guard !token.isEmpty, token.contains(where: \\.isNumber) else { return nil }\n            /// `1,234,567`: one to three digits, then groups of exactly three;\n            /// or, with commas, `12,34,567`: one or two digits, groups of two,\n            /// then a last group of three.\n            func grouped(_ text: Substring, by mark: Character) -> Bool {\n                let parts = text.split(separator: mark, omittingEmptySubsequences: false)\n                guard parts.count >= 2, let first = parts.first, (1...3).contains(first.count), first.first != \"0\" else { return false }\n                guard parts.allSatisfy({ $0.allSatisfy(\\.isASCII) && $0.allSatisfy(\\.isNumber) }) else { return false }\n                if parts.dropFirst().allSatisfy({ $0.count == 3 }) { return true }\n                guard mark == \",\", parts.count >= 3, first.count <= 2, parts.last?.count == 3 else { return false }\n                return parts.dropFirst().dropLast().allSatisfy { $0.count == 2 }\n            }\n            func clean(_ text: Substring, mark: Character) -> String { text.filter { $0 != mark } }\n            let hasDot = token.contains(\".\"), hasComma = token.contains(\",\")\n            var normalized: String\n            switch (hasDot, hasComma) {\n            case (false, false):\n                normalized = token\n            case (true, true):\n                guard let dot = token.lastIndex(of: \".\"), let comma = token.lastIndex(of: \",\") else { return nil }\n                let (group, decimal): (Character, Character) = dot > comma ? (\",\", \".\") : (\".\", \",\")\n                let parts = token.split(separator: decimal, omittingEmptySubsequences: false)\n                guard parts.count == 2, grouped(parts[0], by: group) else { return nil }\n                normalized = clean(parts[0], mark: group) + \".\" + parts[1]\n            default:\n                let mark: Character = hasComma ? \",\" : \".\"\n                let parts = token.split(separator: mark, omittingEmptySubsequences: false)\n                let isGrouping = grouped(Substring(token), by: mark)\n                let isLocaleDecimalMark = (mark == \",\") == decimalComma\n                if parts.count > 2 {\n                    guard isGrouping else { return nil }\n                    normalized = clean(Substring(token), mark: mark)\n                } else if isGrouping && !isLocaleDecimalMark {\n                    normalized = clean(Substring(token), mark: mark)\n                } else {\n                    normalized = (parts[0].isEmpty ? \"0\" : String(parts[0])) + (parts[1].isEmpty ? \"\" : \".\" + parts[1])\n                }\n            }\n            return Double(normalized)\n        }\n\n        /// Recursive descent: + - over * / % over unary minus over ^ over\n        /// numbers, parentheses, `pi`, `e` and `sqrt(…)`.\n        struct Parser {\n            private let characters: [Character]\n            private var index = 0\n            private(set) var sawOperator = false\n            /// Whether the last product was a bare percent (`10%`), which a sum\n            /// adds or takes as a share of the amount before it.\n            private var lastWasBarePercent = false\n\n            private let decimalComma: Bool\n\n            init(_ text: String, decimalComma: Bool = Math.localeUsesDecimalComma) {\n                characters = Array(text.lowercased())\n                self.decimalComma = decimalComma\n            }\n\n            mutating func parse() -> Double? {\n                guard let value = sum() else { return nil }\n                skipSpaces()\n                return index == characters.count ? value : nil\n            }\n\n            private mutating func skipSpaces() {\n                while index < characters.count, characters[index].isWhitespace { index += 1 }\n            }\n\n            private mutating func take(_ options: Set<Character>) -> Character? {\n                skipSpaces()\n                guard index < characters.count, options.contains(characters[index]) else { return nil }\n                defer { index += 1 }\n                return characters[index]\n            }\n\n            private mutating func sum() -> Double? {\n                guard var value = product() else { return nil }\n                // `100 + 10%` adds a tenth of 100, but `10% + 5%` is 15%, not a\n                // share of 10%: a bare percent on the left is already a fraction.\n                var leftIsPercent = lastWasBarePercent\n                while let sign = take([\"+\", \"-\", \"−\"]) {\n                    sawOperator = true\n                    guard let next = product() else { return nil }\n                    let delta = lastWasBarePercent && !leftIsPercent ? value * next : next\n                    value = sign == \"+\" ? value + delta : value - delta\n                    leftIsPercent = false\n                }\n                return value\n            }\n\n            private mutating func product() -> Double? {\n                guard var value = unary() else { return nil }\n                // Only a number and its % signs is a bare percent: in `200 + 100 * 10%`\n                // the percent takes the product, so it is a plain 10.\n                var sawPercent = false\n                var sawMultiplication = false\n                while true {\n                    if take([\"%\"]) != nil {\n                        // `15%` alone is 0.15; `10 % 3` would read as a modulo\n                        // nobody types here, so % is always a percent.\n                        sawOperator = true\n                        sawPercent = true\n                        value /= 100\n                        continue\n                    }\n                    guard let sign = take([\"*\", \"/\", \"×\", \"÷\", \"x\"]) else {\n                        lastWasBarePercent = sawPercent && !sawMultiplication\n                        return value\n                    }\n                    sawOperator = true\n                    sawMultiplication = true\n                    guard let next = unary() else { return nil }\n                    value = (sign == \"/\" || sign == \"÷\") ? value / next : value * next\n                }\n            }\n\n            private mutating func unary() -> Double? {\n                if take([\"-\", \"−\"]) != nil {\n                    return power().map { -$0 }\n                }\n                _ = take([\"+\"])\n                return power()\n            }\n\n            private mutating func power() -> Double? {\n                guard let base = atom() else { return nil }\n                if take([\"^\"]) != nil {\n                    sawOperator = true\n                    guard let exponent = unary() else { return nil }\n                    return pow(base, exponent)\n                }\n                return base\n            }\n\n            private mutating func atom() -> Double? {\n                skipSpaces()\n                guard index < characters.count else { return nil }\n                if take([\"(\"]) != nil {\n                    guard let value = sum(), take([\")\"]) != nil else { return nil }\n                    return value\n                }\n                if word(\"sqrt\") {\n                    sawOperator = true\n                    guard take([\"(\"]) != nil, let value = sum(), take([\")\"]) != nil else { return nil }\n                    return value.squareRoot()\n                }\n                if word(\"pi\") || word(\"π\") { return .pi }\n                if constant(\"e\") { return M_E }\n                return number()\n            }\n\n            private mutating func word(_ name: String) -> Bool {\n                skipSpaces()\n                let letters = Array(name)\n                guard index + letters.count <= characters.count, Array(characters[index..<index + letters.count]) == letters else { return false }\n                index += letters.count\n                return true\n            }\n\n            /// A one-letter constant, unless letters follow it (`exp` is not `e`).\n            private mutating func constant(_ letter: Character) -> Bool {\n                skipSpaces()\n                guard index < characters.count, characters[index] == letter else { return false }\n                guard index + 1 >= characters.count || !characters[index + 1].isLetter else { return false }\n                index += 1\n                return true\n            }\n\n            private mutating func number() -> Double? {\n                let start = index\n                while index < characters.count, characters[index].isNumber || characters[index] == \".\" || characters[index] == \",\" {\n                    index += 1\n                }\n                return Math.number(String(characters[start..<index]), decimalComma: decimalComma)\n            }\n        }\n\n        // MARK: Units\n\n        struct Unit {\n            var symbol: String\n            var category: String\n            /// How many of the category's base unit one of these is.\n            var factor: Double\n        }\n\n        /// Every spelling a unit is typed with, lowercased.\n        static let units: [String: Unit] = {\n            var table: [String: Unit] = [:]\n            func add(_ category: String, _ symbol: String, _ factor: Double, _ names: [String]) {\n                for name in names + [symbol.lowercased()] { table[name] = Unit(symbol: symbol, category: category, factor: factor) }\n            }\n            add(\"length\", \"mm\", 0.001, [\"millimeter\", \"millimeters\", \"millimetre\", \"millimetres\"])\n            add(\"length\", \"cm\", 0.01, [\"centimeter\", \"centimeters\", \"centimetre\", \"centimetres\"])\n            add(\"length\", \"m\", 1, [\"meter\", \"meters\", \"metre\", \"metres\"])\n            add(\"length\", \"km\", 1000, [\"kilometer\", \"kilometers\", \"kilometre\", \"kilometres\"])\n            add(\"length\", \"in\", 0.0254, [\"inch\", \"inches\", \"\\\"\"])\n            add(\"length\", \"ft\", 0.3048, [\"foot\", \"feet\", \"'\"])\n            add(\"length\", \"yd\", 0.9144, [\"yard\", \"yards\"])\n            add(\"length\", \"mi\", 1609.344, [\"mile\", \"miles\"])\n            add(\"mass\", \"mg\", 0.001, [\"milligram\", \"milligrams\"])\n            add(\"mass\", \"g\", 1, [\"gram\", \"grams\"])\n            add(\"mass\", \"kg\", 1000, [\"kilogram\", \"kilograms\", \"kilo\", \"kilos\"])\n            add(\"mass\", \"oz\", 28.349523125, [\"ounce\", \"ounces\"])\n            add(\"mass\", \"lb\", 453.59237, [\"lbs\", \"pound\", \"pounds\"])\n            add(\"mass\", \"t\", 1_000_000, [\"tonne\", \"tonnes\"])\n            add(\"time\", \"ms\", 0.001, [\"millisecond\", \"milliseconds\"])\n            add(\"time\", \"s\", 1, [\"sec\", \"secs\", \"second\", \"seconds\"])\n            add(\"time\", \"min\", 60, [\"mins\", \"minute\", \"minutes\"])\n            add(\"time\", \"h\", 3600, [\"hr\", \"hrs\", \"hour\", \"hours\"])\n            add(\"time\", \"d\", 86400, [\"day\", \"days\"])\n            add(\"time\", \"wk\", 604_800, [\"week\", \"weeks\"])\n            add(\"data\", \"B\", 1, [\"byte\", \"bytes\"])\n            add(\"data\", \"KB\", 1e3, [\"kilobyte\", \"kilobytes\"])\n            add(\"data\", \"MB\", 1e6, [\"megabyte\", \"megabytes\"])\n            add(\"data\", \"GB\", 1e9, [\"gigabyte\", \"gigabytes\"])\n            add(\"data\", \"TB\", 1e12, [\"terabyte\", \"terabytes\"])\n            add(\"data\", \"KiB\", 1024, [])\n            add(\"data\", \"MiB\", 1_048_576, [])\n            add(\"data\", \"GiB\", 1_073_741_824, [])\n            add(\"volume\", \"ml\", 0.001, [\"milliliter\", \"milliliters\", \"millilitre\", \"millilitres\"])\n            add(\"volume\", \"l\", 1, [\"liter\", \"liters\", \"litre\", \"litres\"])\n            add(\"volume\", \"tsp\", 0.00492892159375, [\"teaspoon\", \"teaspoons\"])\n            add(\"volume\", \"tbsp\", 0.01478676478125, [\"tablespoon\", \"tablespoons\"])\n            add(\"volume\", \"fl oz\", 0.0295735295625, [\"floz\"])\n            add(\"volume\", \"cup\", 0.2365882365, [\"cups\"])\n            add(\"volume\", \"gal\", 3.785411784, [\"gallon\", \"gallons\"])\n            add(\"speed\", \"m/s\", 1, [\"mps\"])\n            add(\"speed\", \"km/h\", 1 / 3.6, [\"kph\", \"kmh\", \"kmph\"])\n            add(\"speed\", \"mph\", 0.44704, [])\n            add(\"speed\", \"kn\", 0.514444, [\"knot\", \"knots\", \"kt\"])\n            add(\"temperature\", \"°C\", 1, [\"c\", \"celsius\", \"°c\"])\n            add(\"temperature\", \"°F\", 1, [\"f\", \"fahrenheit\", \"°f\"])\n            add(\"temperature\", \"K\", 1, [\"kelvin\"])\n            return table\n        }()\n\n        /// `<sum> <unit> in|to|as <unit>`.\n        static func convert(_ text: String) -> Answer? {\n            let lower = text.lowercased()\n            // The last ` in `/` to `/` as ` whose right side is a unit, so\n            // `12 in to cm` reads as inches to centimetres.\n            var found: (left: String, to: Unit, at: Int)?\n            for word in [\" in \", \" to \", \" as \"] {\n                guard let range = lower.range(of: word, options: .backwards),\n                      let unit = units[lower[range.upperBound...].trimmingCharacters(in: .whitespaces)] else { continue }\n                let at = range.lowerBound.utf16Offset(in: lower)\n                if let current = found, current.at > at { continue }\n                found = (String(lower[..<range.lowerBound]), unit, at)\n            }\n            guard let found else { return nil }\n            let to = found.to\n            // The unit is everything after the last digit or closing bracket:\n            // `5km`, `3 h`, `2 fl oz`, `(1+2) mi`.\n            let left = found.left.trimmingCharacters(in: .whitespaces)\n            guard let lastValue = left.lastIndex(where: { $0.isNumber || $0 == \")\" || $0 == \".\" }) else { return nil }\n            let amountText = String(left[...lastValue])\n            let unitText = left[left.index(after: lastValue)...].trimmingCharacters(in: .whitespaces)\n            guard let from = units[unitText], from.category == to.category else { return nil }\n            var parser = Parser(amountText)\n            guard let amount = parser.parse(), amount.isFinite else {\n                return compound(left, to: to)\n            }\n            let value: Double\n            if from.category == \"temperature\" {\n                value = celsius(amount, from: from.symbol).map { fromCelsius($0, to: to.symbol) } ?? .nan\n            } else {\n                value = amount * from.factor / to.factor\n            }\n            guard value.isFinite else { return nil }\n            let result = format(value)\n            return Answer(expression: \"\\(format(amount)) \\(from.symbol) → \\(to.symbol)\", result: result, display: \"\\(result) \\(to.symbol)\")\n        }\n\n        /// A mixed amount such as `5 ft 10 in` or `1h 30min`: each part a\n        /// number and a unit of the target's kind (not temperatures).\n        static func compound(_ left: String, to: Unit) -> Answer? {\n            guard to.category != \"temperature\" else { return nil }\n            var rest = Substring(left)\n            var total = 0.0\n            var parts: [String] = []\n            while !rest.isEmpty {\n                rest = rest.drop { $0 == \" \" }\n                let number = rest.prefix { $0.isNumber || $0 == \".\" }\n                guard !number.isEmpty, let value = Double(number) else { return nil }\n                rest = rest.dropFirst(number.count).drop { $0 == \" \" }\n                let name = rest.prefix { !$0.isNumber && $0 != \" \" }\n                guard let unit = units[String(name)], unit.category == to.category else { return nil }\n                rest = rest.dropFirst(name.count)\n                total += value * unit.factor\n                parts.append(format(value) + \" \" + unit.symbol)\n            }\n            guard parts.count > 1 else { return nil }\n            let result = format(total / to.factor)\n            return Answer(expression: parts.joined(separator: \" \") + \" → \\(to.symbol)\", result: result, display: \"\\(result) \\(to.symbol)\")\n        }\n\n        private static func celsius(_ value: Double, from symbol: String) -> Double? {\n            switch symbol {\n            case \"°C\": return value\n            case \"°F\": return (value - 32) * 5 / 9\n            case \"K\": return value - 273.15\n            default: return nil\n            }\n        }\n\n        private static func fromCelsius(_ value: Double, to symbol: String) -> Double {\n            switch symbol {\n            case \"°F\": return value * 9 / 5 + 32\n            case \"K\": return value + 273.15\n            default: return value\n            }\n        }\n\n        // MARK: Currencies\n\n        struct Rates: Codable, Equatable, Sendable {\n            /// Units of each currency one euro buys, EUR itself included.\n            var perEuro: [String: Double]\n            /// The day the bank published them, like `2026-11-04`.\n            var date: String\n            var fetched: Date\n\n            /// Symbols and words for the codes people type most.\n            static let names: [String: String] = [\n                \"$\": \"USD\", \"dollar\": \"USD\", \"dollars\": \"USD\", \"usd\": \"USD\",\n                \"€\": \"EUR\", \"euro\": \"EUR\", \"euros\": \"EUR\",\n                \"£\": \"GBP\", \"pound\": \"GBP\", \"pounds\": \"GBP\", \"quid\": \"GBP\",\n                \"¥\": \"JPY\", \"yen\": \"JPY\",\n                \"₹\": \"INR\", \"rupee\": \"INR\", \"rupees\": \"INR\", \"rs\": \"INR\",\n                \"₩\": \"KRW\", \"won\": \"KRW\",\n                \"fr\": \"CHF\", \"franc\": \"CHF\", \"francs\": \"CHF\",\n                \"a$\": \"AUD\", \"c$\": \"CAD\", \"s$\": \"SGD\", \"hk$\": \"HKD\", \"r$\": \"BRL\",\n                \"yuan\": \"CNY\", \"rmb\": \"CNY\", \"kr\": \"SEK\", \"zł\": \"PLN\",\n            ]\n\n            /// The currencies the bank publishes, so a sum in one of them is known even without rates.\n            static let knownCodes: Set<String> = [\"AUD\", \"BGN\", \"BRL\", \"CAD\", \"CHF\", \"CNY\", \"CZK\", \"DKK\", \"EUR\", \"GBP\", \"HKD\", \"HUF\", \"IDR\", \"ILS\", \"INR\", \"ISK\", \"JPY\", \"KRW\", \"MXN\", \"MYR\", \"NOK\", \"NZD\", \"PHP\", \"PLN\", \"RON\", \"SEK\", \"SGD\", \"THB\", \"TRY\", \"USD\", \"ZAR\"]\n\n            /// Whether `text` converts between currencies, whether or not there are rates.\n            static func looksLikeMoney(_ text: String) -> Bool {\n                Rates(perEuro: Dictionary(uniqueKeysWithValues: knownCodes.map { ($0, 1.0) }), date: \"\", fetched: .distantPast).convert(text) != nil\n            }\n\n            func code(_ word: String) -> String? {\n                let word = word.trimmingCharacters(in: .whitespaces).lowercased()\n                if let code = Self.names[word], perEuro[code] != nil { return code }\n                let upper = word.uppercased()\n                return upper.count == 3 && perEuro[upper] != nil ? upper : nil\n            }\n\n            /// `<sum> <currency> in|to|as <currency>`, with the currency\n            /// before or after the amount (`$100`, `100 usd`).\n            func convert(_ text: String) -> Answer? {\n                let lower = text.lowercased()\n                for word in [\" in \", \" to \", \" as \"] {\n                    guard let range = lower.range(of: word, options: .backwards),\n                          let to = code(String(lower[range.upperBound...])) else { continue }\n                    let left = lower[..<range.lowerBound].trimmingCharacters(in: .whitespaces)\n                    guard let (amount, from) = amount(left), let fromRate = perEuro[from], let toRate = perEuro[to], fromRate > 0 else { continue }\n                    let value = amount / fromRate * toRate\n                    guard value.isFinite else { return nil }\n                    let result = Self.money(value)\n                    return Answer(expression: \"\\(Math.format(amount)) \\(from) → \\(to) · rates of \\(date)\", result: result, display: \"\\(result) \\(to)\")\n                }\n                return nil\n            }\n\n            /// The amount and its currency: `100 usd`, `100usd`, `$100`,\n            /// `€ 12.50`, `(2+3) gbp`.\n            private func amount(_ text: String) -> (Double, String)? {\n                if let first = text.firstIndex(where: { $0.isNumber || $0 == \"(\" || $0 == \".\" }), first != text.startIndex,\n                   let from = code(String(text[..<first])) {\n                    var parser = Parser(String(text[first...]))\n                    if let value = parser.parse(), value.isFinite { return (value, from) }\n                }\n                guard let last = text.lastIndex(where: { $0.isNumber || $0 == \")\" || $0 == \".\" }),\n                      let from = code(String(text[text.index(after: last)...])) else { return nil }\n                var parser = Parser(String(text[...last]))\n                guard let value = parser.parse(), value.isFinite else { return nil }\n                return (value, from)\n            }\n\n            /// Two decimals, or more for tiny amounts, with no exponent.\n            static func money(_ value: Double, decimalComma: Bool = Math.localeUsesDecimalComma) -> String {\n                let text = abs(value) >= 1 || value == 0 ? String(format: \"%.2f\", value) : Math.format(value, decimalComma: false)\n                return decimalComma && !text.contains(\"e\") ? text.replacingOccurrences(of: \".\", with: \",\") : text\n            }\n        }\n\n        // MARK: Time zones\n\n        enum Times {\n            /// Cities, countries and abbreviations people type, beyond the\n            /// city part of every time zone identifier (`tokyo`, `new york`).\n            static let aliases: [String: String] = [\n                \"nyc\": \"America/New_York\", \"ny\": \"America/New_York\", \"boston\": \"America/New_York\", \"miami\": \"America/New_York\", \"washington\": \"America/New_York\",\n                \"sf\": \"America/Los_Angeles\", \"san francisco\": \"America/Los_Angeles\", \"la\": \"America/Los_Angeles\", \"seattle\": \"America/Los_Angeles\", \"california\": \"America/Los_Angeles\",\n                \"austin\": \"America/Chicago\", \"dallas\": \"America/Chicago\", \"houston\": \"America/Chicago\",\n                \"uk\": \"Europe/London\", \"england\": \"Europe/London\", \"edinburgh\": \"Europe/London\",\n                \"india\": \"Asia/Kolkata\", \"bangalore\": \"Asia/Kolkata\", \"bengaluru\": \"Asia/Kolkata\", \"delhi\": \"Asia/Kolkata\", \"mumbai\": \"Asia/Kolkata\", \"chennai\": \"Asia/Kolkata\", \"hyderabad\": \"Asia/Kolkata\",\n                \"japan\": \"Asia/Tokyo\", \"china\": \"Asia/Shanghai\", \"beijing\": \"Asia/Shanghai\", \"germany\": \"Europe/Berlin\", \"munich\": \"Europe/Berlin\",\n                \"france\": \"Europe/Paris\", \"spain\": \"Europe/Madrid\", \"barcelona\": \"Europe/Madrid\", \"italy\": \"Europe/Rome\", \"milan\": \"Europe/Rome\",\n                \"netherlands\": \"Europe/Amsterdam\", \"australia\": \"Australia/Sydney\", \"melbourne\": \"Australia/Melbourne\", \"dubai\": \"Asia/Dubai\", \"uae\": \"Asia/Dubai\",\n                \"korea\": \"Asia/Seoul\", \"taiwan\": \"Asia/Taipei\", \"brazil\": \"America/Sao_Paulo\", \"canada\": \"America/Toronto\", \"vancouver\": \"America/Vancouver\",\n                \"utc\": \"UTC\", \"gmt\": \"GMT\", \"pst\": \"America/Los_Angeles\", \"pdt\": \"America/Los_Angeles\", \"pt\": \"America/Los_Angeles\",\n                \"est\": \"America/New_York\", \"edt\": \"America/New_York\", \"et\": \"America/New_York\", \"cst\": \"America/Chicago\", \"cdt\": \"America/Chicago\",\n                \"mst\": \"America/Denver\", \"ist\": \"Asia/Kolkata\", \"cet\": \"Europe/Paris\", \"cest\": \"Europe/Paris\", \"bst\": \"Europe/London\", \"jst\": \"Asia/Tokyo\", \"sgt\": \"Asia/Singapore\",\n                \"aest\": \"Australia/Sydney\",\n            ]\n\n            static let cities: [String: String] = {\n                var table: [String: String] = [:]\n                for identifier in TimeZone.knownTimeZoneIdentifiers {\n                    guard let city = identifier.split(separator: \"/\").last else { continue }\n                    table[city.replacingOccurrences(of: \"_\", with: \" \").lowercased()] = identifier\n                }\n                return table.merging(aliases) { _, alias in alias }\n            }()\n\n            static func zone(_ name: String) -> (TimeZone, String)? {\n                // Accents fold for the lookup only, so \"são paulo\" finds São Paulo and is shown as typed.\n                let typed = name.trimmingCharacters(in: .whitespaces).lowercased()\n                let key = typed.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil)\n                guard !key.isEmpty, let identifier = cities[key], let zone = TimeZone(identifier: identifier) else { return nil }\n                let shown = typed.count <= 4 ? typed.uppercased() : typed.split(separator: \" \").map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined(separator: \" \")\n                return (zone, shown)\n            }\n\n            /// `now in tokyo`, `time in london`, `5pm in tokyo`,\n            /// `9:30 am nyc to london`, `17:00 berlin in sf`.\n            static func answer(_ text: String, now: Date, here: TimeZone) -> Answer? {\n                let lower = text.lowercased()\n                guard let range = lower.range(of: \" in \", options: .backwards) ?? lower.range(of: \" to \", options: .backwards),\n                      let (target, targetName) = zone(String(lower[range.upperBound...])) else { return nil }\n                let left = lower[..<range.lowerBound].trimmingCharacters(in: .whitespaces)\n                let when: Date\n                var source = here\n                var sourceName = \"here\"\n                if left == \"now\" || left == \"time\" || left == \"time now\" {\n                    when = now\n                } else {\n                    guard let (clock, rest) = clockTime(left) else { return nil }\n                    if !rest.isEmpty {\n                        guard let (zone, name) = zone(rest) else { return nil }\n                        source = zone\n                        sourceName = name\n                    }\n                    var calendar = Calendar(identifier: .gregorian)\n                    calendar.timeZone = source\n                    var day = calendar.dateComponents([.year, .month, .day], from: now)\n                    day.hour = clock.hour\n                    day.minute = clock.minute\n                    guard let date = calendar.date(from: day) else { return nil }\n                    when = date\n                }\n                let result = Self.clock(when, in: target)\n                var expression = \"\\(Self.clock(when, in: source)) \\(sourceName) → \\(targetName)\"\n                let dayShift = Self.dayOffset(when, from: source, to: target)\n                if dayShift != 0 { expression += dayShift > 0 ? \" (next day)\" : \" (previous day)\" }\n                return Answer(expression: expression, result: result, display: \"\\(result) in \\(targetName)\")\n            }\n\n            /// Compiled once, not on every keystroke.\n            private static let clockPattern = try? NSRegularExpression(pattern: #\"^(\\d{1,2})(?::(\\d{2}))?\\s*(am|pm|a\\.m\\.|p\\.m\\.)?(?=\\s|$)\"#)\n\n            /// A clock time at the start of the text, and what follows it:\n            /// `5pm`, `5 pm`, `5:30pm`, `17:00`, `noon`, `midnight`.\n            static func clockTime(_ text: String) -> ((hour: Int, minute: Int), String)? {\n                for (word, hour) in [(\"noon\", 12), (\"midnight\", 0)] where text.hasPrefix(word) {\n                    return ((hour, 0), String(text.dropFirst(word.count)).trimmingCharacters(in: .whitespaces))\n                }\n                guard let regex = clockPattern,\n                      let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),\n                      let hourRange = Range(match.range(at: 1), in: text), var hour = Int(text[hourRange]) else { return nil }\n                let minute = Range(match.range(at: 2), in: text).flatMap { Int(text[$0]) }\n                let meridiem = Range(match.range(at: 3), in: text).map { String(text[$0]) }\n                // A bare number is a sum, not a time: `5 in tokyo` is nothing.\n                guard minute != nil || meridiem != nil else { return nil }\n                if let meridiem {\n                    guard (1...12).contains(hour) else { return nil }\n                    if meridiem.hasPrefix(\"p\"), hour != 12 { hour += 12 }\n                    if meridiem.hasPrefix(\"a\"), hour == 12 { hour = 0 }\n                }\n                guard (0...23).contains(hour), (0...59).contains(minute ?? 0),\n                      let whole = Range(match.range, in: text) else { return nil }\n                return ((hour, minute ?? 0), text[whole.upperBound...].trimmingCharacters(in: .whitespaces))\n            }\n\n            private static let clockFormatter: DateFormatter = {\n                let formatter = DateFormatter()\n                formatter.locale = Locale(identifier: \"en_US_POSIX\")\n                formatter.dateFormat = \"h:mm a\"\n                return formatter\n            }()\n\n            static func clock(_ date: Date, in zone: TimeZone) -> String {\n                clockFormatter.timeZone = zone\n                return clockFormatter.string(from: date)\n            }\n\n            static func dayOffset(_ date: Date, from source: TimeZone, to target: TimeZone) -> Int {\n                var calendar = Calendar(identifier: .gregorian)\n                calendar.timeZone = source\n                let a = calendar.dateComponents([.year, .month, .day], from: date)\n                calendar.timeZone = target\n                let b = calendar.dateComponents([.year, .month, .day], from: date)\n                guard let first = Calendar(identifier: .gregorian).date(from: a), let second = Calendar(identifier: .gregorian).date(from: b) else { return 0 }\n                return Calendar(identifier: .gregorian).dateComponents([.day], from: first, to: second).day ?? 0\n            }\n        }\n    }\n}\n",
  "sourceDigest" : "3451bebe7b2dd55a1c536f052e95cdc89edb916f488d62967f510040a36b72ef"
}