feat: add daily fiat and crypto currency conversion
This commit is contained in:
@@ -4,6 +4,12 @@ public struct CalculatorResult: Sendable {
|
||||
public let displayValue: String
|
||||
public let copyValue: String?
|
||||
public let detail: String
|
||||
|
||||
public init(displayValue: String, copyValue: String?, detail: String) {
|
||||
self.displayValue = displayValue
|
||||
self.copyValue = copyValue
|
||||
self.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
public enum Calculator {
|
||||
@@ -15,9 +21,34 @@ public enum Calculator {
|
||||
private static let maximumDepth = 16
|
||||
|
||||
public static func evaluate(_ query: String) -> CalculatorResult? {
|
||||
evaluate(query, currencyRates: nil)
|
||||
}
|
||||
|
||||
public static func isCurrencyQuery(_ query: String, currencyRates: CurrencyRates? = nil) -> Bool {
|
||||
currencyQuery(query, rates: currencyRates) != nil
|
||||
}
|
||||
|
||||
public static func evaluate(_ query: String, currencyRates: CurrencyRates?) -> CalculatorResult? {
|
||||
guard query.utf8.count <= 512 else { return nil }
|
||||
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty else { return nil }
|
||||
if let currency = currencyQuery(query, rates: currencyRates) {
|
||||
let detail = "\(currency.expression) \(currency.source.uppercased()) → \(currency.target.uppercased())"
|
||||
if let error = currency.amount.error { return failure(error, detail: detail) }
|
||||
guard let currencyRates else { return failure("Loading currency rates…", detail: detail) }
|
||||
let datedDetail = "\(detail) · Daily snapshot \(currencyRates.date)"
|
||||
guard let source = currencyRates.rates[currency.source],
|
||||
let target = currencyRates.rates[currency.target] else {
|
||||
return failure("Unsupported currency", detail: datedDetail)
|
||||
}
|
||||
guard source.isFinite, source > 0, target.isFinite, target > 0 else {
|
||||
return failure("Invalid currency rates", detail: datedDetail)
|
||||
}
|
||||
let value = currency.amount.value * target / source
|
||||
guard value.isFinite else { return failure("Result is not finite", detail: datedDetail) }
|
||||
let text = "\(format(value)) \(currency.target.uppercased())"
|
||||
return CalculatorResult(displayValue: text, copyValue: text, detail: datedDetail)
|
||||
}
|
||||
let words = query.split(whereSeparator: \.isWhitespace).map(String.init)
|
||||
if words.count >= 3 {
|
||||
for separator in 1..<(words.count - 1)
|
||||
@@ -57,6 +88,52 @@ public enum Calculator {
|
||||
return CalculatorResult(displayValue: text, copyValue: text, detail: query)
|
||||
}
|
||||
|
||||
private struct CurrencyQuery {
|
||||
let expression: String
|
||||
let source: String
|
||||
let target: String
|
||||
let amount: Parser
|
||||
}
|
||||
|
||||
private static let cryptoCodes: Set<String> = [
|
||||
"1inch", "aave", "atom", "avax", "bake", "btcb", "busd", "cake", "celo", "comp",
|
||||
"dash", "doge", "dydx", "egld", "eurc", "flow", "frax", "gala", "gusd", "hbar",
|
||||
"kava", "klay", "link", "luna", "lunc", "mana", "mina", "near", "nexo", "ordi",
|
||||
"paxg", "pepe", "qtum", "rune", "sand", "shib", "theta", "tusd", "usdc", "usdt",
|
||||
"wbtc", "weth",
|
||||
]
|
||||
|
||||
private static func currencyCode(_ text: String, rates: CurrencyRates?) -> String? {
|
||||
let code = text.lowercased()
|
||||
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 }
|
||||
let conventional = code.utf8.count == 3 && code.utf8.allSatisfy { (97...122).contains($0) }
|
||||
return conventional || cryptoCodes.contains(code) || rates?.rates[code] != nil ? code : nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func failure(_ error: String, detail: String) -> CalculatorResult {
|
||||
CalculatorResult(displayValue: error, copyValue: nil, detail: detail)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user