diff --git a/README.md b/README.md index ab14283..8ccb3aa 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ Convert length, mass, temperature, volume, duration, speed, area, and data with Currency and crypto conversions require internet access and use [currency-api](https://github.com/fawazahmed0/exchange-api). These are **daily rates, not live market prices**; the snapshot date is shown with the answer. Treat them as estimates, not trading or payment quotes. Fetch failures show an error rather than silently using old rates; change the query to retry. -`yen` is accepted as an alias for `JPY` in either conversion direction, case-insensitively. +Currency codes and common English names work in either direction, case-insensitively: `10 euros to Danish kroner`, `1000 Japanese yen to DKK`, or `2 US dollars to British pounds`. Results use currency codes. Supported names include yen, euros, Danish/Norwegian kroner, Swedish kronor, US/Canadian/Australian/New Zealand dollars, British pounds, Swiss francs, Chinese yuan, Indian rupees, South Korean won, bitcoin, and ether/ethereum. Singular and plural forms are accepted. Bare `dollars` and `kroner` are ambiguous and require a qualifier or code; bare `pounds` remains a mass unit. Rates are shared across queries and refreshed on the next currency query after the local day changes. While refreshing, a cached answer remains copyable and is labeled stale. Leaving the query or dismissing the launcher does not cancel that shared refresh. diff --git a/Sources/ConjureKit/Calculator.swift b/Sources/ConjureKit/Calculator.swift index d0d4a2f..09d99a1 100644 --- a/Sources/ConjureKit/Calculator.swift +++ b/Sources/ConjureKit/Calculator.swift @@ -103,9 +103,28 @@ public enum Calculator { "wbtc", "weth", ] + private static let currencyNames: [String: String] = [ + "yen": "jpy", "japanese yen": "jpy", + "euro": "eur", "euros": "eur", + "danish krone": "dkk", "danish kroner": "dkk", + "swedish krona": "sek", "swedish kronor": "sek", + "norwegian krone": "nok", "norwegian kroner": "nok", + "us dollar": "usd", "us dollars": "usd", + "american dollar": "usd", "american dollars": "usd", + "canadian dollar": "cad", "canadian dollars": "cad", + "australian dollar": "aud", "australian dollars": "aud", + "new zealand dollar": "nzd", "new zealand dollars": "nzd", + "british pound": "gbp", "british pounds": "gbp", "pound sterling": "gbp", + "swiss franc": "chf", "swiss francs": "chf", + "chinese yuan": "cny", "yuan": "cny", + "indian rupee": "inr", "indian rupees": "inr", + "south korean won": "krw", + "bitcoin": "btc", "bitcoins": "btc", "ether": "eth", "ethereum": "eth", + ] + private static func currencyCode(_ text: String, rates: CurrencyRates?) -> String? { let code = text.lowercased() - if code == "yen" { return "jpy" } + if let code = currencyNames[code] { return code } guard (2...12).contains(code.utf8.count), code.utf8.allSatisfy({ (97...122).contains($0) || (48...57).contains($0) }), ConversionUnit.parse(text) == nil else { return nil } @@ -116,21 +135,25 @@ public enum Calculator { private static func currencyQuery(_ query: String, rates: CurrencyRates?) -> CurrencyQuery? { guard query.utf8.count <= 512 else { return nil } let words = query.split(whereSeparator: \.isWhitespace) - guard words.count >= 3, - ["to", "in", "as"].contains(words[words.count - 2].lowercased()), - let target = currencyCode(String(words[words.count - 1]), rates: rates) else { return nil } - let sourceText = words.dropLast(2).joined(separator: " ") - let characters = Array(sourceText) - guard characters.count > 1 else { return nil } - for length in stride(from: min(12, characters.count - 1), through: 2, by: -1) { - let suffix = String(characters.suffix(length)) - guard let source = currencyCode(suffix, rates: rates) else { continue } - let boundary = characters[characters.count - length - 1] - // Digit-bearing tickers need a space so 21inch cannot silently become 2 × 1INCH. - guard boundary.isWhitespace || (suffix.allSatisfy(\.isLetter) && !boundary.isLetter) else { continue } - let expression = String(characters.dropLast(length)).trimmingCharacters(in: .whitespaces) - guard let amount = parse(expression) else { continue } - return CurrencyQuery(expression: expression, source: source, target: target, amount: amount) + guard words.count >= 3 else { return nil } + for separator in 1..<(words.count - 1) + where ["to", "in", "as"].contains(words[separator].lowercased()) { + guard let target = currencyCode(words[(separator + 1)...].joined(separator: " "), rates: rates) else { + continue + } + let sourceText = words[.. 1 else { continue } + for length in stride(from: min(32, characters.count - 1), through: 2, by: -1) { + let suffix = String(characters.suffix(length)) + guard let source = currencyCode(suffix, rates: rates) else { continue } + let boundary = characters[characters.count - length - 1] + // Digit-bearing tickers need a space so 21inch cannot silently become 2 × 1INCH. + guard boundary.isWhitespace || (suffix.allSatisfy { $0.isLetter || $0.isWhitespace } && !boundary.isLetter) else { continue } + let expression = String(characters.dropLast(length)).trimmingCharacters(in: .whitespaces) + guard let amount = parse(expression) else { continue } + return CurrencyQuery(expression: expression, source: source, target: target, amount: amount) + } } return nil } diff --git a/TODO.md b/TODO.md index 5caca07..0ef71c5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,35 +1,5 @@ -- [x] conjure notes: - - [x] window should be resizable (and set size should be remembered) - - [x] make font for note/input text monospaced -- [x] bug: looks like dragging around the conjure windows can cause other conjure windows of difference window sizes to spawn offset the corner instead of from the center of the windows - - when moving a conjure window, the saved position should always be the center of the window, and windows should always be spawned at some position taking to mean their center. - - this should keep window placement invariant of corner window sizes -- [x] conjure notes: - - [x] add action to delete a note (currently selected note) - - [x] make images have rounded corners - - [x] bug: text flashes when inserting characters, which is visually disturbing. - - [x] fix heading editing using paragraph semantics: - - typing `#{1,6} ` at the visible paragraph start sets the absolute heading level, replacing any existing level; fenced code remains literal. - - Backspace at heading start deletes the preceding newline, not the heading prefix; at document start it does nothing. - - joining onto nonempty prose inherits the preceding paragraph's type: `Text\n## Heading` becomes `TextHeading`; removing an empty line above preserves the heading. - - keep native attributed-text editing, Undo, images, Markdown export, and RTFD autosaves; structured paragraph metadata is stored alongside RTFD content. - - [x] add whole-note Markdown export actions to the Notes Actions menu: - - Copy as Markdown: replace images with inline-code placeholders such as ``. - - Export as Markdown…: save a single `.md` file without images; with images, create a folder containing the Markdown and image files, using relative links. - - exports leave the original note and RTFD autosaves unchanged. - - [x] bug: pressing backspace at the start of a heading will move it back a line (as expected), but a subsequent return will cause the heading `#`s to appear on the first and the heading text to move down a line. - - [x] keep the caret on the heading's visible line after Backspace and Return, not on the preceding line. - - [x] improve: - - add cmd+shift+s for strikethrough and cmd+shift+u for underline - - code-block fences were made visible in source editing; superseded by the structured editing cutover below. - - give inline codeblocks (e.g. `some code snippet`) an inline container and an orange text color matching the color scheme of the conjure application - - [x] feature: vim motions - - opt-in Normal/Insert/Visual editing, counted motions, delete/change/yank, rich-text paste, and native Undo/Redo. - - [x] use Notion-style structured editing instead of hidden Markdown source: - - Markdown input shortcuts become attributed marks and paragraph types; native editing and Vim share visible offsets and Undo. - - preserve image bytes and versioned semantic metadata in RTFD packages; back up legacy packages before their first structured save. - - generate normalized Markdown for export; plain-text paste stays literal. - [ ] feature: configurable color scheme - general design should stay consistent - only colors should be configurable; not the way the colors are used/presented (e.g. frosted glass effect with color blobs behind shouldn't change) - - this should play nicely together with the theme control tool in `/Users/valdemar/Developer/Personal/Tools/tint`. \ No newline at end of file + - this should play nicely together with the theme control tool in `/Users/valdemar/Developer/Personal/Tools/tint`. + - maybe a configuration file (e.g. ~/.config/conjure/config.toml) is warranted \ No newline at end of file diff --git a/Tests/ConjureKitTests/CurrencyTests.swift b/Tests/ConjureKitTests/CurrencyTests.swift index c57e0e8..500cda0 100644 --- a/Tests/ConjureKitTests/CurrencyTests.swift +++ b/Tests/ConjureKitTests/CurrencyTests.swift @@ -26,6 +26,27 @@ func calculatorConvertsYenAsJPYInEitherDirection() { } } +@Test +func calculatorConvertsCurrencyNamesWithoutGuessingAmbiguousNames() { + let snapshot = CurrencyRates(date: "2026-01-02", rates: [ + "eur": 1, "dkk": 8, "usd": 2, "gbp": 0.5, "btc": 0.00002, + ]) + for (query, expected) in [ + ("10 euros to Danish kroner", "80 DKK"), + ("80 DANISH KRONER in euro", "10 EUR"), + ("2*5 US dollars as British pounds", "2.5 GBP"), + ("1 bitcoin to euros", "50000 EUR"), + ("10euros to DKK", "80 DKK"), + ] { + #expect(Calculator.isCurrencyQuery(query)) + #expect(Calculator.evaluate(query, currencyRates: snapshot)?.copyValue == expected) + } + for query in ["10 dollars to EUR", "10 kroner to EUR", "10 pounds to EUR", "10 EUR to dollars"] { + #expect(!Calculator.isCurrencyQuery(query, currencyRates: snapshot)) + } + #expect(Calculator.evaluate("2 pounds to oz", currencyRates: snapshot)?.copyValue == "32 oz") +} + @Test func calculatorConvertsFiatAndCryptoWithCaseInsensitiveSeparators() { let snapshot = CurrencyRates(date: "2026-01-02", rates: [