Skip to content

Commit

Permalink
Enhance currency module in calculator plugin with exchange rate
Browse files Browse the repository at this point in the history
  • Loading branch information
qianlifeng committed Dec 27, 2024
1 parent b68951c commit 3b6fd75
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 31 deletions.
1 change: 1 addition & 0 deletions .cursorrules
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ wox.ui.flutter flutter实现的Wox前端,通过websocket与wox.cor
# 其他要求
* 你在回答问题的时候请使用中文回答我, 但是生成的代码中的注释必须使用英文
* 当你需要查看日志的时候,请使用 `tail -n 100 ~/.wox/log/log` 查看最新日志, 帮助你排查问题
* 编写单元测试的时候, 注意初始化日志,否则日志可能不会打印到正确的位置,参考:wox.core/main_test.go
59 changes: 44 additions & 15 deletions wox.core/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"strings"
"testing"
"time"
"wox/i18n"
Expand All @@ -19,8 +20,29 @@ func TestCalculatorCurrency(t *testing.T) {
{
name: "USD to EUR",
query: "100 USD in EUR",
expectedTitle: "€85.3",
expectedTitle: "€",
expectedAction: "Copy result",
titleCheck: func(title string) bool {
return len(title) > 1 && strings.HasPrefix(title, "€") && title[len("€")] >= '0' && title[len("€")] <= '9'
},
},
{
name: "EUR to USD",
query: "50 EUR = ? USD",
expectedTitle: "$",
expectedAction: "Copy result",
titleCheck: func(title string) bool {
return len(title) > 1 && strings.HasPrefix(title, "$") && title[1] >= '0' && title[1] <= '9'
},
},
{
name: "USD to CNY",
query: "100 USD to CNY",
expectedTitle: "¥",
expectedAction: "Copy result",
titleCheck: func(title string) bool {
return len(title) > 1 && strings.HasPrefix(title, "¥") && title[len("¥")] >= '0' && title[len("¥")] <= '9'
},
},
}
runQueryTests(t, tests)
Expand Down Expand Up @@ -294,6 +316,7 @@ type queryTest struct {
query string
expectedTitle string
expectedAction string
titleCheck func(string) bool
}

