feat: accept unambiguous currency names

This commit is contained in:
2026-10-22 14:10:33 +02:00
parent 0a85d21801
commit 1ec4c0d582
4 changed files with 63 additions and 49 deletions
+1 -1
View File
@@ -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. 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. 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.
+39 -16
View File
@@ -103,9 +103,28 @@ public enum Calculator {
"wbtc", "weth", "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? { private static func currencyCode(_ text: String, rates: CurrencyRates?) -> String? {
let code = text.lowercased() let code = text.lowercased()
if code == "yen" { return "jpy" } if let code = currencyNames[code] { return code }
guard (2...12).contains(code.utf8.count), guard (2...12).contains(code.utf8.count),
code.utf8.allSatisfy({ (97...122).contains($0) || (48...57).contains($0) }), code.utf8.allSatisfy({ (97...122).contains($0) || (48...57).contains($0) }),
ConversionUnit.parse(text) == nil else { return nil } ConversionUnit.parse(text) == nil else { return nil }
@@ -116,21 +135,25 @@ public enum Calculator {
private static func currencyQuery(_ query: String, rates: CurrencyRates?) -> CurrencyQuery? { private static func currencyQuery(_ query: String, rates: CurrencyRates?) -> CurrencyQuery? {
guard query.utf8.count <= 512 else { return nil } guard query.utf8.count <= 512 else { return nil }
let words = query.split(whereSeparator: \.isWhitespace) let words = query.split(whereSeparator: \.isWhitespace)
guard words.count >= 3, guard words.count >= 3 else { return nil }
["to", "in", "as"].contains(words[words.count - 2].lowercased()), for separator in 1..<(words.count - 1)
let target = currencyCode(String(words[words.count - 1]), rates: rates) else { return nil } where ["to", "in", "as"].contains(words[separator].lowercased()) {
let sourceText = words.dropLast(2).joined(separator: " ") guard let target = currencyCode(words[(separator + 1)...].joined(separator: " "), rates: rates) else {
let characters = Array(sourceText) continue
guard characters.count > 1 else { return nil } }
for length in stride(from: min(12, characters.count - 1), through: 2, by: -1) { let sourceText = words[..<separator].joined(separator: " ")
let suffix = String(characters.suffix(length)) let characters = Array(sourceText)
guard let source = currencyCode(suffix, rates: rates) else { continue } guard characters.count > 1 else { continue }
let boundary = characters[characters.count - length - 1] for length in stride(from: min(32, characters.count - 1), through: 2, by: -1) {
// Digit-bearing tickers need a space so 21inch cannot silently become 2 × 1INCH. let suffix = String(characters.suffix(length))
guard boundary.isWhitespace || (suffix.allSatisfy(\.isLetter) && !boundary.isLetter) else { continue } guard let source = currencyCode(suffix, rates: rates) else { continue }
let expression = String(characters.dropLast(length)).trimmingCharacters(in: .whitespaces) let boundary = characters[characters.count - length - 1]
guard let amount = parse(expression) else { continue } // Digit-bearing tickers need a space so 21inch cannot silently become 2 × 1INCH.
return CurrencyQuery(expression: expression, source: source, target: target, amount: amount) 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 return nil
} }
+1 -31
View File
@@ -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 `<photo.png>`.
- 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 - [ ] feature: configurable color scheme
- general design should stay consistent - 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) - 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`. - 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
+21
View File
@@ -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 @Test
func calculatorConvertsFiatAndCryptoWithCaseInsensitiveSeparators() { func calculatorConvertsFiatAndCryptoWithCaseInsensitiveSeparators() {
let snapshot = CurrencyRates(date: "2026-01-02", rates: [ let snapshot = CurrencyRates(date: "2026-01-02", rates: [