From b68951cb894be3d082ca13c4b8176501b0b94994 Mon Sep 17 00:00:00 2001 From: qianlifeng Date: Fri, 27 Dec 2024 00:05:13 +0800 Subject: [PATCH] Enhance calculator plugin functionality and testing --- .cursorrules | 3 +- Makefile | 3 + wox.core/main_test.go | 299 +++++++--- .../plugin/system/calculator/calculator.go | 155 +++--- .../plugin/system/calculator/core/parser.go | 89 ++- .../plugin/system/calculator/core/registry.go | 76 +++ .../system/calculator/core/tokenizer.go | 147 +++-- .../plugin/system/calculator/core/types.go | 153 ------ .../calculator/modules/base_regex_module.go | 117 ++++ .../system/calculator/modules/currency.go | 155 ++++++ .../plugin/system/calculator/modules/math.go | 51 +- .../plugin/system/calculator/modules/time.go | 509 +++++++----------- 12 files changed, 1057 insertions(+), 700 deletions(-) create mode 100644 wox.core/plugin/system/calculator/core/registry.go delete mode 100644 wox.core/plugin/system/calculator/core/types.go create mode 100644 wox.core/plugin/system/calculator/modules/base_regex_module.go create mode 100644 wox.core/plugin/system/calculator/modules/currency.go diff --git a/.cursorrules b/.cursorrules index d6ac493be..6a5643439 100644 --- a/.cursorrules +++ b/.cursorrules @@ -21,4 +21,5 @@ wox.ui.flutter flutter实现的Wox前端,通过websocket与wox.cor * dataclass进行数据模型定义, 请参考wox.plugin.python/src/wox_plugin/models/query.py # 其他要求 -你在回答问题的时候请使用中文回答我, 但是生成的代码中的注释必须使用英文 \ No newline at end of file +* 你在回答问题的时候请使用中文回答我, 但是生成的代码中的注释必须使用英文 +* 当你需要查看日志的时候,请使用 `tail -n 100 ~/.wox/log/log` 查看最新日志, 帮助你排查问题 diff --git a/Makefile b/Makefile index 9ac5ebb12..ee7129c23 100644 --- a/Makefile +++ b/Makefile @@ -73,6 +73,9 @@ dev: _check_deps test: dev cd wox.core && go test ./... +only_test: + cd wox.core && go test ./... + publish: clean dev $(MAKE) -C wox.core build diff --git a/wox.core/main_test.go b/wox.core/main_test.go index 3ca364018..c059d55b7 100644 --- a/wox.core/main_test.go +++ b/wox.core/main_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "testing" "time" "wox/i18n" @@ -13,136 +14,292 @@ import ( "wox/util" ) -func init() { - ctx := context.Background() - - // Initialize location - err := util.GetLocation().Init() - if err != nil { - panic(err) - } - - // Extract resources - err = resource.Extract(ctx) - if err != nil { - panic(err) - } - - // Initialize settings - err = setting.GetSettingManager().Init(ctx) - if err != nil { - panic(err) +func TestCalculatorCurrency(t *testing.T) { + tests := []queryTest{ + { + name: "USD to EUR", + query: "100 USD in EUR", + expectedTitle: "€85.3", + expectedAction: "Copy result", + }, } + runQueryTests(t, tests) +} - // Initialize i18n - woxSetting := setting.GetSettingManager().GetWoxSetting(ctx) - err = i18n.GetI18nManager().UpdateLang(ctx, woxSetting.LangCode) - if err != nil { - panic(err) +func TestCalculatorBasic(t *testing.T) { + tests := []queryTest{ + { + name: "Simple addition", + query: "1+2", + expectedTitle: "3", + expectedAction: "Copy result", + }, + { + name: "Complex expression", + query: "1+2*3", + expectedTitle: "7", + expectedAction: "Copy result", + }, + { + name: "Parentheses", + query: "(1+2)*3", + expectedTitle: "9", + expectedAction: "Copy result", + }, } + runQueryTests(t, tests) +} - // Initialize UI - err = ui.GetUIManager().Start(ctx) - if err != nil { - panic(err) +func TestCalculatorTrigonometric(t *testing.T) { + tests := []queryTest{ + { + name: "Sin with addition", + query: "sin(8) + 1", + expectedTitle: "1.9893582466233817", + expectedAction: "Copy result", + }, + { + name: "Sin with pi", + query: "sin(pi/4)", + expectedTitle: "0.7071067811865475", + expectedAction: "Copy result", + }, + { + name: "Complex expression with pi", + query: "2*pi + sin(pi/2)", + expectedTitle: "7.283185307179586", + expectedAction: "Copy result", + }, } - - // Initialize plugin system with UI - plugin.GetPluginManager().Start(ctx, ui.GetUIManager().GetUI(ctx)) - - // Initialize selection - util.InitSelection() + runQueryTests(t, tests) } -func TestQuery(t *testing.T) { - ctx := util.NewTraceContext() - - tests := []struct { - name string - query string - expectedTitle string - expectedAction string - }{ - // Calculator plugin tests +func TestCalculatorAdvanced(t *testing.T) { + tests := []queryTest{ { - name: "Calculator plugin - simple addition", - query: "1+2", - expectedTitle: "1+2 = 3", + name: "Exponential", + query: "exp(2)", + expectedTitle: "7.38905609893065", expectedAction: "Copy result", }, { - name: "Calculator plugin - complex expression", - query: "1+2*3", - expectedTitle: "1+2*3 = 7", + name: "Logarithm", + query: "log2(8)", + expectedTitle: "3", expectedAction: "Copy result", }, { - name: "Calculator plugin - parentheses", - query: "(1+2)*3", - expectedTitle: "(1+2)*3 = 9", + name: "Power", + query: "pow(2,3)", + expectedTitle: "8", expectedAction: "Copy result", }, + { + name: "Square root", + query: "sqrt(16)", + expectedTitle: "4", + expectedAction: "Copy result", + }, + { + name: "Absolute value", + query: "abs(-42)", + expectedTitle: "42", + expectedAction: "Copy result", + }, + { + name: "Rounding", + query: "round(3.7)", + expectedTitle: "4", + expectedAction: "Copy result", + }, + { + name: "Nested functions", + query: "sqrt(pow(3,2) + pow(4,2))", + expectedTitle: "5", + expectedAction: "Copy result", + }, + } + runQueryTests(t, tests) +} - // URL plugin tests +func TestUrlPlugin(t *testing.T) { + tests := []queryTest{ { - name: "URL plugin - domain only", + name: "Domain only", query: "google.com", expectedTitle: "google.com", expectedAction: "Open", }, { - name: "URL plugin - with https", + name: "With https", query: "https://www.google.com", expectedTitle: "https://www.google.com", expectedAction: "Open", }, { - name: "URL plugin - with path", + name: "With path", query: "github.com/Wox-launcher/Wox", expectedTitle: "github.com/Wox-launcher/Wox", expectedAction: "Open", }, + } + runQueryTests(t, tests) +} - // System plugin tests +func TestSystemPlugin(t *testing.T) { + tests := []queryTest{ { - name: "System plugin - lock", + name: "Lock command", query: "lock", expectedTitle: "Lock PC", expectedAction: "Execute", }, { - name: "System plugin - settings", + name: "Settings command", query: "settings", expectedTitle: "Open Wox Settings", expectedAction: "Execute", }, + } + runQueryTests(t, tests) +} - // Web search plugin tests +func TestWebSearchPlugin(t *testing.T) { + tests := []queryTest{ { - name: "Web search plugin - google", + name: "Google search", query: "g wox launcher", expectedTitle: "Search for wox launcher", expectedAction: "Search", }, + } + runQueryTests(t, tests) +} - // File plugin tests +func TestFilePlugin(t *testing.T) { + tests := []queryTest{ { - name: "File plugin - search by name", + name: "Search by name", query: "f main.go", expectedTitle: "main.go", expectedAction: "Open", }, + } + runQueryTests(t, tests) +} + +func TestCalculatorTime(t *testing.T) { + // Get current time + now := time.Now() + hour := now.Hour() + ampm := "AM" + if hour >= 12 { + ampm = "PM" + if hour > 12 { + hour -= 12 + } + } + if hour == 0 { + hour = 12 + } + expectedTime := fmt.Sprintf("%d:%02d %s", hour, now.Minute(), ampm) - // Clipboard plugin tests + // Calculate expected date for "monday in 10 days" + targetDate := now.AddDate(0, 0, 10) + for targetDate.Weekday() != time.Monday { + targetDate = targetDate.AddDate(0, 0, 1) + } + expectedMonday := fmt.Sprintf("%s (Monday)", targetDate.Format("2006-01-02")) + + // Calculate expected days until Christmas 2025 + christmas := time.Date(2025, time.December, 25, 0, 0, 0, 0, time.Local) + daysUntilChristmas := int(christmas.Sub(now).Hours() / 24) + expectedDaysUntil := fmt.Sprintf("%d days", daysUntilChristmas) + + tests := []queryTest{ + { + name: "Time in location", + query: "time in Shanghai", + expectedTitle: expectedTime, + expectedAction: "Copy result", + }, + { + name: "Weekday in future", + query: "monday in 10 days", + expectedTitle: expectedMonday, + expectedAction: "Copy result", + }, { - name: "Clipboard plugin - show history", - query: "cb", - expectedTitle: "cb", - expectedAction: "Activate", + name: "Days until Christmas", + query: "days until 25 Dec 2025", + expectedTitle: expectedDaysUntil, + expectedAction: "Copy result", }, + { + name: "Specific time in location", + query: "3:30 pm in tokyo", + expectedTitle: "2:30 PM", + expectedAction: "Copy result", + }, + } + runQueryTests(t, tests) +} + +func init() { + ctx := context.Background() + + // Initialize location + err := util.GetLocation().Init() + if err != nil { + panic(err) } + // Extract resources + err = resource.Extract(ctx) + if err != nil { + panic(err) + } + + // Initialize settings + err = setting.GetSettingManager().Init(ctx) + if err != nil { + panic(err) + } + + // Initialize i18n + woxSetting := setting.GetSettingManager().GetWoxSetting(ctx) + err = i18n.GetI18nManager().UpdateLang(ctx, woxSetting.LangCode) + if err != nil { + panic(err) + } + + // Initialize UI + err = ui.GetUIManager().Start(ctx) + if err != nil { + panic(err) + } + + // Initialize plugin system with UI + plugin.GetPluginManager().Start(ctx, ui.GetUIManager().GetUI(ctx)) + + // Wait for plugins to initialize + time.Sleep(time.Second) + + // Initialize selection + util.InitSelection() +} + +type queryTest struct { + name string + query string + expectedTitle string + expectedAction string +} + +func runQueryTests(t *testing.T, tests []queryTest) { + ctx := util.NewTraceContext() var failedTests []string + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { success := true @@ -164,7 +321,6 @@ func TestQuery(t *testing.T) { // Collect all results var allResults []plugin.QueryResultUI - timeout := time.After(time.Second * 5) CollectResults: for { @@ -173,7 +329,7 @@ func TestQuery(t *testing.T) { allResults = append(allResults, results...) case <-doneChan: break CollectResults - case <-timeout: + case <-time.After(time.Second * 30): t.Errorf("Query timeout") failedTests = append(failedTests, tt.name) return @@ -244,7 +400,6 @@ func TestQuery(t *testing.T) { }) } - // 在所有测试完成后,显示失败的测试列表 if len(failedTests) > 0 { t.Errorf("\nFailed tests (%d):", len(failedTests)) for i, name := range failedTests { diff --git a/wox.core/plugin/system/calculator/calculator.go b/wox.core/plugin/system/calculator/calculator.go index 4f22df7cc..a9fb0e1c0 100644 --- a/wox.core/plugin/system/calculator/calculator.go +++ b/wox.core/plugin/system/calculator/calculator.go @@ -3,10 +3,13 @@ package calculator import ( "context" "fmt" + "strings" "wox/plugin" "wox/plugin/system/calculator/core" "wox/plugin/system/calculator/modules" "wox/util/clipboard" + + "github.com/samber/lo" ) func init() { @@ -21,7 +24,7 @@ type Calculator struct { func (c *Calculator) GetMetadata() plugin.Metadata { return plugin.Metadata{ - Id: "system.calculator", + Id: "a48dc5f0-dab9-4112-b883-b68129d6782b", Name: "Calculator", Author: "Wox Launcher", Website: "https://github.com/Wox-launcher/Wox", @@ -29,12 +32,11 @@ func (c *Calculator) GetMetadata() plugin.Metadata { MinWoxVersion: "2.0.0", Runtime: "Go", Description: "Calculator for Wox", - Icon: "calculator.png", + Icon: plugin.PluginCalculatorIcon.String(), Entry: "", TriggerKeywords: []string{ "*", "calculator", - "time", }, Commands: []plugin.MetadataCommand{}, SupportedOS: []string{ @@ -49,33 +51,35 @@ func (c *Calculator) Init(ctx context.Context, initParams plugin.InitParams) { c.api = initParams.API registry := core.NewModuleRegistry() - // Register all modules - mathModule := modules.NewMathModule(ctx, c.api) - registry.Register(mathModule) - - timeModule := modules.NewTimeModule(ctx, c.api) - registry.Register(timeModule) - - // TODO: implement these modules - //registry.Register(modules.NewCurrencyModule()) - //registry.Register(modules.NewUnitModule()) - //registry.Register(modules.NewCryptoModule()) + registry.Register(modules.NewMathModule(ctx, c.api)) + registry.Register(modules.NewTimeModule(ctx, c.api)) + registry.Register(modules.NewCurrencyModule(ctx, c.api)) - // Create tokenizer with all patterns from registered modules tokenizer := core.NewTokenizer(registry.GetTokenPatterns()) - c.registry = registry c.tokenizer = tokenizer } // parseExpression parses a complex expression like "1btc + 100usd" // It returns a slice of tokens grouped by their module -func (c *Calculator) parseExpression(ctx context.Context, tokens []core.Token) ([]*core.Value, []string, error) { - values := make([]*core.Value, 0) +func (c *Calculator) parseExpression(ctx context.Context, tokens []core.Token) ([]*core.Result, []string, error) { + values := make([]*core.Result, 0) operators := make([]string, 0) currentTokens := make([]core.Token, 0) + // First try math module for the entire expression + // because +-/* are supported by math module, which will be used for mixed unit expression + mathModule := c.registry.GetModule("math") + if mathModule != nil && mathModule.CanHandle(ctx, tokens) { + value, err := mathModule.Parse(ctx, tokens) + if err == nil { + values = append(values, value) + return values, operators, nil + } + } + + // If math module can't handle it, try parsing as mixed unit expression for i := 0; i < len(tokens); i++ { t := tokens[i] @@ -120,17 +124,31 @@ func (c *Calculator) parseExpression(ctx context.Context, tokens []core.Token) ( // calculateMixedUnits calculates expressions with mixed units // For example: "1btc + 100usd" will convert everything to USD and then calculate -func (c *Calculator) calculateMixedUnits(ctx context.Context, values []*core.Value, operators []string) (*core.Value, error) { +func (c *Calculator) calculateMixedUnits(ctx context.Context, values []*core.Result, operators []string) (*core.Result, error) { if len(values) == 0 { return nil, fmt.Errorf("no values to calculate") } - // Convert all values to USD (or the unit of the first value) + // If there are no operators, just return the first value as is + if len(operators) == 0 { + return values[0], nil + } + + // Convert all values to the first value's unit targetUnit := values[0].Unit - result := values[0].Amount + if targetUnit == "" || values[0].RawValue == nil { + return nil, fmt.Errorf("first value must have a unit and raw value") + } + + result := values[0].RawValue + unit := values[0].Unit for i := 0; i < len(operators); i++ { // Convert the next value to the target unit + if values[i+1].Unit == "" || values[i+1].RawValue == nil { + return nil, fmt.Errorf("value must have a unit and raw value") + } + convertedValue, err := c.registry.Convert(ctx, values[i+1], targetUnit) if err != nil { return nil, err @@ -139,13 +157,21 @@ func (c *Calculator) calculateMixedUnits(ctx context.Context, values []*core.Val // Perform the calculation switch operators[i] { case "+": - result = result.Add(convertedValue.Amount) + val := result.Add(*convertedValue.RawValue) + result = &val + unit = convertedValue.Unit case "-": - result = result.Sub(convertedValue.Amount) + val := result.Sub(*convertedValue.RawValue) + result = &val + unit = convertedValue.Unit } } - return &core.Value{Amount: result, Unit: targetUnit}, nil + return &core.Result{ + DisplayValue: fmt.Sprintf("%s %s", result.String(), unit), + RawValue: result, + Unit: unit, + }, nil } func (c *Calculator) Query(ctx context.Context, query plugin.Query) []plugin.QueryResult { @@ -160,64 +186,41 @@ func (c *Calculator) Query(ctx context.Context, query plugin.Query) []plugin.Que } c.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Tokens: %+v", tokens)) - // Try to parse as a mixed unit expression + // Try to parse as an expression (could be a simple math expression or a mixed unit expression) values, operators, err := c.parseExpression(ctx, tokens) - if err == nil && len(values) > 0 { - c.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Mixed unit expression: values=%+v, operators=%+v", values, operators)) - result, err := c.calculateMixedUnits(ctx, values, operators) - if err == nil { - return []plugin.QueryResult{ - { - Title: fmt.Sprintf("%s = %s", query.Search, result.Amount.String()), - SubTitle: fmt.Sprintf("Copy %s to clipboard", result.Amount.String()), - Icon: plugin.PluginCalculatorIcon, - Actions: []plugin.QueryResultAction{ - { - Name: "i18n:plugin_calculator_copy_result", - Action: func(ctx context.Context, actionContext plugin.ActionContext) { - clipboard.WriteText(result.Amount.String()) - }, - }, - }, - }, - } - } else { - c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("Mixed unit calculation error: %v", err)) - } - } else if err != nil { + if err != nil { c.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Parse expression error: %v", err)) + return []plugin.QueryResult{} } - // If mixed unit calculation fails, try to find a single module to handle it - for _, module := range c.registry.Modules() { - c.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Trying module: %s", module.Name())) - if module.CanHandle(ctx, tokens) { - c.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Module %s can handle tokens", module.Name())) - result, err := module.Calculate(ctx, tokens) - if err != nil { - c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("Calculate error from module %s: %v", module.Name(), err)) - continue - } - return []plugin.QueryResult{ + if len(values) == 0 { + c.api.Log(ctx, plugin.LogLevelDebug, "No values parsed from expression") + return []plugin.QueryResult{} + } + + c.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Expression parsed: values=[%s], operators=[%s]", + lo.Map(values, func(v *core.Result, _ int) string { return v.DisplayValue }), + strings.Join(operators, ""))) + + // Calculate the result (handles both simple and mixed unit expressions) + result, err := c.calculateMixedUnits(ctx, values, operators) + if err != nil { + c.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("Calculation error: %v", err)) + return []plugin.QueryResult{} + } + + return []plugin.QueryResult{ + { + Title: result.DisplayValue, + Icon: plugin.PluginCalculatorIcon, + Actions: []plugin.QueryResultAction{ { - Title: fmt.Sprintf("%s = %s", query.Search, result.Amount.String()), - SubTitle: fmt.Sprintf("Copy %s to clipboard", result.Amount.String()), - Icon: plugin.PluginCalculatorIcon, - Actions: []plugin.QueryResultAction{ - { - Name: "i18n:plugin_calculator_copy_result", - Action: func(ctx context.Context, actionContext plugin.ActionContext) { - clipboard.WriteText(result.Amount.String()) - }, - }, + Name: "i18n:plugin_calculator_copy_result", + Action: func(ctx context.Context, actionContext plugin.ActionContext) { + clipboard.WriteText(result.DisplayValue) }, }, - } - } else { - c.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Module %s cannot handle tokens", module.Name())) - } + }, + }, } - - c.api.Log(ctx, plugin.LogLevelDebug, "No module can handle the expression") - return []plugin.QueryResult{} } diff --git a/wox.core/plugin/system/calculator/core/parser.go b/wox.core/plugin/system/calculator/core/parser.go index 1fbb14e4b..442f8def7 100644 --- a/wox.core/plugin/system/calculator/core/parser.go +++ b/wox.core/plugin/system/calculator/core/parser.go @@ -9,6 +9,40 @@ import ( "github.com/shopspring/decimal" ) +// NodeKind represents the type of AST node +type NodeKind string + +const ( + AddNode NodeKind = "+" + SubNode NodeKind = "-" + MulNode NodeKind = "*" + DivNode NodeKind = "/" + FuncNode NodeKind = "func" + NumNode NodeKind = "num" + IdentNode NodeKind = "ident" +) + +// Node represents a node in the AST +type Node struct { + Kind NodeKind + Left *Node + Right *Node + FuncName string + Args []*Node + Val decimal.Decimal + Str string // Used for identifiers +} + +// Result represents a calculation result +type Result struct { + // The display value that will be shown to user + DisplayValue string + // The raw value that will be used for calculation (optional) + RawValue *decimal.Decimal + // The unit of the result (optional) + Unit string +} + type Parser struct { tokens []Token i int @@ -156,7 +190,60 @@ func (p *Parser) primary(ctx context.Context) (*Node, error) { } if p.tokens[p.i].Kind == IdentToken { - return p.identNode(ctx) + ident, err := p.identNode(ctx) + if err != nil { + return nil, err + } + + // Check if this is a function call + if p.i < len(p.tokens) && p.tokens[p.i].Kind == ReservedToken && p.tokens[p.i].Str == "(" { + // Consume the opening parenthesis + p.i++ + + // Parse function arguments + var args []*Node + for { + if p.i >= len(p.tokens) { + return nil, fmt.Errorf("unexpected end of input in function call") + } + + // Check for empty argument list or end of arguments + if p.tokens[p.i].Kind == ReservedToken && p.tokens[p.i].Str == ")" { + p.i++ // Consume the closing parenthesis + break + } + + // Parse the argument + arg, err := p.add(ctx) + if err != nil { + return nil, err + } + args = append(args, arg) + + // Check for comma or closing parenthesis + if p.i >= len(p.tokens) { + return nil, fmt.Errorf("unexpected end of input in function call") + } + if p.tokens[p.i].Kind == ReservedToken { + if p.tokens[p.i].Str == ")" { + p.i++ // Consume the closing parenthesis + break + } else if p.tokens[p.i].Str == "," { + p.i++ // Consume the comma + continue + } + } + return nil, fmt.Errorf("expected ',' or ')' in function call") + } + + return &Node{ + Kind: FuncNode, + FuncName: strings.ToLower(ident.Str), + Args: args, + }, nil + } + + return ident, nil } return p.numberNode(ctx) } diff --git a/wox.core/plugin/system/calculator/core/registry.go b/wox.core/plugin/system/calculator/core/registry.go new file mode 100644 index 000000000..87f431d92 --- /dev/null +++ b/wox.core/plugin/system/calculator/core/registry.go @@ -0,0 +1,76 @@ +package core + +import ( + "context" + "fmt" +) + +// Module represents a calculator module that can handle specific types of calculations +type Module interface { + // Name returns the name of the module + Name() string + + // TokenPatterns returns the token patterns this module needs + TokenPatterns() []TokenPattern + + // CanHandle returns true if this module can handle the given tokens + CanHandle(ctx context.Context, tokens []Token) bool + + // Parse parses tokens into a Result + Parse(ctx context.Context, tokens []Token) (*Result, error) + + // Calculate performs the calculation for this module + Calculate(ctx context.Context, tokens []Token) (*Result, error) + + // Convert converts a result to another unit within the same module + // For example: USD -> EUR, m -> km + Convert(ctx context.Context, value *Result, toUnit string) (*Result, error) + + // CanConvertTo returns true if this module can convert to the specified unit + CanConvertTo(unit string) bool +} + +type ModuleRegistry struct { + modules []Module +} + +func NewModuleRegistry() *ModuleRegistry { + return &ModuleRegistry{ + modules: make([]Module, 0), + } +} + +func (r *ModuleRegistry) Register(module Module) { + r.modules = append(r.modules, module) +} + +func (r *ModuleRegistry) Modules() []Module { + return r.modules +} + +func (r *ModuleRegistry) GetModule(name string) Module { + for _, module := range r.modules { + if module.Name() == name { + return module + } + } + return nil +} + +func (r *ModuleRegistry) GetTokenPatterns() []TokenPattern { + var patterns []TokenPattern + for _, module := range r.modules { + patterns = append(patterns, module.TokenPatterns()...) + } + return patterns +} + +func (r *ModuleRegistry) Convert(ctx context.Context, value *Result, toUnit string) (*Result, error) { + // Try to find a module that can convert to the target unit + for _, module := range r.modules { + if module.CanConvertTo(toUnit) { + return module.Convert(ctx, value, toUnit) + } + } + return nil, fmt.Errorf("no module can convert to unit: %s", toUnit) +} diff --git a/wox.core/plugin/system/calculator/core/tokenizer.go b/wox.core/plugin/system/calculator/core/tokenizer.go index 0e899fcef..415bfde23 100644 --- a/wox.core/plugin/system/calculator/core/tokenizer.go +++ b/wox.core/plugin/system/calculator/core/tokenizer.go @@ -2,112 +2,111 @@ package core import ( "context" + "fmt" "regexp" - "strconv" "strings" - "unicode" "github.com/shopspring/decimal" ) +type TokenKind int + +const ( + UnknownToken TokenKind = iota // For error handling + NumberToken // For numbers (e.g., 100, 3.14) + IdentToken // For identifiers and keywords (e.g., USD, in, to) + ReservedToken // For operators and special characters (e.g., +, -, *, /) + EosToken // End of stream token +) + +type Token struct { + Kind TokenKind + Val decimal.Decimal // Only used for NumberToken + Str string // Original string representation +} + +func (t *Token) String() string { + return t.Str +} + +type TokenPattern struct { + Pattern string // Regex pattern for matching + Type TokenKind // Type of token this pattern produces + Priority int // Higher priority patterns are matched first + FullMatch bool // Whether this pattern should match the entire input +} + type Tokenizer struct { patterns []TokenPattern } func NewTokenizer(patterns []TokenPattern) *Tokenizer { - // Sort patterns by priority - sorted := make([]TokenPattern, len(patterns)) - copy(sorted, patterns) - for i := 0; i < len(sorted)-1; i++ { - for j := i + 1; j < len(sorted); j++ { - if sorted[i].Priority < sorted[j].Priority { - sorted[i], sorted[j] = sorted[j], sorted[i] + // Sort patterns by priority (highest first) + for i := 0; i < len(patterns)-1; i++ { + for j := i + 1; j < len(patterns); j++ { + if patterns[i].Priority < patterns[j].Priority { + patterns[i], patterns[j] = patterns[j], patterns[i] } } } - return &Tokenizer{patterns: sorted} -} -type invalidTokenError struct { - input string - position int + return &Tokenizer{patterns: patterns} } -func (e *invalidTokenError) Error() string { - curr := "" - pos := e.position - for _, line := range strings.Split(e.input, "\n") { - len := len(line) - curr += line + "\n" - if pos < len { - return curr + strings.Repeat(" ", pos) + "^ invalid token" +func (t *Tokenizer) Tokenize(ctx context.Context, input string) ([]Token, error) { + var tokens []Token + input = strings.TrimSpace(input) + + // Try full match patterns first + for _, pattern := range t.patterns { + if !pattern.FullMatch { + continue + } + re := regexp.MustCompile(`^` + pattern.Pattern + `$`) + if re.MatchString(input) { + // For full match patterns, we create a single token with the entire input + token := Token{Kind: pattern.Type, Str: input} + if pattern.Type == NumberToken { + // Only parse decimal value for number tokens + if val, err := decimal.NewFromString(input); err == nil { + token.Val = val + } + } + return []Token{token, {Kind: EosToken}}, nil } - pos -= len + 1 } - return "" -} -func (t *Tokenizer) Tokenize(ctx context.Context, input string) ([]Token, error) { - chars := []rune(input) - i := 0 - n := len(chars) - tokens := []Token{} - - for i < n { - char := chars[i] - if unicode.IsSpace(char) { - i++ - continue + // If no full match, tokenize normally + for len(input) > 0 { + input = strings.TrimSpace(input) + if len(input) == 0 { + break } - // Try to match each pattern matched := false for _, pattern := range t.patterns { - var re *regexp.Regexp - var match string - if pattern.FullMatch { - // For full match patterns, try to match the entire remaining input - re = regexp.MustCompile("^" + pattern.Pattern + "$") - match = re.FindString(strings.TrimSpace(string(chars[i:]))) - if match != "" { - tokens = append(tokens, Token{Kind: pattern.Type, Str: match}) - i = n // Move to the end - matched = true - break - } - } else { - // For partial match patterns, match from current position - re = regexp.MustCompile("^" + pattern.Pattern) - match = re.FindString(string(chars[i:])) - if match != "" { - if pattern.Type == NumberToken { - val, err := strconv.ParseFloat(match, 64) - if err != nil { - return nil, err - } - tokens = append(tokens, Token{Kind: NumberToken, Val: decimal.NewFromFloat(val)}) - } else { - tokens = append(tokens, Token{Kind: pattern.Type, Str: match}) - } - i += len([]rune(match)) - matched = true - break - } + continue } - } - if !matched { - // Special handling for operators - if strings.ContainsRune("+-*/(),", char) { - tokens = append(tokens, Token{Kind: ReservedToken, Str: string(char)}) - i++ + re := regexp.MustCompile(`^` + pattern.Pattern) + if matches := re.FindString(input); matches != "" { + token := Token{Kind: pattern.Type, Str: matches} + if pattern.Type == NumberToken { + // Only parse decimal value for number tokens + if val, err := decimal.NewFromString(matches); err == nil { + token.Val = val + } + } + tokens = append(tokens, token) + input = input[len(matches):] matched = true + break } } if !matched { - return nil, &invalidTokenError{input: input, position: i} + return nil, fmt.Errorf("invalid token at: %s", input) } } diff --git a/wox.core/plugin/system/calculator/core/types.go b/wox.core/plugin/system/calculator/core/types.go deleted file mode 100644 index 49edc78d3..000000000 --- a/wox.core/plugin/system/calculator/core/types.go +++ /dev/null @@ -1,153 +0,0 @@ -package core - -import ( - "context" - "fmt" - - "github.com/shopspring/decimal" -) - -type TokenKind string - -const ( - ReservedToken TokenKind = "reserved" - NumberToken TokenKind = "number" - IdentToken TokenKind = "ident" - EosToken TokenKind = "eos" -) - -type Token struct { - Kind TokenKind - Val decimal.Decimal - Str string -} - -// TokenPattern defines how a module's tokens should be recognized -type TokenPattern struct { - Pattern string - Type TokenKind - Priority int - // If true, try to match the entire remaining input - FullMatch bool -} - -// Value represents a value with its unit -type Value struct { - Amount decimal.Decimal - Unit string // e.g., "USD", "BTC", "m", "kg" -} - -// NodeKind represents the type of AST node -type NodeKind string - -const ( - AddNode NodeKind = "+" - SubNode NodeKind = "-" - MulNode NodeKind = "*" - DivNode NodeKind = "/" - FuncNode NodeKind = "func" - NumNode NodeKind = "num" - IdentNode NodeKind = "ident" -) - -// Node represents a node in the AST -type Node struct { - Kind NodeKind - Left *Node - Right *Node - FuncName string - Args []*Node - Val decimal.Decimal - Str string // Used for identifiers -} - -// Module represents a calculator module that can handle specific types of calculations -type Module interface { - // Name returns the name of the module - Name() string - - // TokenPatterns returns the token patterns this module needs - TokenPatterns() []TokenPattern - - // CanHandle returns true if this module can handle the given tokens - CanHandle(ctx context.Context, tokens []Token) bool - - // Parse parses tokens into a Value - Parse(ctx context.Context, tokens []Token) (*Value, error) - - // Calculate performs the calculation for this module - Calculate(ctx context.Context, tokens []Token) (*Value, error) - - // Convert converts a value to another unit within the same module - // For example: USD -> EUR, m -> km - Convert(ctx context.Context, value *Value, toUnit string) (*Value, error) - - // CanConvertTo returns true if this module can convert to the specified unit - CanConvertTo(unit string) bool -} - -// ModuleRegistry manages all calculator modules -type ModuleRegistry struct { - modules []Module -} - -func NewModuleRegistry() *ModuleRegistry { - return &ModuleRegistry{ - modules: make([]Module, 0), - } -} - -func (r *ModuleRegistry) Register(module Module) { - r.modules = append(r.modules, module) -} - -func (r *ModuleRegistry) GetTokenPatterns() []TokenPattern { - patterns := make([]TokenPattern, 0) - for _, module := range r.modules { - patterns = append(patterns, module.TokenPatterns()...) - } - return patterns -} - -// Modules returns all registered modules -func (r *ModuleRegistry) Modules() []Module { - return r.modules -} - -// FindModuleForUnit finds the module that can handle the specified unit -func (r *ModuleRegistry) FindModuleForUnit(unit string) Module { - for _, module := range r.modules { - if module.CanConvertTo(unit) { - return module - } - } - return nil -} - -// Convert converts a value from one unit to another, possibly across different modules -// For example: BTC -> USD (requires crypto module to convert BTC to USD) -func (r *ModuleRegistry) Convert(ctx context.Context, value *Value, toUnit string) (*Value, error) { - // First try to convert within the same module - fromModule := r.FindModuleForUnit(value.Unit) - if fromModule != nil && fromModule.CanConvertTo(toUnit) { - return fromModule.Convert(ctx, value, toUnit) - } - - // If direct conversion is not possible, try to find a module that can handle the target unit - toModule := r.FindModuleForUnit(toUnit) - if toModule == nil { - return nil, fmt.Errorf("no module can handle unit: %s", toUnit) - } - - // Try to convert through USD as an intermediate currency - // This is a simplified example, we might need a more sophisticated conversion graph - if value.Unit != "USD" { - usdValue, err := fromModule.Convert(ctx, value, "USD") - if err != nil { - return nil, err - } - return toModule.Convert(ctx, usdValue, toUnit) - } - - return nil, fmt.Errorf("cannot convert from %s to %s", value.Unit, toUnit) -} diff --git a/wox.core/plugin/system/calculator/modules/base_regex_module.go b/wox.core/plugin/system/calculator/modules/base_regex_module.go new file mode 100644 index 000000000..fac800d46 --- /dev/null +++ b/wox.core/plugin/system/calculator/modules/base_regex_module.go @@ -0,0 +1,117 @@ +package modules + +import ( + "context" + "fmt" + "regexp" + "strings" + "wox/plugin" + "wox/plugin/system/calculator/core" +) + +// PatternHandler represents a pattern and its corresponding handler +type patternHandler struct { + Pattern string // regex pattern + Priority int // pattern priority + Handler func(ctx context.Context, matches []string) (*core.Result, error) // handler function for the pattern + Description string // description of what this pattern does + regexp *regexp.Regexp // compiled regexp +} + +// regexBaseModule is a base module that provides regex-based pattern matching functionality +type regexBaseModule struct { + api plugin.API + name string + patternHandlers []*patternHandler +} + +// NewregexBaseModule creates a new regexBaseModule +func NewRegexBaseModule(api plugin.API, name string, handlers []*patternHandler) *regexBaseModule { + m := ®exBaseModule{ + api: api, + name: name, + patternHandlers: handlers, + } + + // Compile all regexps + for _, handler := range handlers { + handler.regexp = regexp.MustCompile(handler.Pattern) + } + + return m +} + +// Name returns the name of the module +func (m *regexBaseModule) Name() string { + return m.name +} + +// TokenPatterns returns the token patterns for this module +func (m *regexBaseModule) TokenPatterns() []core.TokenPattern { + patterns := make([]core.TokenPattern, 0, len(m.patternHandlers)) + for _, handler := range m.patternHandlers { + patterns = append(patterns, core.TokenPattern{ + Pattern: handler.Pattern, + Type: core.IdentToken, + Priority: handler.Priority, + FullMatch: true, + }) + } + return patterns +} + +// CanHandle checks if this module can handle the given tokens +func (m *regexBaseModule) CanHandle(ctx context.Context, tokens []core.Token) bool { + if len(tokens) == 0 { + m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("%s.CanHandle: no tokens", m.name)) + return false + } + + inputStr := m.getInputString(tokens) + m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("%s.CanHandle: input=%s", m.name, inputStr)) + + for _, handler := range m.patternHandlers { + if handler.regexp.MatchString(inputStr) { + m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("%s.CanHandle: matched pattern %s", m.name, handler.Description)) + return true + } + } + + m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("%s.CanHandle: no pattern matched", m.name)) + return false +} + +// Parse parses the tokens using the registered patterns +func (m *regexBaseModule) Parse(ctx context.Context, tokens []core.Token) (*core.Result, error) { + if len(tokens) == 0 { + return nil, fmt.Errorf("no tokens to parse") + } + + inputStr := m.getInputString(tokens) + m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("%s.Parse: input=%s", m.name, inputStr)) + + for _, handler := range m.patternHandlers { + if matches := handler.regexp.FindStringSubmatch(inputStr); len(matches) > 0 { + m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Matched pattern %s with matches: %v", handler.Description, matches)) + return handler.Handler(ctx, matches) + } + } + + return nil, fmt.Errorf("unsupported format") +} + +// Calculate performs the calculation using the Parse method +func (m *regexBaseModule) Calculate(ctx context.Context, tokens []core.Token) (*core.Result, error) { + return m.Parse(ctx, tokens) +} + +// Helper functions + +func (m *regexBaseModule) getInputString(tokens []core.Token) string { + var input strings.Builder + for _, token := range tokens { + input.WriteString(token.Str) + input.WriteString(" ") + } + return strings.TrimSpace(strings.ToLower(input.String())) +} diff --git a/wox.core/plugin/system/calculator/modules/currency.go b/wox.core/plugin/system/calculator/modules/currency.go new file mode 100644 index 000000000..b67f9afbb --- /dev/null +++ b/wox.core/plugin/system/calculator/modules/currency.go @@ -0,0 +1,155 @@ +package modules + +import ( + "context" + "fmt" + "strings" + "wox/plugin" + "wox/plugin/system/calculator/core" + + "github.com/shopspring/decimal" +) + +type CurrencyModule struct { + *regexBaseModule + rates map[string]float64 +} + +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, + } + + const ( + currencyPattern = `(usd|eur|gbp|jpy|cny|aud|cad)` + numberPattern = `([0-9]+(?:\.[0-9]+)?)` + ) + + // Initialize pattern handlers + handlers := []*patternHandler{ + { + Pattern: numberPattern + `\s*` + currencyPattern + `\s+in\s+` + currencyPattern, + Priority: 1000, + Description: "Convert currency using 'in' format (e.g., 10 USD in EUR)", + Handler: m.handleConversion, + }, + { + Pattern: numberPattern + `\s*` + currencyPattern + `\s*=\s*\?\s*` + currencyPattern, + Priority: 900, + Description: "Convert currency using '=?' format (e.g., 10USD=?EUR)", + Handler: m.handleConversion, + }, + { + Pattern: numberPattern + `\s*` + currencyPattern + `\s+to\s+` + currencyPattern, + Priority: 800, + Description: "Convert currency using 'to' format (e.g., 10 USD to EUR)", + Handler: m.handleConversion, + }, + } + + m.regexBaseModule = NewRegexBaseModule(api, "currency", handlers) + return m +} + +func (m *CurrencyModule) Convert(ctx context.Context, value *core.Result, toUnit string) (*core.Result, error) { + fromCurrency := value.Unit + toCurrency := strings.ToUpper(toUnit) + + // Check if currencies are supported + if _, ok := m.rates[fromCurrency]; !ok { + return nil, fmt.Errorf("unsupported currency: %s", fromCurrency) + } + if _, ok := m.rates[toCurrency]; !ok { + return nil, fmt.Errorf("unsupported currency: %s", toCurrency) + } + + // Convert to USD first (as base currency), then to target currency + amountFloat, _ := value.RawValue.Float64() + amountInUSD := amountFloat / m.rates[fromCurrency] + result := amountInUSD * m.rates[toCurrency] + resultDecimal := decimal.NewFromFloat(result) + + return &core.Result{ + DisplayValue: m.formatWithCurrencySymbol(resultDecimal, toCurrency), + RawValue: &resultDecimal, + Unit: toCurrency, + }, nil +} + +func (m *CurrencyModule) CanConvertTo(unit string) bool { + _, ok := m.rates[strings.ToUpper(unit)] + return ok +} + +// Helper functions + +func (m *CurrencyModule) handleConversion(ctx context.Context, matches []string) (*core.Result, error) { + // matches[0] is the full match + // matches[1] is the amount + // matches[2] is the source currency + // matches[3] is the target currency + amount, err := decimal.NewFromString(matches[1]) + if err != nil { + return nil, fmt.Errorf("invalid amount: %s", matches[1]) + } + + fromCurrency := strings.ToUpper(matches[2]) + toCurrency := strings.ToUpper(matches[3]) + + // Check if currencies are supported + if _, ok := m.rates[fromCurrency]; !ok { + return nil, fmt.Errorf("unsupported currency: %s", fromCurrency) + } + if _, ok := m.rates[toCurrency]; !ok { + return nil, fmt.Errorf("unsupported currency: %s", toCurrency) + } + + // Convert to USD first (as base currency), then to target currency + amountFloat, _ := amount.Float64() + amountInUSD := amountFloat / m.rates[fromCurrency] + result := amountInUSD * m.rates[toCurrency] + resultDecimal := decimal.NewFromFloat(result) + + return &core.Result{ + DisplayValue: m.formatWithCurrencySymbol(resultDecimal, toCurrency), + RawValue: &resultDecimal, + Unit: toCurrency, + }, nil +} + +func (m *CurrencyModule) formatWithCurrencySymbol(amount decimal.Decimal, currency string) string { + var symbol string + switch currency { + case "USD": + symbol = "$" + case "EUR": + symbol = "€" + case "GBP": + symbol = "£" + case "JPY": + symbol = "¥" + case "CNY": + symbol = "¥" + case "AUD": + symbol = "A$" + case "CAD": + symbol = "C$" + default: + symbol = "" + } + + // Format with exactly 2 decimal places + return fmt.Sprintf("%s%s", symbol, amount) +} diff --git a/wox.core/plugin/system/calculator/modules/math.go b/wox.core/plugin/system/calculator/modules/math.go index 9838252c8..07e24e563 100644 --- a/wox.core/plugin/system/calculator/modules/math.go +++ b/wox.core/plugin/system/calculator/modules/math.go @@ -122,11 +122,36 @@ func (m *MathModule) CanHandle(ctx context.Context, tokens []core.Token) bool { m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("MathModule.CanHandle: tokens=%+v", tokens)) - firstToken := tokens[0] - m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("MathModule.CanHandle: first token kind=%v", firstToken.Kind)) - return firstToken.Kind == core.NumberToken || - firstToken.Kind == core.IdentToken || - (firstToken.Kind == core.ReservedToken && firstToken.Str == "(") + // Check all tokens to ensure they are valid math expressions + for _, token := range tokens { + switch token.Kind { + case core.NumberToken: + // Numbers are always valid + continue + case core.ReservedToken: + // Only allow math operators and parentheses + if !strings.Contains("+-*/(),", token.Str) { + m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("MathModule.CanHandle: invalid reserved token %s", token.Str)) + return false + } + case core.IdentToken: + // Check if it's a known function or constant + if _, ok := functions[strings.ToLower(token.Str)]; !ok { + if _, ok := constants[strings.ToLower(token.Str)]; !ok { + m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("MathModule.CanHandle: unknown identifier %s", token.Str)) + return false + } + } + case core.EosToken: + // Ignore end of stream token + continue + default: + m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("MathModule.CanHandle: invalid token kind %v", token.Kind)) + return false + } + } + + return true } func (m *MathModule) call(funcName string, args []decimal.Decimal) (decimal.Decimal, error) { @@ -134,6 +159,7 @@ func (m *MathModule) call(funcName string, args []decimal.Decimal) (decimal.Deci if !ok { return decimal.Zero, fmt.Errorf("unknown function %s", funcName) } + switch f := f.(type) { case func() float64: return decimal.NewFromFloat(f()), nil @@ -216,7 +242,7 @@ func (m *MathModule) calculate(ctx context.Context, n *core.Node) (decimal.Decim return decimal.Zero, fmt.Errorf("unknown node type: %s", n.Kind) } -func (m *MathModule) Parse(ctx context.Context, tokens []core.Token) (*core.Value, error) { +func (m *MathModule) Parse(ctx context.Context, tokens []core.Token) (*core.Result, error) { m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("MathModule.Parse: tokens=%+v", tokens)) m.parser = core.NewParser(tokens) node, err := m.parser.Parse(ctx) @@ -225,21 +251,26 @@ func (m *MathModule) Parse(ctx context.Context, tokens []core.Token) (*core.Valu return nil, err } m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("MathModule.Parse: node=%+v", node)) + result, err := m.calculate(ctx, node) if err != nil { m.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("MathModule.Parse: calculate error=%v", err)) return nil, err } m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("MathModule.Parse: result=%v", result)) - return &core.Value{Amount: result, Unit: ""}, nil + + return &core.Result{ + DisplayValue: result.String(), + RawValue: &result, + }, nil } -func (m *MathModule) Calculate(ctx context.Context, tokens []core.Token) (*core.Value, error) { +func (m *MathModule) Calculate(ctx context.Context, tokens []core.Token) (*core.Result, error) { return m.Parse(ctx, tokens) } -func (m *MathModule) Convert(ctx context.Context, value *core.Value, toUnit string) (*core.Value, error) { - return nil, fmt.Errorf("math module doesn't support unit conversion") +func (m *MathModule) Convert(ctx context.Context, value *core.Result, toUnit string) (*core.Result, error) { + return nil, fmt.Errorf("math module does not support unit conversion") } func (m *MathModule) CanConvertTo(unit string) bool { diff --git a/wox.core/plugin/system/calculator/modules/time.go b/wox.core/plugin/system/calculator/modules/time.go index e549e6faf..0991959f7 100644 --- a/wox.core/plugin/system/calculator/modules/time.go +++ b/wox.core/plugin/system/calculator/modules/time.go @@ -3,7 +3,6 @@ package modules import ( "context" "fmt" - "regexp" "strconv" "strings" "time" @@ -13,385 +12,269 @@ import ( "github.com/shopspring/decimal" ) -const ( - weekdayNames = `(monday|tuesday|wednesday|thursday|friday|saturday|sunday)` - monthNames = `(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)` - timePattern = `[0-9]{1,2}(:[0-9]{2})?\s*(am|pm)?` - - // Base patterns - timeInLocationPattern = `time\s+in\s+([a-zA-Z\s/]+)` - weekdayInFuturePattern = weekdayNames + `\s+in\s+(\d+)\s*([a-z]*)` - daysUntilPattern = `days?\s+until\s+\d+\s*(?:st|nd|rd|th)?\s+` + monthNames + `(?:\s+\d{4})?` - specificTimePattern = timePattern + `\s+in\s+([a-zA-Z\s/]+)` -) +var timeZoneAliases = map[string]string{ + "shanghai": "Asia/Shanghai", + "beijing": "Asia/Shanghai", + "london": "Europe/London", + "tokyo": "Asia/Tokyo", + "paris": "Europe/Paris", + "berlin": "Europe/Berlin", + "new york": "America/New_York", + "la": "America/Los_Angeles", + "los angeles": "America/Los_Angeles", +} type TimeModule struct { - // Pre-compiled regular expressions - timeInLocationRe *regexp.Regexp - weekdayInFutureRe *regexp.Regexp - daysUntilRe *regexp.Regexp - specificTimeRe *regexp.Regexp - api plugin.API + *regexBaseModule } func NewTimeModule(ctx context.Context, api plugin.API) *TimeModule { - return &TimeModule{ - timeInLocationRe: regexp.MustCompile(timeInLocationPattern), - weekdayInFutureRe: regexp.MustCompile(weekdayInFuturePattern), - daysUntilRe: regexp.MustCompile(daysUntilPattern), - specificTimeRe: regexp.MustCompile(specificTimePattern), - api: api, - } -} + m := &TimeModule{} -func (m *TimeModule) Name() string { - return "time" -} + const ( + weekdayNames = `(monday|tuesday|wednesday|thursday|friday|saturday|sunday)` + monthNames = `(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)` + timePattern = `([0-9]{1,2}(?::[0-9]{2})?\s*(?i:(?:am|pm))?)` + ) -func (m *TimeModule) TokenPatterns() []core.TokenPattern { - return []core.TokenPattern{ - { - Pattern: `[a-zA-Z]+(/[a-zA-Z_]+)*`, - Type: core.IdentToken, - Priority: 10, - FullMatch: false, - }, + // Initialize pattern handlers + handlers := []*patternHandler{ { - Pattern: timeInLocationPattern, - Type: core.IdentToken, - Priority: 100, - FullMatch: true, + Pattern: `(?i:time\s+in\s+([a-zA-Z\s/]+))`, + Priority: 1000, + Description: "Get current time in a specific location", + Handler: m.handleTimeInLocation, }, { - Pattern: timePattern, - Type: core.NumberToken, - Priority: 90, - FullMatch: false, + Pattern: `(?i:` + timePattern + `\s+in\s+([a-zA-Z\s/]+))`, + Priority: 900, + Description: "Convert specific time from one location to local time", + Handler: m.handleSpecificTime, }, { - Pattern: weekdayInFuturePattern, - Type: core.IdentToken, - Priority: 85, - FullMatch: true, + Pattern: `(?i:` + weekdayNames + `\s+in\s+(\d+)\s*([a-z]*))`, + Priority: 800, + Description: "Calculate future weekday", + Handler: m.handleWeekdayInFuture, }, { - Pattern: daysUntilPattern, - Type: core.IdentToken, - Priority: 85, - FullMatch: true, + Pattern: `(?i:days?\s+until\s+(\d+)(?:st|nd|rd|th)?\s+` + monthNames + `(?:\s+(\d{4}))?)`, + Priority: 800, + Description: "Calculate days until a specific date", + Handler: m.handleDaysUntil, }, } + + m.regexBaseModule = NewRegexBaseModule(api, "time", handlers) + return m } -func (m *TimeModule) CanHandle(ctx context.Context, tokens []core.Token) bool { - if len(tokens) == 0 { - m.api.Log(ctx, plugin.LogLevelDebug, "TimeModule.CanHandle: no tokens") - return false - } +func (m *TimeModule) Convert(ctx context.Context, value *core.Result, toUnit string) (*core.Result, error) { + return nil, fmt.Errorf("time conversion not supported") +} - m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("TimeModule.CanHandle: tokens=%+v", tokens)) +func (m *TimeModule) CanConvertTo(unit string) bool { + return false +} - // Join all tokens into a string with spaces - var sb strings.Builder - for i, token := range tokens { - if i > 0 { - sb.WriteString(" ") - } - sb.WriteString(token.Str) - } - input := strings.ToLower(strings.TrimSpace(sb.String())) +// Helper functions - m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("TimeModule.CanHandle: input=%s", input)) +func (m *TimeModule) handleTimeInLocation(ctx context.Context, matches []string) (*core.Result, error) { + location := strings.ToLower(strings.TrimSpace(matches[1])) - // Check if input matches any of our patterns - if m.timeInLocationRe.MatchString(input) { - m.api.Log(ctx, plugin.LogLevelDebug, "TimeModule.CanHandle: timeInLocation pattern matched") - return true - } - if m.weekdayInFutureRe.MatchString(input) { - m.api.Log(ctx, plugin.LogLevelDebug, "TimeModule.CanHandle: weekdayInFuture pattern matched") - return true - } - if m.daysUntilRe.MatchString(input) { - m.api.Log(ctx, plugin.LogLevelDebug, "TimeModule.CanHandle: daysUntil pattern matched") - return true - } - if m.specificTimeRe.MatchString(input) { - m.api.Log(ctx, plugin.LogLevelDebug, "TimeModule.CanHandle: specificTime pattern matched") - return true + // Try to find the timezone alias + if tzName, ok := timeZoneAliases[location]; ok { + location = tzName } - m.api.Log(ctx, plugin.LogLevelDebug, "TimeModule.CanHandle: no pattern matched") - return false -} - -func (m *TimeModule) Parse(ctx context.Context, tokens []core.Token) (*core.Value, error) { - // Join all tokens into a string with spaces - var sb strings.Builder - for i, t := range tokens { - if i > 0 { - sb.WriteString(" ") - } - sb.WriteString(t.Str) + // Load the location + loc, err := time.LoadLocation(location) + if err != nil { + return nil, fmt.Errorf("unknown location: %s", location) } - input := strings.ToLower(strings.TrimSpace(sb.String())) - - m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("TimeModule.Parse: input=%s", input)) - // Check for timezone query first - if matches := m.timeInLocationRe.FindStringSubmatch(input); len(matches) > 0 { - m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Time in location matches: %v", matches)) - if len(matches) < 2 { - return nil, fmt.Errorf("invalid time in location format") - } - location := strings.ToLower(strings.TrimSpace(matches[len(matches)-1])) - m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Extracted location: %s", location)) - - // Try to find the timezone alias - if tzName, ok := timeZoneAliases[location]; ok { - location = tzName - } - m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Resolved timezone: %s", location)) + // Get current time in location + now := time.Now().In(loc) + val := decimal.NewFromInt(now.Unix()) + return &core.Result{ + DisplayValue: m.formatTimeForDisplay(now), + RawValue: &val, + Unit: location, + }, nil +} - // Load the location - loc, err := time.LoadLocation(location) - if err != nil { - m.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("Failed to load location: %v", err)) - return nil, fmt.Errorf("unknown location: %s", location) - } +func (m *TimeModule) handleSpecificTime(ctx context.Context, matches []string) (*core.Result, error) { + timeStr := matches[1] + location := strings.ToLower(strings.TrimSpace(matches[2])) - // Get current time in location - now := time.Now().In(loc) - return &core.Value{ - Amount: decimal.NewFromInt(now.Unix()), - Unit: "timestamp", - }, nil + // Try to find the timezone alias + if tzName, ok := timeZoneAliases[location]; ok { + location = tzName } - // Check for specific time in location - if matches := m.specificTimeRe.FindStringSubmatch(input); len(matches) > 0 { - m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Specific time matches: %v", matches)) - if len(matches) < 2 { - return nil, fmt.Errorf("invalid specific time format") - } - - timeStr := matches[1] - location := strings.ToLower(strings.TrimSpace(matches[len(matches)-1])) - - // Parse the time - t, err := parseTime(ctx, timeStr) - if err != nil { - return nil, err - } + // Load the source location + sourceLoc, err := time.LoadLocation(location) + if err != nil { + return nil, fmt.Errorf("unknown location: %s", location) + } - // Try to find the timezone alias - if tzName, ok := timeZoneAliases[location]; ok { - location = tzName - } + // Parse time in source timezone + t, err := m.parseTime(ctx, timeStr) + if err != nil { + return nil, err + } - // Load the location - loc, err := time.LoadLocation(location) - if err != nil { - return nil, fmt.Errorf("unknown location: %s", location) - } + // Convert time from source timezone to local timezone + sourceTime := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, sourceLoc) + localTime := sourceTime.In(time.Local) + + displayValue := m.formatTimeForDisplay(localTime) + val := decimal.NewFromInt(localTime.Unix()) + return &core.Result{ + DisplayValue: displayValue, + RawValue: &val, + Unit: "local", + }, nil +} - // Set the location - t = t.In(loc) +func (m *TimeModule) handleWeekdayInFuture(ctx context.Context, matches []string) (*core.Result, error) { + targetWeekday := strings.ToLower(matches[1]) + daysStr := matches[2] + // unit is optional and not used currently + // unit := matches[3] // might be empty, "days", "day" - return &core.Value{ - Amount: decimal.NewFromInt(t.Unix()), - Unit: "timestamp", - }, nil + // Parse number of days + days, err := strconv.Atoi(daysStr) + if err != nil { + return nil, fmt.Errorf("invalid number of days: %s", daysStr) } - // Check for weekday in future - if matches := m.weekdayInFutureRe.FindStringSubmatch(input); len(matches) > 0 { - m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Weekday matches: %v", matches)) - if len(matches) < 3 { - return nil, fmt.Errorf("invalid weekday format") - } - - weekday := weekdayMap[matches[1]] - number, _ := strconv.Atoi(matches[2]) - unit := normalizeUnit(ctx, matches[3]) - if unit == "" { - unit = "week" // Default unit is week - } + // Get target weekday + weekdayMap := map[string]time.Weekday{ + "monday": time.Monday, + "tuesday": time.Tuesday, + "wednesday": time.Wednesday, + "thursday": time.Thursday, + "friday": time.Friday, + "saturday": time.Saturday, + "sunday": time.Sunday, + } + targetDay, ok := weekdayMap[targetWeekday] + if !ok { + return nil, fmt.Errorf("invalid weekday: %s", targetWeekday) + } - var result time.Time - switch unit { - case "week": - result = calculateNextWeekday(ctx, weekday, number) - case "day": - result = time.Now().AddDate(0, 0, number) - case "month": - result = time.Now().AddDate(0, number, 0) - default: - result = calculateNextWeekday(ctx, weekday, number) // Default to week calculation - } + // Calculate target date + now := time.Now() + targetDate := now.AddDate(0, 0, days) - return &core.Value{ - Amount: decimal.NewFromInt(result.Unix()), - Unit: "timestamp", - }, nil + // Find the next occurrence of the target weekday after the target date + for targetDate.Weekday() != targetDay { + targetDate = targetDate.AddDate(0, 0, 1) } - // Check for days until - if matches := m.daysUntilRe.FindStringSubmatch(input); len(matches) > 0 { - m.api.Log(ctx, plugin.LogLevelDebug, fmt.Sprintf("Days until matches: %v", matches)) - if len(matches) < 3 { - return nil, fmt.Errorf("invalid days until format") - } - - // Extract day, month and year - parts := strings.Fields(matches[0]) - dayStr := strings.TrimRight(parts[2], "stndrdth") - day, err := strconv.Atoi(dayStr) - if err != nil { - m.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("Failed to parse day: %v", err)) - return nil, fmt.Errorf("invalid day: %s", dayStr) - } + val := decimal.NewFromInt(targetDate.Unix()) + displayValue := fmt.Sprintf("%s (%s)", targetDate.Format("2006-01-02"), targetDate.Weekday().String()) + return &core.Result{ + DisplayValue: displayValue, + RawValue: &val, + Unit: "date", + }, nil +} - monthStr := strings.ToLower(matches[len(matches)-1]) - month := monthMap[monthStr] +func (m *TimeModule) handleDaysUntil(ctx context.Context, matches []string) (*core.Result, error) { + // Parse day + day, err := strconv.Atoi(matches[1]) + if err != nil { + return nil, fmt.Errorf("invalid day: %s", matches[1]) + } - year := time.Now().Year() - if len(matches) > 3 { - year, _ = strconv.Atoi(matches[3]) - } + // Parse month + monthMap := map[string]time.Month{ + "jan": time.January, + "feb": time.February, + "mar": time.March, + "apr": time.April, + "may": time.May, + "jun": time.June, + "jul": time.July, + "aug": time.August, + "sep": time.September, + "oct": time.October, + "nov": time.November, + "dec": time.December, + } + month, ok := monthMap[strings.ToLower(matches[2])] + if !ok { + return nil, fmt.Errorf("invalid month: %s", matches[2]) + } - days, err := calculateDaysUntil(ctx, day, month, year) + // Parse year (use current year if not specified) + year := time.Now().Year() + if len(matches) > 3 && matches[3] != "" { + year, err = strconv.Atoi(matches[3]) if err != nil { - m.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("Failed to calculate days: %v", err)) - return nil, err + return nil, fmt.Errorf("invalid year: %s", matches[3]) } - - return &core.Value{ - Amount: decimal.NewFromInt(int64(days)), - Unit: "days", - }, nil } - m.api.Log(ctx, plugin.LogLevelDebug, "No pattern matched") - return nil, fmt.Errorf("unsupported time format") -} - -func (m *TimeModule) Calculate(ctx context.Context, tokens []core.Token) (*core.Value, error) { - return m.Parse(ctx, tokens) -} - -func (m *TimeModule) Convert(ctx context.Context, value *core.Value, toUnit string) (*core.Value, error) { - return nil, fmt.Errorf("time module does not support conversion") -} - -func (m *TimeModule) CanConvertTo(unit string) bool { - return false -} + // Create target date + targetDate := time.Date(year, month, day, 0, 0, 0, 0, time.Local) + now := time.Now() -var timeZoneAliases = map[string]string{ - "tokyo": "Asia/Tokyo", - "beijing": "Asia/Shanghai", - "shanghai": "Asia/Shanghai", - "london": "Europe/London", - "paris": "Europe/Paris", - "new york": "America/New_York", - "nyc": "America/New_York", - "la": "America/Los_Angeles", - "sydney": "Australia/Sydney", - "singapore": "Asia/Singapore", - "hong kong": "Asia/Hong_Kong", - "berlin": "Europe/Berlin", - "moscow": "Europe/Moscow", - "dubai": "Asia/Dubai", - "seoul": "Asia/Seoul", - "bangkok": "Asia/Bangkok", - "vancouver": "America/Vancouver", - "toronto": "America/Toronto", - "sao paulo": "America/Sao_Paulo", - "melbourne": "Australia/Melbourne", - "japan": "Asia/Tokyo", - "china": "Asia/Shanghai", - "korea": "Asia/Seoul", - "uk": "Europe/London", - "us": "America/New_York", -} + // If the target date is in the past and no year was specified, use next year + if targetDate.Before(now) && len(matches) <= 3 { + targetDate = targetDate.AddDate(1, 0, 0) + } -var weekdayMap = map[string]time.Weekday{ - "sunday": time.Sunday, - "monday": time.Monday, - "tuesday": time.Tuesday, - "wednesday": time.Wednesday, - "thursday": time.Thursday, - "friday": time.Friday, - "saturday": time.Saturday, + // Calculate days until target date + days := int(targetDate.Sub(now).Hours() / 24) + val := decimal.NewFromInt(int64(days)) + displayValue := fmt.Sprintf("%d days", days) + return &core.Result{ + DisplayValue: displayValue, + RawValue: &val, + Unit: "days", + }, nil } -var monthMap = map[string]time.Month{ - "jan": time.January, - "feb": time.February, - "mar": time.March, - "apr": time.April, - "may": time.May, - "jun": time.June, - "jul": time.July, - "aug": time.August, - "sep": time.September, - "oct": time.October, - "nov": time.November, - "dec": time.December, -} +func (m *TimeModule) parseTime(ctx context.Context, timeStr string) (time.Time, error) { + timeStr = strings.ToLower(strings.TrimSpace(timeStr)) -func parseTime(ctx context.Context, timeStr string) (time.Time, error) { + // Get current time as base now := time.Now() - timeStr = strings.ToLower(strings.TrimSpace(timeStr)) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + // Try parsing with AM/PM format first formats := []string{ + "3:04 pm", "3:04pm", "3pm", + "3 pm", "15:04", } for _, format := range formats { if t, err := time.Parse(format, timeStr); err == nil { - return time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), 0, 0, time.Local), nil + // Use the parsed hour and minute, but keep today's date and local timezone + return time.Date(today.Year(), today.Month(), today.Day(), t.Hour(), t.Minute(), 0, 0, today.Location()), nil } } - return time.Time{}, fmt.Errorf("invalid time format: %s", timeStr) + return time.Time{}, fmt.Errorf("unsupported time format: %s", timeStr) } -func calculateNextWeekday(ctx context.Context, weekday time.Weekday, weeks int) time.Time { - now := time.Now() - current := now - - // Find the next occurrence of the specified weekday - daysUntilNext := (int(weekday) - int(current.Weekday()) + 7) % 7 - if daysUntilNext == 0 { - daysUntilNext = 7 - } - - // Add the specified number of weeks - return current.AddDate(0, 0, daysUntilNext+(weeks-1)*7) -} - -func calculateDaysUntil(ctx context.Context, day int, month time.Month, year int) (int, error) { - now := time.Now() - if year == 0 { - year = now.Year() - // If target date is before current date, use next year - if month < now.Month() || (month == now.Month() && day < now.Day()) { - year++ +func (m *TimeModule) formatTimeForDisplay(t time.Time) string { + hour := t.Hour() + ampm := "AM" + if hour >= 12 { + ampm = "PM" + if hour > 12 { + hour -= 12 } } - - target := time.Date(year, month, day, 0, 0, 0, 0, time.Local) - days := int(target.Sub(now).Hours() / 24) - return days, nil -} - -func normalizeUnit(ctx context.Context, unit string) string { - // Remove spaces and plural 's' - unit = strings.TrimSpace(unit) - unit = strings.TrimSuffix(unit, "s") - return unit + if hour == 0 { + hour = 12 + } + return fmt.Sprintf("%d:%02d %s", hour, t.Minute(), ampm) }