func runQueryTests(t *testing.T, tests []queryTest) {
Expand Down Expand Up @@ -351,7 +374,24 @@ func runQueryTests(t *testing.T, tests []queryTest) {
// Find matching result
found := false
for _, result := range allResults {
if result.Title == tt.expectedTitle {
if tt.titleCheck != nil {
if tt.titleCheck(result.Title) {
found = true
// Verify action
actionFound := false
for _, action := range result.Actions {
if action.Name == tt.expectedAction {
actionFound = true
break
}
}
if !actionFound {
t.Errorf("Expected action %q not found in result actions for title %q", tt.expectedAction, result.Title)
success = false
}
break
}
} else if result.Title == tt.expectedTitle {
found = true
// Verify action
actionFound := false
Expand All @@ -362,26 +402,15 @@ func runQueryTests(t *testing.T, tests []queryTest) {
}
}
if !actionFound {
t.Errorf("Expected action %q not found in result actions:", tt.expectedAction)
t.Errorf("Got results for query %q:", tt.query)
for i, result := range allResults {
t.Errorf("Result %d:", i+1)
t.Errorf(" Title: %s", result.Title)
t.Errorf(" SubTitle: %s", result.SubTitle)
t.Errorf(" Actions:")
for j, action := range result.Actions {
t.Errorf(" %d. %s", j+1, action.Name)
}
}
t.Errorf("Expected action %q not found in result actions for title %q", tt.expectedAction, result.Title)
success = false
}
break
}
}

if !found {
t.Errorf("Expected title %q not found in results:", tt.expectedTitle)
t.Errorf("Got results for query %q:", tt.query)
t.Errorf("Expected title format not found in results. Got results for query %q:", tt.query)
for i, result := range allResults {
t.Errorf("Result %d:", i+1)
t.Errorf(" Title: %s", result.Title)
Expand Down
5 changes: 4 additions & 1 deletion wox.core/plugin/system/calculator/calculator.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ func (c *Calculator) Init(ctx context.Context, initParams plugin.InitParams) {
registry := core.NewModuleRegistry()
registry.Register(modules.NewMathModule(ctx, c.api))
registry.Register(modules.NewTimeModule(ctx, c.api))
registry.Register(modules.NewCurrencyModule(ctx, c.api))

currencyModule := modules.NewCurrencyModule(ctx, c.api)
currencyModule.StartExchangeRateSyncSchedule(ctx)
registry.Register(currencyModule)

tokenizer := core.NewTokenizer(registry.GetTokenPatterns())
c.registry = registry
Expand Down
153 changes: 138 additions & 15 deletions wox.core/plugin/system/calculator/modules/currency.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package modules

import (
"bytes"
"context"
"fmt"
"strconv"
"strings"
"time"
"wox/plugin"
"wox/plugin/system/calculator/core"
"wox/util"

"github.com/PuerkitoBio/goquery"
"github.com/shopspring/decimal"
)

Expand All @@ -16,24 +21,12 @@ type CurrencyModule struct {
}

func NewCurrencyModule(ctx context.Context, api plugin.API) *CurrencyModule {
// Initialize with some common currencies
// In real world application, these rates should be fetched from an API
rates := map[string]float64{
"USD": 1.0,
"EUR": 0.853,
"GBP": 0.79,
"JPY": 142.35,
"CNY": 7.14,
"AUD": 1.47,
"CAD": 1.32,
}

m := &CurrencyModule{
rates: rates,
rates: make(map[string]float64),
}

const (
currencyPattern = `(usd|eur|gbp|jpy|cny|aud|cad)`
currencyPattern = `(?i)(usd|eur|gbp|jpy|cny|aud|cad)`
numberPattern = `([0-9]+(?:\.[0-9]+)?)`
)

Expand All @@ -59,10 +52,41 @@ func NewCurrencyModule(ctx context.Context, api plugin.API) *CurrencyModule {
},
}

// Add debug logging for pattern matching
for _, h := range handlers {
pattern := h.Pattern
originalHandler := h.Handler
h.Handler = func(ctx context.Context, matches []string) (*core.Result, error) {
util.GetLogger().Debug(ctx, fmt.Sprintf("Currency pattern matched: %s, matches: %v", pattern, matches))
return originalHandler(ctx, matches)
}
}

m.regexBaseModule = NewRegexBaseModule(api, "currency", handlers)
return m
}

func (m *CurrencyModule) StartExchangeRateSyncSchedule(ctx context.Context) {
util.Go(ctx, "currency_exchange_rate_sync", func() {

rates, err := m.parseExchangeRateFromHKAB(ctx)
if err == nil {
m.rates = rates
} else {
util.GetLogger().Error(ctx, fmt.Sprintf("Failed to fetch initial exchange rates from HKAB: %s", err.Error()))
}

for range time.NewTicker(1 * time.Hour).C {
rates, err := m.parseExchangeRateFromHKAB(ctx)
if err == nil {
m.rates = rates
} else {
util.GetLogger().Error(ctx, fmt.Sprintf("Failed to fetch exchange rates from HKAB: %s", err.Error()))
}
}
})
}

func (m *CurrencyModule) Convert(ctx context.Context, value *core.Result, toUnit string) (*core.Result, error) {
fromCurrency := value.Unit
toCurrency := strings.ToUpper(toUnit)
Expand Down Expand Up @@ -151,5 +175,104 @@ func (m *CurrencyModule) formatWithCurrencySymbol(amount decimal.Decimal, curren
}

// Format with exactly 2 decimal places
return fmt.Sprintf("%s%s", symbol, amount)
return fmt.Sprintf("%s%s", symbol, amount.Round(2))
}

func (m *CurrencyModule) parseExchangeRateFromHKAB(ctx context.Context) (rates map[string]float64, err error) {
util.GetLogger().Info(ctx, "Starting to parse exchange rates from HKAB")

// Initialize maps
rates = make(map[string]float64)
rawRates := make(map[string]float64)

body, err := util.HttpGet(ctx, "https://www.hkab.org.hk/en/rates/exchange-rates")
if err != nil {
util.GetLogger().Error(ctx, fmt.Sprintf("Failed to get exchange rates from HKAB: %s", err.Error()))
return nil, err
}

doc, err := goquery.NewDocumentFromReader(bytes.NewReader(body))
if err != nil {
util.GetLogger().Error(ctx, fmt.Sprintf("Failed to parse HTML: %s", err.Error()))
return nil, err
}

// Find the first general_table_container
firstTable := doc.Find(".general_table_container").First()
if firstTable.Length() == 0 {
util.GetLogger().Error(ctx, "Failed to find exchange rate table")
return nil, fmt.Errorf("exchange rate table not found")
}

// First pass: collect all raw rates from the first table only
firstTable.Find(".general_table_row.exchange_rate").Each(func(i int, s *goquery.Selection) {
// Get currency code
currencyCode := strings.TrimSpace(s.Find(".exchange_rate_1 div:last-child").Text())
if currencyCode == "" {
return
}

// Get selling rate and buying rate
var sellingRateStr, buyingRateStr string
s.Find("div").Each(func(j int, sel *goquery.Selection) {
text := strings.TrimSpace(sel.Text())
if text == "Selling:" {
sellingRateStr = strings.TrimSpace(sel.Parent().Find("div:last-child").Text())
} else if text == "Buying TT:" {
buyingRateStr = strings.TrimSpace(sel.Parent().Find("div:last-child").Text())
}
})

if sellingRateStr == "" || buyingRateStr == "" {
return
}

// Clean up rate strings and parse
sellingRate, err := strconv.ParseFloat(strings.ReplaceAll(sellingRateStr, ",", ""), 64)
if err != nil {
util.GetLogger().Error(ctx, fmt.Sprintf("Failed to parse selling rate for %s: %v", currencyCode, err))
return
}

buyingRate, err := strconv.ParseFloat(strings.ReplaceAll(buyingRateStr, ",", ""), 64)
if err != nil {
util.GetLogger().Error(ctx, fmt.Sprintf("Failed to parse buying rate for %s: %v", currencyCode, err))
return
}

if sellingRate <= 0 || buyingRate <= 0 {
return
}

// Calculate middle rate
middleRate := (sellingRate + buyingRate) / 2
rawRates[strings.ToUpper(currencyCode)] = middleRate
})

// Find USD rate first
usdRate, exists := rawRates["USD"]
if !exists {
util.GetLogger().Error(ctx, "USD rate not found")
return nil, fmt.Errorf("USD rate not found")
}

// Set base USD rate
rates["USD"] = 1.0

// Second pass: calculate all rates relative to USD
for currency, rate := range rawRates {
// Convert rates relative to USD
usdToHkd := usdRate / 100.0
currencyToHkd := rate / 100.0
currencyPerUsd := usdToHkd / currencyToHkd
rates[currency] = currencyPerUsd
}

if len(rates) < 2 {
util.GetLogger().Error(ctx, "Failed to parse enough exchange rates")
return nil, fmt.Errorf("failed to parse exchange rates")
}

util.GetLogger().Info(ctx, fmt.Sprintf("Successfully parsed %d exchange rates", len(rates)))
return rates, nil
}
41 changes: 41 additions & 0 deletions wox.core/plugin/system/calculator/modules/currency_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package modules

import (
"testing"
"wox/util"
)

func TestParseExchangeRateFromHKAB(t *testing.T) {
ctx := util.NewTraceContext()
err := util.GetLocation().Init()
if err != nil {
panic(err)
}

m := &CurrencyModule{
rates: make(map[string]float64),
}

rates, err := m.parseExchangeRateFromHKAB(ctx)
if err != nil {
t.Errorf("TestParseExchangeRateFromHKAB failed: %v", err)
return
}

// Check if we have rates
if len(rates) < 2 {
t.Errorf("Expected at least 2 rates, got %d", len(rates))
return
}

// Check USD rate
if rates["USD"] != 1.0 {
t.Errorf("Expected USD rate to be 1.0, got %f", rates["USD"])
}

// Check CNY rate is in reasonable range (6-8)
cnyRate := rates["CNY"]
if cnyRate < 6.0 || cnyRate > 8.0 {
t.Errorf("CNY rate %f is outside expected range [6.0, 8.0]", cnyRate)
}
}

0 comments on commit 3b6fd75

Please sign in to comment.