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.
`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.
+31 -8
View File
@@ -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,22 +135,26 @@ 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: " ")
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[..<separator].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) {
guard characters.count > 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(\.isLetter) && !boundary.isLetter) else { continue }
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
}
+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
- 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`.
- 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
func calculatorConvertsFiatAndCryptoWithCaseInsensitiveSeparators() {
let snapshot = CurrencyRates(date: "2026-01-02", rates: [