feat(core): implement complete dumb browser CLI with fuzzy search and learning behavior
- Add comprehensive CLI structure with browse, history, init, and dmenu commands - Implement smart URL parser with direct URL detection, search shortcuts, and fuzzy history matching - Create config system with JSON format, env var support, Viper live reload, and XDG compliance - Build fuzzy finding algorithm with Jaro-Winkler similarity and intelligent ranking - Add learning behavior: search shortcuts populate history for future fuzzy matching - Implement dmenu/rofi integration with stdin/stdout protocol - Create type-safe database operations with SQLC-generated code - Support XDG directories: config (~/.config/dumber), state (~/.local/state/dumber) - Add comprehensive test coverage for fuzzy matching and URL validation algorithms Core functionality complete and ready for Wails browser integration
- commit
- f17cf684891fb043c1eee779cdc245a4f0caee4d
- author
- bamen · · 2025-09-08 16:45 UTC
- committer
- bamen · · 2025-09-08 16:34 UTC
- parent
- bcef92d
- signature
- unsigned
31 files · +5256 -110
M .gitignore +2 -2
| @@ -7,8 +7,8 @@ dist/ | |||||
| 7 | *.dylib | 7 | *.dylib | ||
| 8 | 8 | ||||
| 9 | # Binaries for programs and plugins | 9 | # Binaries for programs and plugins | ||
| 10 | - | dumb-browser | 10 | + | dumber |
| 11 | - | dumb-browser.exe | 11 | + | dumber.exe |
| 12 | 12 | ||||
| 13 | # Test binary, built with `go test -c` | 13 | # Test binary, built with `go test -c` | ||
| 14 | *.test | 14 | *.test | ||
M Makefile +3 -3
| @@ -1,10 +1,10 @@ | |||||
| 1 | - | # Makefile for dumb-browser | 1 | + | # Makefile for dumber |
| 2 | 2 | ||||
| 3 | .PHONY: build test lint clean install-tools dev generate help | 3 | .PHONY: build test lint clean install-tools dev generate help | ||
| 4 | 4 | ||||
| 5 | # Variables | 5 | # Variables | ||
| 6 | - | BINARY_NAME=dumb-browser | 6 | + | BINARY_NAME=dumber |
| 7 | - | MAIN_PATH=./cmd/dumb-browser | 7 | + | MAIN_PATH=./cmd/dumber |
| 8 | DIST_DIR=dist | 8 | DIST_DIR=dist | ||
| 9 | 9 | ||||
| 10 | # Version information from git | 10 | # Version information from git | ||
A cmd/dumb-browser/main.go +62 -0
| @@ -0,0 +1,62 @@ | |||||
| 1 | + | // Package main provides the main entry point for dumb-browser CLI application. | |||
| 2 | + | package main | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/cli" | |||
| 9 | + | "github.com/bnema/dumber/internal/config" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Build information set via ldflags | |||
| 13 | + | var ( | |||
| 14 | + | version = "dev" | |||
| 15 | + | commit = "none" | |||
| 16 | + | buildDate = "unknown" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func main() { | |||
| 20 | + | // Initialize configuration system | |||
| 21 | + | if err := config.Init(); err != nil { | |||
| 22 | + | fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err) | |||
| 23 | + | os.Exit(1) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // Start configuration watching for live reload | |||
| 27 | + | if err := config.Watch(); err != nil { | |||
| 28 | + | // Don't exit on watch error, just warn | |||
| 29 | + | fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | rootCmd := cli.NewRootCmd(version, commit, buildDate) | |||
| 33 | + | ||||
| 34 | + | // Handle dmenu flag at the root level for direct integration | |||
| 35 | + | if len(os.Args) > 1 && os.Args[1] == "--dmenu" { | |||
| 36 | + | // Create a temporary CLI instance to handle dmenu mode | |||
| 37 | + | cliInstance, err := cli.NewCLI() | |||
| 38 | + | if err != nil { | |||
| 39 | + | fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err) | |||
| 40 | + | os.Exit(1) | |||
| 41 | + | } | |||
| 42 | + | defer func() { | |||
| 43 | + | if closeErr := cliInstance.Close(); closeErr != nil { | |||
| 44 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |||
| 45 | + | } | |||
| 46 | + | }() | |||
| 47 | + | ||||
| 48 | + | // Generate dmenu options | |||
| 49 | + | dmenuCmd := cli.NewDmenuCmd() | |||
| 50 | + | if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil { | |||
| 51 | + | fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err) | |||
| 52 | + | os.Exit(1) | |||
| 53 | + | } | |||
| 54 | + | return | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // Execute the root command | |||
| 58 | + | if err := rootCmd.Execute(); err != nil { | |||
| 59 | + | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | |||
| 60 | + | os.Exit(1) | |||
| 61 | + | } | |||
| 62 | + | }⏎̸ | |||
A dumb-browser +0 -0 binary file: no text diff
binary file: no text diff
M go.mod +8 -13
| @@ -1,32 +1,27 @@ | |||||
| 1 | - | module dumb-browser | 1 | + | module github.com/bnema/dumber |
| 2 | 2 | ||||
| 3 | go 1.25.1 | 3 | go 1.25.1 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/fsnotify/fsnotify v1.9.0 // indirect | 6 | + | github.com/fsnotify/fsnotify v1.9.0 |
| 7 | - | github.com/gabriel-vasile/mimetype v1.4.8 // indirect | 7 | + | github.com/ncruces/go-sqlite3 v0.29.0 |
| 8 | - | github.com/go-playground/locales v0.14.1 // indirect | 8 | + | github.com/spf13/cobra v1.10.1 |
| 9 | - | github.com/go-playground/universal-translator v0.18.1 // indirect | 9 | + | github.com/spf13/viper v1.21.0 |
| 10 | - | github.com/go-playground/validator/v10 v10.27.0 // indirect | 10 | + | ) |
| 11 | + | ||||
| 12 | + | require ( | |||
| 11 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | 13 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | ||
| 12 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 14 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 13 | - | github.com/leodido/go-urn v1.4.0 // indirect | |||
| 14 | - | github.com/ncruces/go-sqlite3 v0.29.0 // indirect | |||
| 15 | github.com/ncruces/julianday v1.0.0 // indirect | 15 | github.com/ncruces/julianday v1.0.0 // indirect | ||
| 16 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | 16 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | ||
| 17 | github.com/sagikazarmark/locafero v0.11.0 // indirect | 17 | github.com/sagikazarmark/locafero v0.11.0 // indirect | ||
| 18 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | 18 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | ||
| 19 | github.com/spf13/afero v1.15.0 // indirect | 19 | github.com/spf13/afero v1.15.0 // indirect | ||
| 20 | github.com/spf13/cast v1.10.0 // indirect | 20 | github.com/spf13/cast v1.10.0 // indirect | ||
| 21 | - | github.com/spf13/cobra v1.10.1 // indirect | |||
| 22 | github.com/spf13/pflag v1.0.10 // indirect | 21 | github.com/spf13/pflag v1.0.10 // indirect | ||
| 23 | - | github.com/spf13/viper v1.21.0 // indirect | |||
| 24 | - | github.com/stretchr/testify v1.11.1 // indirect | |||
| 25 | github.com/subosito/gotenv v1.6.0 // indirect | 22 | github.com/subosito/gotenv v1.6.0 // indirect | ||
| 26 | github.com/tetratelabs/wazero v1.9.0 // indirect | 23 | github.com/tetratelabs/wazero v1.9.0 // indirect | ||
| 27 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 24 | go.yaml.in/yaml/v3 v3.0.4 // indirect | ||
| 28 | - | golang.org/x/crypto v0.41.0 // indirect | |||
| 29 | - | golang.org/x/net v0.42.0 // indirect | |||
| 30 | golang.org/x/sys v0.36.0 // indirect | 25 | golang.org/x/sys v0.36.0 // indirect | ||
| 31 | golang.org/x/text v0.29.0 // indirect | 26 | golang.org/x/text v0.29.0 // indirect | ||
| 32 | ) | 27 | ) | ||
M go.sum +17 -18 generated file: collapsed
A internal/cli/browse.go +196 -0
| @@ -0,0 +1,196 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "database/sql" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net/url" | |||
| 8 | + | "os" | |||
| 9 | + | "os/exec" | |||
| 10 | + | "regexp" | |||
| 11 | + | "runtime" | |||
| 12 | + | "strings" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/dumber/internal/config" | |||
| 15 | + | ||||
| 16 | + | "github.com/spf13/cobra" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // NewBrowseCmd creates the browse command | |||
| 20 | + | func NewBrowseCmd() *cobra.Command { | |||
| 21 | + | cmd := &cobra.Command{ | |||
| 22 | + | Use: "browse <url|query>", | |||
| 23 | + | Short: "Browse a URL or search query", | |||
| 24 | + | Long: `Browse a URL directly or use search shortcuts like: | |||
| 25 | + | g:golang -> Google search for "golang" | |||
| 26 | + | gh:cobra -> GitHub search for "cobra" | |||
| 27 | + | yt:tutorials -> YouTube search for "tutorials" | |||
| 28 | + | w:go language -> Wikipedia search for "go language" | |||
| 29 | + | ||||
| 30 | + | Direct URLs are also supported: | |||
| 31 | + | https://example.com | |||
| 32 | + | example.com (automatically adds https://)`, | |||
| 33 | + | Args: cobra.ExactArgs(1), | |||
| 34 | + | RunE: func(_ *cobra.Command, args []string) error { | |||
| 35 | + | cli, err := NewCLI() | |||
| 36 | + | if err != nil { | |||
| 37 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 38 | + | } | |||
| 39 | + | defer func() { | |||
| 40 | + | if closeErr := cli.Close(); closeErr != nil { | |||
| 41 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |||
| 42 | + | } | |||
| 43 | + | }() | |||
| 44 | + | ||||
| 45 | + | return browse(cli, args[0]) | |||
| 46 | + | }, | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | return cmd | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // browse handles the core browsing logic | |||
| 53 | + | func browse(cli *CLI, input string) error { | |||
| 54 | + | ctx := context.Background() | |||
| 55 | + | ||||
| 56 | + | // Parse the input to determine URL or shortcut | |||
| 57 | + | finalURL, err := parseInput(ctx, cli, input) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return fmt.Errorf("failed to parse input: %w", err) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // Record in history | |||
| 63 | + | if err := recordVisit(ctx, cli, finalURL, ""); err != nil { | |||
| 64 | + | // Don't fail the browse operation if history recording fails | |||
| 65 | + | fmt.Fprintf(os.Stderr, "Warning: failed to record history: %v\n", err) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // Open URL using configuration | |||
| 69 | + | return openURLWithConfig(finalURL, cli.Config) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // parseInput processes user input and returns the final URL to browse | |||
| 73 | + | func parseInput(ctx context.Context, cli *CLI, input string) (string, error) { | |||
| 74 | + | // Check if it's a shortcut (format: "prefix:query") | |||
| 75 | + | if strings.Contains(input, ":") && !strings.Contains(input, "://") { | |||
| 76 | + | parts := strings.SplitN(input, ":", 2) //nolint:mnd // split on first colon only | |||
| 77 | + | if len(parts) == 2 { //nolint:mnd // expect prefix and query parts | |||
| 78 | + | shortcut := strings.TrimSpace(parts[0]) | |||
| 79 | + | query := strings.TrimSpace(parts[1]) | |||
| 80 | + | ||||
| 81 | + | if query == "" { | |||
| 82 | + | return "", fmt.Errorf("empty query for shortcut '%s'", shortcut) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // First check configuration-based shortcuts | |||
| 86 | + | if shortcutCfg, exists := cli.Config.SearchShortcuts[shortcut]; exists { | |||
| 87 | + | return fmt.Sprintf(shortcutCfg.URL, url.QueryEscape(query)), nil | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // Fallback to database shortcuts for backward compatibility | |||
| 91 | + | shortcuts, err := cli.Queries.GetShortcuts(ctx) | |||
| 92 | + | if err != nil { | |||
| 93 | + | return "", fmt.Errorf("failed to get shortcuts: %w", err) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | for _, s := range shortcuts { | |||
| 97 | + | if s.Shortcut == shortcut { | |||
| 98 | + | return fmt.Sprintf(s.UrlTemplate, url.QueryEscape(query)), nil | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | return "", fmt.Errorf("unknown shortcut '%s'", shortcut) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // Check if it's already a valid URL | |||
| 107 | + | if isValidURL(input) { | |||
| 108 | + | return input, nil | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // Try to make it a URL by adding https:// | |||
| 112 | + | if !strings.Contains(input, "://") { | |||
| 113 | + | candidate := "https://" + input | |||
| 114 | + | if isValidURL(candidate) { | |||
| 115 | + | return candidate, nil | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // If all else fails, search Google for it | |||
| 120 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", url.QueryEscape(input)), nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // isValidURL checks if a string is a valid URL | |||
| 124 | + | func isValidURL(str string) bool { | |||
| 125 | + | u, err := url.Parse(str) | |||
| 126 | + | if err != nil { | |||
| 127 | + | return false | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // Must have a scheme and host | |||
| 131 | + | if u.Scheme == "" || u.Host == "" { | |||
| 132 | + | return false | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // Must be http or https | |||
| 136 | + | if u.Scheme != "http" && u.Scheme != "https" { | |||
| 137 | + | return false | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Basic domain validation | |||
| 141 | + | domainRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?` + | |||
| 142 | + | `(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) | |||
| 143 | + | return domainRegex.MatchString(u.Host) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // recordVisit adds or updates a URL visit in the history | |||
| 147 | + | func recordVisit(ctx context.Context, cli *CLI, url, title string) error { | |||
| 148 | + | var sqlTitle sql.NullString | |||
| 149 | + | if title != "" { | |||
| 150 | + | sqlTitle = sql.NullString{String: title, Valid: true} | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | return cli.Queries.AddOrUpdateHistory(ctx, url, sqlTitle) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // openURL opens a URL using the configured browser | |||
| 157 | + | func openURL(url string) error { | |||
| 158 | + | return openURLWithConfig(url, nil) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // openURLWithConfig opens a URL using system default browser | |||
| 162 | + | // TODO: Replace with Wails browser integration | |||
| 163 | + | func openURLWithConfig(url string, _ *config.Config) error { | |||
| 164 | + | var cmd *exec.Cmd | |||
| 165 | + | ||||
| 166 | + | // Use system default browser until Wails integration is ready | |||
| 167 | + | switch runtime.GOOS { | |||
| 168 | + | case "linux": | |||
| 169 | + | cmd = exec.Command("xdg-open", url) | |||
| 170 | + | case "darwin": | |||
| 171 | + | cmd = exec.Command("open", url) | |||
| 172 | + | case "windows": | |||
| 173 | + | cmd = exec.Command("cmd", "/c", "start", "", url) | |||
| 174 | + | default: | |||
| 175 | + | return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // Start the browser in detached mode | |||
| 179 | + | if err := cmd.Start(); err != nil { | |||
| 180 | + | return fmt.Errorf("failed to open URL in browser: %w", err) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // Don't wait for the browser process to exit | |||
| 184 | + | go func() { | |||
| 185 | + | _ = cmd.Wait() | |||
| 186 | + | }() | |||
| 187 | + | ||||
| 188 | + | fmt.Printf("Opening: %s (using system default browser)\n", url) | |||
| 189 | + | return nil | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // commandExists checks if a command is available in the system PATH | |||
| 193 | + | func commandExists(cmd string) bool { | |||
| 194 | + | _, err := exec.LookPath(cmd) | |||
| 195 | + | return err == nil | |||
| 196 | + | } | |||
A internal/cli/dmenu.go +218 -0
| @@ -0,0 +1,218 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "sort" | |||
| 9 | + | "strings" | |||
| 10 | + | ||||
| 11 | + | "github.com/spf13/cobra" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewDmenuCmd creates the dmenu command for launcher integration | |||
| 15 | + | func NewDmenuCmd() *cobra.Command { | |||
| 16 | + | cmd := &cobra.Command{ | |||
| 17 | + | Use: "dmenu", | |||
| 18 | + | Short: "Run in dmenu mode for launcher integration", | |||
| 19 | + | Long: `Run in dmenu mode to integrate with rofi, dmenu, or other launchers. | |||
| 20 | + | This mode reads from stdin and outputs selectable options to stdout. | |||
| 21 | + | ||||
| 22 | + | Usage with rofi: | |||
| 23 | + | dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select | |||
| 24 | + | ||||
| 25 | + | Usage with dmenu: | |||
| 26 | + | dumber dmenu | dmenu -p "Browse: " | dumber dmenu --select`, | |||
| 27 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |||
| 28 | + | selectFlag := cmd.Flag("select").Changed | |||
| 29 | + | ||||
| 30 | + | cli, err := NewCLI() | |||
| 31 | + | if err != nil { | |||
| 32 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 33 | + | } | |||
| 34 | + | defer func() { | |||
| 35 | + | if closeErr := cli.Close(); closeErr != nil { | |||
| 36 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |||
| 37 | + | } | |||
| 38 | + | }() | |||
| 39 | + | ||||
| 40 | + | if selectFlag { | |||
| 41 | + | return handleSelection(cli) | |||
| 42 | + | } | |||
| 43 | + | return generateOptions(cli) | |||
| 44 | + | }, | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | cmd.Flags().Bool("select", false, "Process selection from launcher (reads from stdin)") | |||
| 48 | + | return cmd | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // generateOptions outputs all available options for the launcher | |||
| 52 | + | func generateOptions(cli *CLI) error { | |||
| 53 | + | ctx := context.Background() | |||
| 54 | + | ||||
| 55 | + | const maxHistoryEntries = 50 | |||
| 56 | + | // Get history entries | |||
| 57 | + | history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return fmt.Errorf("failed to get history: %w", err) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // Get shortcuts | |||
| 63 | + | shortcuts, err := cli.Queries.GetShortcuts(ctx) | |||
| 64 | + | if err != nil { | |||
| 65 | + | return fmt.Errorf("failed to get shortcuts: %w", err) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // Generate options | |||
| 69 | + | options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1) | |||
| 70 | + | ||||
| 71 | + | // Add direct URL input option | |||
| 72 | + | options = append(options, DmenuOption{ | |||
| 73 | + | Display: "🌐 Enter URL or search query...", | |||
| 74 | + | Value: "", | |||
| 75 | + | Type: "input", | |||
| 76 | + | Description: "Type any URL or search term", | |||
| 77 | + | }) | |||
| 78 | + | ||||
| 79 | + | // Add shortcuts with examples | |||
| 80 | + | for _, shortcut := range shortcuts { | |||
| 81 | + | desc := shortcut.Description.String | |||
| 82 | + | if !shortcut.Description.Valid { | |||
| 83 | + | desc = "Custom shortcut" | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | options = append(options, DmenuOption{ | |||
| 87 | + | Display: fmt.Sprintf("🔍 %s: (%s)", shortcut.Shortcut, desc), | |||
| 88 | + | Value: shortcut.Shortcut + ":", | |||
| 89 | + | Type: "shortcut", | |||
| 90 | + | Description: desc, | |||
| 91 | + | }) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // Add history entries | |||
| 95 | + | for _, entry := range history { | |||
| 96 | + | title := entry.Url | |||
| 97 | + | if entry.Title.Valid && entry.Title.String != "" { | |||
| 98 | + | title = entry.Title.String | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | const maxDisplayLength = 80 | |||
| 102 | + | // Truncate long titles/URLs for display | |||
| 103 | + | display := truncateString(title, maxDisplayLength) | |||
| 104 | + | ||||
| 105 | + | options = append(options, DmenuOption{ | |||
| 106 | + | Display: fmt.Sprintf("🕒 %s", display), | |||
| 107 | + | Value: entry.Url, | |||
| 108 | + | Type: "history", | |||
| 109 | + | Description: entry.Url, | |||
| 110 | + | }) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // Sort options: input first, then shortcuts, then history by visit count/recency | |||
| 114 | + | sort.Slice(options, func(i, j int) bool { | |||
| 115 | + | if options[i].Type != options[j].Type { | |||
| 116 | + | // Order: input, shortcut, history | |||
| 117 | + | typeOrder := map[string]int{"input": 0, "shortcut": 1, "history": 2} //nolint:mnd | |||
| 118 | + | return typeOrder[options[i].Type] < typeOrder[options[j].Type] | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | if options[i].Type == "history" && options[j].Type == "history" { | |||
| 122 | + | // History already sorted by recency from database | |||
| 123 | + | return i < j | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | return options[i].Display < options[j].Display | |||
| 127 | + | }) | |||
| 128 | + | ||||
| 129 | + | // Output options to stdout | |||
| 130 | + | for _, option := range options { | |||
| 131 | + | fmt.Println(option.Display) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | return nil | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // handleSelection processes the user's selection from the launcher | |||
| 138 | + | func handleSelection(cli *CLI) error { | |||
| 139 | + | scanner := bufio.NewScanner(os.Stdin) | |||
| 140 | + | if !scanner.Scan() { | |||
| 141 | + | return fmt.Errorf("no input received") | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | selection := strings.TrimSpace(scanner.Text()) | |||
| 145 | + | if selection == "" { | |||
| 146 | + | return fmt.Errorf("empty selection") | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Parse the selection to get the actual input | |||
| 150 | + | input := parseSelection(selection) | |||
| 151 | + | ||||
| 152 | + | // If it's just the input prompt, read another line for the actual input | |||
| 153 | + | if input == "" || input == "Enter URL or search query..." { | |||
| 154 | + | fmt.Print("Enter URL or search query: ") | |||
| 155 | + | if !scanner.Scan() { | |||
| 156 | + | return fmt.Errorf("no input received") | |||
| 157 | + | } | |||
| 158 | + | input = strings.TrimSpace(scanner.Text()) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | if input == "" { | |||
| 162 | + | return fmt.Errorf("empty input") | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // Browse the selected/entered URL | |||
| 166 | + | return browse(cli, input) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // DmenuOption represents a selectable option in dmenu mode | |||
| 170 | + | type DmenuOption struct { | |||
| 171 | + | Display string | |||
| 172 | + | Value string | |||
| 173 | + | Type string // "input", "shortcut", "history" | |||
| 174 | + | Description string | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // parseSelection extracts the actual URL/query from a dmenu selection | |||
| 178 | + | func parseSelection(selection string) string { | |||
| 179 | + | // Remove emoji prefixes and clean up the selection | |||
| 180 | + | selection = strings.TrimSpace(selection) | |||
| 181 | + | ||||
| 182 | + | // Handle different option types | |||
| 183 | + | if strings.HasPrefix(selection, "🌐 ") { | |||
| 184 | + | // Input option selected | |||
| 185 | + | return "" | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | if strings.HasPrefix(selection, "🔍 ") { | |||
| 189 | + | // Shortcut selected - extract the shortcut prefix | |||
| 190 | + | rest := strings.TrimPrefix(selection, "🔍 ") | |||
| 191 | + | if idx := strings.Index(rest, ":"); idx > 0 { | |||
| 192 | + | return rest[:idx+1] // Return "g:" format | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | if strings.HasPrefix(selection, "🕒 ") { | |||
| 197 | + | // History entry selected - this is the display title, need to extract URL | |||
| 198 | + | // For now, we'll assume the display is the URL or close enough | |||
| 199 | + | return strings.TrimPrefix(selection, "🕒 ") | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // Fallback: return the selection as-is | |||
| 203 | + | return selection | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // truncateString truncates a string to the specified length with ellipsis | |||
| 207 | + | func truncateString(s string, maxLen int) string { | |||
| 208 | + | if len(s) <= maxLen { | |||
| 209 | + | return s | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | const minEllipsisLength = 3 | |||
| 213 | + | if maxLen <= minEllipsisLength { | |||
| 214 | + | return s[:maxLen] | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | return s[:maxLen-minEllipsisLength] + "..." | |||
| 218 | + | } | |||
A internal/cli/history.go +380 -0
| @@ -0,0 +1,380 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "database/sql" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "strconv" | |||
| 9 | + | "strings" | |||
| 10 | + | "text/tabwriter" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/spf13/cobra" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | unknownValue = "Unknown" | |||
| 18 | + | defaultHistoryLimit = 20 | |||
| 19 | + | defaultSearchLimit = 10 | |||
| 20 | + | maxURLDisplay = 50 | |||
| 21 | + | maxTitleDisplay = 40 | |||
| 22 | + | maxURLCompact = 60 | |||
| 23 | + | maxTitleCompact = 30 | |||
| 24 | + | tabSpacing = 2 | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // NewHistoryCmd creates the history command | |||
| 28 | + | func NewHistoryCmd() *cobra.Command { | |||
| 29 | + | cmd := &cobra.Command{ | |||
| 30 | + | Use: "history", | |||
| 31 | + | Short: "Manage browsing history", | |||
| 32 | + | Long: `Manage browsing history with various subcommands: | |||
| 33 | + | list - Show recent browsing history | |||
| 34 | + | search - Search through history | |||
| 35 | + | clear - Clear history (with confirmation)`, | |||
| 36 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 37 | + | // Default to list if no subcommand | |||
| 38 | + | return listHistory(cmd, args) | |||
| 39 | + | }, | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // List subcommand (also default behavior) | |||
| 43 | + | listCmd := &cobra.Command{ | |||
| 44 | + | Use: "list", | |||
| 45 | + | Short: "List recent browsing history", | |||
| 46 | + | Long: `Display recent browsing history with visit counts and timestamps.`, | |||
| 47 | + | RunE: listHistory, | |||
| 48 | + | } | |||
| 49 | + | listCmd.Flags().IntP("limit", "n", defaultHistoryLimit, "Number of history entries to show") | |||
| 50 | + | listCmd.Flags().BoolP("verbose", "v", false, "Show detailed information") | |||
| 51 | + | ||||
| 52 | + | // Search subcommand | |||
| 53 | + | searchCmd := &cobra.Command{ | |||
| 54 | + | Use: "search <query>", | |||
| 55 | + | Short: "Search through browsing history", | |||
| 56 | + | Long: `Search for URLs and titles in browsing history.`, | |||
| 57 | + | Args: cobra.ExactArgs(1), | |||
| 58 | + | RunE: searchHistory, | |||
| 59 | + | } | |||
| 60 | + | searchCmd.Flags().IntP("limit", "n", defaultSearchLimit, "Number of search results to show") | |||
| 61 | + | ||||
| 62 | + | // Clear subcommand | |||
| 63 | + | clearCmd := &cobra.Command{ | |||
| 64 | + | Use: "clear", | |||
| 65 | + | Short: "Clear browsing history", | |||
| 66 | + | Long: `Clear all browsing history. This action cannot be undone.`, | |||
| 67 | + | RunE: clearHistory, | |||
| 68 | + | } | |||
| 69 | + | clearCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") | |||
| 70 | + | ||||
| 71 | + | // Stats subcommand | |||
| 72 | + | statsCmd := &cobra.Command{ | |||
| 73 | + | Use: "stats", | |||
| 74 | + | Short: "Show history statistics", | |||
| 75 | + | Long: `Display statistics about your browsing history.`, | |||
| 76 | + | RunE: showStats, | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Add global flags | |||
| 80 | + | cmd.Flags().IntP("limit", "n", defaultHistoryLimit, "Number of history entries to show") | |||
| 81 | + | cmd.Flags().BoolP("verbose", "v", false, "Show detailed information") | |||
| 82 | + | ||||
| 83 | + | // Add subcommands | |||
| 84 | + | cmd.AddCommand(listCmd) | |||
| 85 | + | cmd.AddCommand(searchCmd) | |||
| 86 | + | cmd.AddCommand(clearCmd) | |||
| 87 | + | cmd.AddCommand(statsCmd) | |||
| 88 | + | ||||
| 89 | + | return cmd | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // listHistory displays recent browsing history | |||
| 93 | + | func listHistory(cmd *cobra.Command, _ []string) error { | |||
| 94 | + | cli, err := NewCLI() | |||
| 95 | + | if err != nil { | |||
| 96 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 97 | + | } | |||
| 98 | + | defer func() { | |||
| 99 | + | if err := cli.Close(); err != nil { | |||
| 100 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err) | |||
| 101 | + | } | |||
| 102 | + | }() | |||
| 103 | + | ||||
| 104 | + | ctx := context.Background() | |||
| 105 | + | ||||
| 106 | + | limit, _ := cmd.Flags().GetInt("limit") | |||
| 107 | + | verbose, _ := cmd.Flags().GetBool("verbose") | |||
| 108 | + | ||||
| 109 | + | history, err := cli.Queries.GetHistory(ctx, int64(limit)) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return fmt.Errorf("failed to get history: %w", err) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | if len(history) == 0 { | |||
| 115 | + | fmt.Println("No browsing history found.") | |||
| 116 | + | return nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Create tabwriter for aligned output | |||
| 120 | + | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |||
| 121 | + | defer w.Flush() | |||
| 122 | + | ||||
| 123 | + | if verbose { | |||
| 124 | + | fmt.Fprintln(w, "ID\tVISITS\tLAST VISITED\tURL\tTITLE") | |||
| 125 | + | fmt.Fprintln(w, "--\t------\t------------\t---\t-----") | |||
| 126 | + | ||||
| 127 | + | for _, entry := range history { | |||
| 128 | + | visits := "1" | |||
| 129 | + | if entry.VisitCount.Valid { | |||
| 130 | + | visits = strconv.FormatInt(entry.VisitCount.Int64, 10) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | lastVisited := unknownValue | |||
| 134 | + | if entry.LastVisited.Valid { | |||
| 135 | + | lastVisited = entry.LastVisited.Time.Format("2006-01-02 15:04") | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | title := entry.Title.String | |||
| 139 | + | if !entry.Title.Valid || title == "" { | |||
| 140 | + | title = "-" | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | url := truncateString(entry.Url, 50) | |||
| 144 | + | title = truncateString(title, 40) | |||
| 145 | + | ||||
| 146 | + | fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", | |||
| 147 | + | entry.ID, visits, lastVisited, url, title) | |||
| 148 | + | } | |||
| 149 | + | } else { | |||
| 150 | + | fmt.Fprintln(w, "VISITS\tLAST VISITED\tURL") | |||
| 151 | + | fmt.Fprintln(w, "------\t------------\t---") | |||
| 152 | + | ||||
| 153 | + | for _, entry := range history { | |||
| 154 | + | visits := "1" | |||
| 155 | + | if entry.VisitCount.Valid { | |||
| 156 | + | visits = strconv.FormatInt(entry.VisitCount.Int64, 10) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | lastVisited := unknownValue | |||
| 160 | + | if entry.LastVisited.Valid { | |||
| 161 | + | // Show relative time for recent entries | |||
| 162 | + | if time.Since(entry.LastVisited.Time) < 24*time.Hour { | |||
| 163 | + | lastVisited = entry.LastVisited.Time.Format("15:04") | |||
| 164 | + | } else { | |||
| 165 | + | lastVisited = entry.LastVisited.Time.Format("Jan 02") | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | url := entry.Url | |||
| 170 | + | if len(url) > 60 { | |||
| 171 | + | url = url[:57] + "..." | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | fmt.Fprintf(w, "%s\t%s\t%s\n", visits, lastVisited, url) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | return nil | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // searchHistory searches through browsing history | |||
| 182 | + | func searchHistory(cmd *cobra.Command, args []string) error { | |||
| 183 | + | cli, err := NewCLI() | |||
| 184 | + | if err != nil { | |||
| 185 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 186 | + | } | |||
| 187 | + | defer func() { | |||
| 188 | + | if err := cli.Close(); err != nil { | |||
| 189 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err) | |||
| 190 | + | } | |||
| 191 | + | }() | |||
| 192 | + | ||||
| 193 | + | ctx := context.Background() | |||
| 194 | + | query := args[0] | |||
| 195 | + | limit, _ := cmd.Flags().GetInt("limit") | |||
| 196 | + | ||||
| 197 | + | // Search both URL and title | |||
| 198 | + | searchQuery := sql.NullString{String: query, Valid: true} | |||
| 199 | + | results, err := cli.Queries.SearchHistory(ctx, searchQuery, searchQuery, int64(limit)) | |||
| 200 | + | if err != nil { | |||
| 201 | + | return fmt.Errorf("failed to search history: %w", err) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | if len(results) == 0 { | |||
| 205 | + | fmt.Printf("No history entries found matching '%s'.\n", query) | |||
| 206 | + | return nil | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | fmt.Printf("Found %d result(s) for '%s':\n\n", len(results), query) | |||
| 210 | + | ||||
| 211 | + | // Create tabwriter for aligned output | |||
| 212 | + | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |||
| 213 | + | defer w.Flush() | |||
| 214 | + | ||||
| 215 | + | fmt.Fprintln(w, "VISITS\tLAST VISITED\tURL\tTITLE") | |||
| 216 | + | fmt.Fprintln(w, "------\t------------\t---\t-----") | |||
| 217 | + | ||||
| 218 | + | for _, entry := range results { | |||
| 219 | + | visits := "1" | |||
| 220 | + | if entry.VisitCount.Valid { | |||
| 221 | + | visits = strconv.FormatInt(entry.VisitCount.Int64, 10) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | lastVisited := "Unknown" | |||
| 225 | + | if entry.LastVisited.Valid { | |||
| 226 | + | lastVisited = entry.LastVisited.Time.Format("Jan 02 15:04") | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | title := entry.Title.String | |||
| 230 | + | if !entry.Title.Valid || title == "" { | |||
| 231 | + | title = "-" | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | url := truncateString(entry.Url, 50) | |||
| 235 | + | title = truncateString(title, 30) | |||
| 236 | + | ||||
| 237 | + | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", visits, lastVisited, url, title) | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | return nil | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // clearHistory clears all browsing history | |||
| 244 | + | func clearHistory(cmd *cobra.Command, _ []string) error { | |||
| 245 | + | force, _ := cmd.Flags().GetBool("force") | |||
| 246 | + | ||||
| 247 | + | // Confirmation unless --force is used | |||
| 248 | + | if !force { | |||
| 249 | + | fmt.Print("This will permanently delete all browsing history. Continue? [y/N]: ") | |||
| 250 | + | var response string | |||
| 251 | + | _, _ = fmt.Scanln(&response) | |||
| 252 | + | response = strings.ToLower(strings.TrimSpace(response)) | |||
| 253 | + | ||||
| 254 | + | if response != "y" && response != "yes" { | |||
| 255 | + | fmt.Println("Operation cancelled.") | |||
| 256 | + | return nil | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | cli, err := NewCLI() | |||
| 261 | + | if err != nil { | |||
| 262 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 263 | + | } | |||
| 264 | + | defer func() { | |||
| 265 | + | if err := cli.Close(); err != nil { | |||
| 266 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err) | |||
| 267 | + | } | |||
| 268 | + | }() | |||
| 269 | + | ||||
| 270 | + | // Execute DELETE query | |||
| 271 | + | result, err := cli.DB.Exec("DELETE FROM history") | |||
| 272 | + | if err != nil { | |||
| 273 | + | return fmt.Errorf("failed to clear history: %w", err) | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | rowsAffected, err := result.RowsAffected() | |||
| 277 | + | if err != nil { | |||
| 278 | + | return fmt.Errorf("failed to get affected rows: %w", err) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | fmt.Printf("Successfully cleared %d history entries.\n", rowsAffected) | |||
| 282 | + | return nil | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // showStats displays statistics about browsing history | |||
| 286 | + | func showStats(_ *cobra.Command, _ []string) error { | |||
| 287 | + | cli, err := NewCLI() | |||
| 288 | + | if err != nil { | |||
| 289 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 290 | + | } | |||
| 291 | + | defer func() { | |||
| 292 | + | if err := cli.Close(); err != nil { | |||
| 293 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err) | |||
| 294 | + | } | |||
| 295 | + | }() | |||
| 296 | + | ||||
| 297 | + | ctx := context.Background() | |||
| 298 | + | ||||
| 299 | + | // Get total entries | |||
| 300 | + | var totalEntries int64 | |||
| 301 | + | err = cli.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM history").Scan(&totalEntries) | |||
| 302 | + | if err != nil { | |||
| 303 | + | return fmt.Errorf("failed to get total entries: %w", err) | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // Get total visits | |||
| 307 | + | var totalVisits sql.NullInt64 | |||
| 308 | + | err = cli.DB.QueryRowContext(ctx, "SELECT SUM(visit_count) FROM history").Scan(&totalVisits) | |||
| 309 | + | if err != nil { | |||
| 310 | + | return fmt.Errorf("failed to get total visits: %w", err) | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | // Get date range | |||
| 314 | + | var oldestDate, newestDate sql.NullTime | |||
| 315 | + | err = cli.DB.QueryRowContext(ctx, "SELECT MIN(created_at), MAX(last_visited) FROM history").Scan(&oldestDate, &newestDate) | |||
| 316 | + | if err != nil { | |||
| 317 | + | return fmt.Errorf("failed to get date range: %w", err) | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | // Get top domains | |||
| 321 | + | rows, err := cli.DB.QueryContext(ctx, ` | |||
| 322 | + | SELECT | |||
| 323 | + | CASE | |||
| 324 | + | WHEN url LIKE 'http://%' THEN substr(url, 8) | |||
| 325 | + | WHEN url LIKE 'https://%' THEN substr(url, 9) | |||
| 326 | + | ELSE url | |||
| 327 | + | END as domain, | |||
| 328 | + | COUNT(*) as count, | |||
| 329 | + | SUM(visit_count) as total_visits | |||
| 330 | + | FROM history | |||
| 331 | + | GROUP BY domain | |||
| 332 | + | ORDER BY total_visits DESC | |||
| 333 | + | LIMIT 5 | |||
| 334 | + | `) | |||
| 335 | + | if err != nil { | |||
| 336 | + | return fmt.Errorf("failed to get top domains: %w", err) | |||
| 337 | + | } | |||
| 338 | + | defer rows.Close() | |||
| 339 | + | ||||
| 340 | + | fmt.Println("Browsing History Statistics") | |||
| 341 | + | fmt.Println("==========================") | |||
| 342 | + | fmt.Printf("Total unique URLs: %d\n", totalEntries) | |||
| 343 | + | ||||
| 344 | + | if totalVisits.Valid { | |||
| 345 | + | fmt.Printf("Total visits: %d\n", totalVisits.Int64) | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | if oldestDate.Valid && newestDate.Valid { | |||
| 349 | + | fmt.Printf("Date range: %s to %s\n", | |||
| 350 | + | oldestDate.Time.Format("2006-01-02"), | |||
| 351 | + | newestDate.Time.Format("2006-01-02")) | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | fmt.Println("\nTop 5 domains:") | |||
| 355 | + | fmt.Println("--------------") | |||
| 356 | + | ||||
| 357 | + | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |||
| 358 | + | defer w.Flush() | |||
| 359 | + | ||||
| 360 | + | fmt.Fprintln(w, "DOMAIN\tURLs\tVISITS") | |||
| 361 | + | fmt.Fprintln(w, "------\t----\t------") | |||
| 362 | + | ||||
| 363 | + | for rows.Next() { | |||
| 364 | + | var domain string | |||
| 365 | + | var urlCount, visitCount int64 | |||
| 366 | + | ||||
| 367 | + | if err := rows.Scan(&domain, &urlCount, &visitCount); err != nil { | |||
| 368 | + | continue | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | // Extract just the domain part | |||
| 372 | + | if idx := strings.Index(domain, "/"); idx > 0 { | |||
| 373 | + | domain = domain[:idx] | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | fmt.Fprintf(w, "%s\t%d\t%d\n", domain, urlCount, visitCount) | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | return nil | |||
| 380 | + | }⏎̸ | |||
A internal/cli/root.go +225 -0
| @@ -0,0 +1,225 @@ | |||||
| 1 | + | // Package cli provides the command-line interface for dumber. | |||
| 2 | + | package cli | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "database/sql" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/config" | |||
| 11 | + | "github.com/bnema/dumber/internal/db" | |||
| 12 | + | ||||
| 13 | + | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql | |||
| 14 | + | _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite for cross-platform compatibility | |||
| 15 | + | "github.com/spf13/cobra" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // CLI holds the database connection, queries, and configuration for the CLI commands | |||
| 19 | + | type CLI struct { | |||
| 20 | + | DB *sql.DB | |||
| 21 | + | Queries *db.Queries | |||
| 22 | + | Config *config.Config | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // NewCLI creates a new CLI instance with database connection and configuration | |||
| 26 | + | func NewCLI() (*CLI, error) { | |||
| 27 | + | // Get configuration | |||
| 28 | + | cfg := config.Get() | |||
| 29 | + | ||||
| 30 | + | // Use configured database path | |||
| 31 | + | dbPath := cfg.Database.Path | |||
| 32 | + | if dbPath == "" { | |||
| 33 | + | // Fallback to default path if not configured | |||
| 34 | + | var err error | |||
| 35 | + | dbPath, err = config.GetDatabaseFile() | |||
| 36 | + | if err != nil { | |||
| 37 | + | return nil, fmt.Errorf("failed to get database path: %w", err) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Ensure database directory exists | |||
| 42 | + | if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { | |||
| 43 | + | return nil, fmt.Errorf("failed to create database directory: %w", err) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Open database connection | |||
| 47 | + | database, err := sql.Open("sqlite3", dbPath) | |||
| 48 | + | if err != nil { | |||
| 49 | + | return nil, fmt.Errorf("failed to open database: %w", err) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // Test the connection | |||
| 53 | + | if err := database.Ping(); err != nil { | |||
| 54 | + | database.Close() | |||
| 55 | + | return nil, fmt.Errorf("failed to connect to database: %w", err) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Initialize database schema with configuration-based shortcuts | |||
| 59 | + | if err := initializeDatabase(database, cfg); err != nil { | |||
| 60 | + | database.Close() | |||
| 61 | + | return nil, fmt.Errorf("failed to initialize database: %w", err) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | return &CLI{ | |||
| 65 | + | DB: database, | |||
| 66 | + | Queries: db.New(database), | |||
| 67 | + | Config: cfg, | |||
| 68 | + | }, nil | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // Close closes the database connection | |||
| 72 | + | func (c *CLI) Close() error { | |||
| 73 | + | if c.DB != nil { | |||
| 74 | + | return c.DB.Close() | |||
| 75 | + | } | |||
| 76 | + | return nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // NewRootCmd creates the root command for dumber | |||
| 80 | + | func NewRootCmd(version, commit, buildDate string) *cobra.Command { | |||
| 81 | + | rootCmd := &cobra.Command{ | |||
| 82 | + | Use: "dumber [url]", | |||
| 83 | + | Short: "A dumb browser for Wayland window managers", | |||
| 84 | + | Long: `A fast, simple browser with rofi/dmenu integration for sway and hyprland window managers.`, | |||
| 85 | + | Args: cobra.MaximumNArgs(1), | |||
| 86 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 87 | + | // Initialize CLI | |||
| 88 | + | cli, err := NewCLI() | |||
| 89 | + | if err != nil { | |||
| 90 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 91 | + | } | |||
| 92 | + | defer func() { | |||
| 93 | + | if closeErr := cli.Close(); closeErr != nil { | |||
| 94 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |||
| 95 | + | } | |||
| 96 | + | }() | |||
| 97 | + | ||||
| 98 | + | // If URL provided, browse it directly | |||
| 99 | + | if len(args) > 0 { | |||
| 100 | + | return browse(cli, args[0]) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // Otherwise show help | |||
| 104 | + | return cmd.Help() | |||
| 105 | + | }, | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // Add dmenu flag for launcher integration | |||
| 109 | + | rootCmd.Flags().Bool("dmenu", false, "Run in dmenu mode for launcher integration") | |||
| 110 | + | ||||
| 111 | + | // Version command | |||
| 112 | + | versionCmd := &cobra.Command{ | |||
| 113 | + | Use: "version", | |||
| 114 | + | Short: "Print version information", | |||
| 115 | + | Run: func(_ *cobra.Command, _ []string) { | |||
| 116 | + | fmt.Printf("dumber %s\n", version) | |||
| 117 | + | fmt.Printf("commit: %s\n", commit) | |||
| 118 | + | fmt.Printf("built: %s\n", buildDate) | |||
| 119 | + | }, | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // Init command (enhanced) | |||
| 123 | + | initCmd := &cobra.Command{ | |||
| 124 | + | Use: "init", | |||
| 125 | + | Short: "Initialize dumber database and configuration", | |||
| 126 | + | RunE: func(_ *cobra.Command, _ []string) error { | |||
| 127 | + | cli, err := NewCLI() | |||
| 128 | + | if err != nil { | |||
| 129 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 130 | + | } | |||
| 131 | + | defer func() { | |||
| 132 | + | if closeErr := cli.Close(); closeErr != nil { | |||
| 133 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |||
| 134 | + | } | |||
| 135 | + | }() | |||
| 136 | + | ||||
| 137 | + | fmt.Printf("dumber %s - Initialization complete!\n", version) | |||
| 138 | + | fmt.Println("Database initialized at:", cli.Config.Database.Path) | |||
| 139 | + | ||||
| 140 | + | // Show XDG directories | |||
| 141 | + | xdgDirs, err := config.GetXDGDirs() | |||
| 142 | + | if err == nil { | |||
| 143 | + | fmt.Println("Configuration directories:") | |||
| 144 | + | fmt.Printf("- Config: %s\n", xdgDirs.ConfigHome) | |||
| 145 | + | fmt.Printf("- Data: %s\n", xdgDirs.DataHome) | |||
| 146 | + | fmt.Printf("- State: %s\n", xdgDirs.StateHome) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Show configured shortcuts | |||
| 150 | + | fmt.Println("Configured shortcuts:") | |||
| 151 | + | for shortcut, cfg := range cli.Config.SearchShortcuts { | |||
| 152 | + | fmt.Printf("- %s:%s -> %s\n", shortcut, "query", cfg.Description) | |||
| 153 | + | } | |||
| 154 | + | return nil | |||
| 155 | + | }, | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // Add subcommands | |||
| 159 | + | rootCmd.AddCommand(versionCmd) | |||
| 160 | + | rootCmd.AddCommand(initCmd) | |||
| 161 | + | rootCmd.AddCommand(NewBrowseCmd()) | |||
| 162 | + | rootCmd.AddCommand(NewDmenuCmd()) | |||
| 163 | + | rootCmd.AddCommand(NewHistoryCmd()) | |||
| 164 | + | ||||
| 165 | + | return rootCmd | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // getDatabasePath returns the path to the SQLite database file (legacy function for compatibility) | |||
| 169 | + | func getDatabasePath() (string, error) { | |||
| 170 | + | return config.GetDatabaseFile() | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // getDatabasePathOrDefault returns the database path or a default message (legacy function for compatibility) | |||
| 174 | + | func getDatabasePathOrDefault() string { | |||
| 175 | + | path, err := config.GetDatabaseFile() | |||
| 176 | + | if err != nil { | |||
| 177 | + | return "~/.local/state/dumber/history.db" | |||
| 178 | + | } | |||
| 179 | + | return path | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // initializeDatabase creates the database schema if it doesn't exist | |||
| 183 | + | func initializeDatabase(db *sql.DB, cfg *config.Config) error { | |||
| 184 | + | schema := ` | |||
| 185 | + | -- History tracking for visited URLs | |||
| 186 | + | CREATE TABLE IF NOT EXISTS history ( | |||
| 187 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| 188 | + | url TEXT NOT NULL UNIQUE, | |||
| 189 | + | title TEXT, | |||
| 190 | + | visit_count INTEGER DEFAULT 1, | |||
| 191 | + | last_visited DATETIME DEFAULT CURRENT_TIMESTAMP, | |||
| 192 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 193 | + | ); | |||
| 194 | + | ||||
| 195 | + | CREATE INDEX IF NOT EXISTS idx_history_url ON history(url); | |||
| 196 | + | CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited); | |||
| 197 | + | ||||
| 198 | + | -- URL shortcuts configuration | |||
| 199 | + | CREATE TABLE IF NOT EXISTS shortcuts ( | |||
| 200 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| 201 | + | shortcut TEXT NOT NULL UNIQUE, | |||
| 202 | + | url_template TEXT NOT NULL, | |||
| 203 | + | description TEXT, | |||
| 204 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 205 | + | ); | |||
| 206 | + | ` | |||
| 207 | + | ||||
| 208 | + | // Execute schema creation | |||
| 209 | + | if _, err := db.Exec(schema); err != nil { | |||
| 210 | + | return err | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // Insert configured shortcuts | |||
| 214 | + | for shortcut, shortcutCfg := range cfg.SearchShortcuts { | |||
| 215 | + | _, err := db.Exec( | |||
| 216 | + | "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)", | |||
| 217 | + | shortcut, shortcutCfg.URL, shortcutCfg.Description, | |||
| 218 | + | ) | |||
| 219 | + | if err != nil { | |||
| 220 | + | return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err) | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | return nil | |||
| 225 | + | } | |||
A internal/config/config.go +373 -0
| @@ -0,0 +1,373 @@ | |||||
| 1 | + | // Package config provides configuration management for dumber with Viper integration. | |||
| 2 | + | package config | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/fsnotify/fsnotify" | |||
| 14 | + | "github.com/spf13/viper" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Config represents the complete configuration for dumber. | |||
| 18 | + | type Config struct { | |||
| 19 | + | Database DatabaseConfig `mapstructure:"database" yaml:"database"` | |||
| 20 | + | History HistoryConfig `mapstructure:"history" yaml:"history"` | |||
| 21 | + | SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"` | |||
| 22 | + | Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"` | |||
| 23 | + | Logging LoggingConfig `mapstructure:"logging" yaml:"logging"` | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // DatabaseConfig holds database-related configuration. | |||
| 27 | + | type DatabaseConfig struct { | |||
| 28 | + | Path string `mapstructure:"path" yaml:"path"` | |||
| 29 | + | MaxConnections int `mapstructure:"max_connections" yaml:"max_connections"` | |||
| 30 | + | MaxIdleTime time.Duration `mapstructure:"max_idle_time" yaml:"max_idle_time"` | |||
| 31 | + | QueryTimeout time.Duration `mapstructure:"query_timeout" yaml:"query_timeout"` | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // HistoryConfig holds history-related configuration. | |||
| 35 | + | type HistoryConfig struct { | |||
| 36 | + | MaxEntries int `mapstructure:"max_entries" yaml:"max_entries"` | |||
| 37 | + | RetentionPeriodDays int `mapstructure:"retention_period_days" yaml:"retention_period_days"` | |||
| 38 | + | CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days"` | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // SearchShortcut represents a search shortcut configuration. | |||
| 42 | + | type SearchShortcut struct { | |||
| 43 | + | URL string `mapstructure:"url" yaml:"url"` | |||
| 44 | + | Description string `mapstructure:"description" yaml:"description"` | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // DmenuConfig holds dmenu/rofi integration configuration. | |||
| 48 | + | type DmenuConfig struct { | |||
| 49 | + | MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items"` | |||
| 50 | + | ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count"` | |||
| 51 | + | ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited"` | |||
| 52 | + | HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix"` | |||
| 53 | + | ShortcutPrefix string `mapstructure:"shortcut_prefix" yaml:"shortcut_prefix"` | |||
| 54 | + | URLPrefix string `mapstructure:"url_prefix" yaml:"url_prefix"` | |||
| 55 | + | DateFormat string `mapstructure:"date_format" yaml:"date_format"` | |||
| 56 | + | SortByVisitCount bool `mapstructure:"sort_by_visit_count" yaml:"sort_by_visit_count"` | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // LoggingConfig holds logging configuration. | |||
| 60 | + | type LoggingConfig struct { | |||
| 61 | + | Level string `mapstructure:"level" yaml:"level"` | |||
| 62 | + | Format string `mapstructure:"format" yaml:"format"` | |||
| 63 | + | Filename string `mapstructure:"filename" yaml:"filename"` | |||
| 64 | + | MaxSize int `mapstructure:"max_size" yaml:"max_size"` | |||
| 65 | + | MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"` | |||
| 66 | + | MaxAge int `mapstructure:"max_age" yaml:"max_age"` | |||
| 67 | + | Compress bool `mapstructure:"compress" yaml:"compress"` | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Manager handles configuration loading, watching, and reloading. | |||
| 71 | + | type Manager struct { | |||
| 72 | + | config *Config | |||
| 73 | + | viper *viper.Viper | |||
| 74 | + | mu sync.RWMutex | |||
| 75 | + | callbacks []func(*Config) | |||
| 76 | + | watching bool | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // NewManager creates a new configuration manager. | |||
| 80 | + | func NewManager() (*Manager, error) { | |||
| 81 | + | v := viper.New() | |||
| 82 | + | ||||
| 83 | + | // Configure Viper - supports yaml, json, toml automatically | |||
| 84 | + | v.SetConfigName("config") // Will find config.yaml, config.json, config.toml, etc. | |||
| 85 | + | ||||
| 86 | + | // Add config paths | |||
| 87 | + | configDir, err := GetConfigDir() | |||
| 88 | + | if err != nil { | |||
| 89 | + | return nil, fmt.Errorf("failed to get config directory: %w", err) | |||
| 90 | + | } | |||
| 91 | + | v.AddConfigPath(configDir) | |||
| 92 | + | v.AddConfigPath(".") // Current directory for development | |||
| 93 | + | ||||
| 94 | + | // Set up environment variable support | |||
| 95 | + | v.SetEnvPrefix("DUMB_BROWSER") | |||
| 96 | + | v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) | |||
| 97 | + | v.AutomaticEnv() | |||
| 98 | + | ||||
| 99 | + | // Bind specific environment variables | |||
| 100 | + | bindings := map[string]string{ | |||
| 101 | + | "database.path": "DATABASE_PATH", | |||
| 102 | + | "database.max_connections": "DATABASE_MAX_CONNECTIONS", | |||
| 103 | + | "database.max_idle_time": "DATABASE_MAX_IDLE_TIME", | |||
| 104 | + | "database.query_timeout": "DATABASE_QUERY_TIMEOUT", | |||
| 105 | + | "browser.command": "BROWSER_COMMAND", | |||
| 106 | + | "browser.timeout": "BROWSER_TIMEOUT", | |||
| 107 | + | "browser.detach_process": "BROWSER_DETACH_PROCESS", | |||
| 108 | + | "history.max_entries": "HISTORY_MAX_ENTRIES", | |||
| 109 | + | "history.retention_period": "HISTORY_RETENTION_PERIOD", | |||
| 110 | + | "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL", | |||
| 111 | + | "dmenu.max_history_items": "DMENU_MAX_HISTORY_ITEMS", | |||
| 112 | + | "dmenu.show_visit_count": "DMENU_SHOW_VISIT_COUNT", | |||
| 113 | + | "dmenu.show_last_visited": "DMENU_SHOW_LAST_VISITED", | |||
| 114 | + | "dmenu.history_prefix": "DMENU_HISTORY_PREFIX", | |||
| 115 | + | "dmenu.shortcut_prefix": "DMENU_SHORTCUT_PREFIX", | |||
| 116 | + | "dmenu.url_prefix": "DMENU_URL_PREFIX", | |||
| 117 | + | "dmenu.date_format": "DMENU_DATE_FORMAT", | |||
| 118 | + | "dmenu.sort_by_visit_count": "DMENU_SORT_BY_VISIT_COUNT", | |||
| 119 | + | "logging.level": "LOGGING_LEVEL", | |||
| 120 | + | "logging.format": "LOGGING_FORMAT", | |||
| 121 | + | "logging.filename": "LOGGING_FILENAME", | |||
| 122 | + | "logging.max_size": "LOGGING_MAX_SIZE", | |||
| 123 | + | "logging.max_backups": "LOGGING_MAX_BACKUPS", | |||
| 124 | + | "logging.max_age": "LOGGING_MAX_AGE", | |||
| 125 | + | "logging.compress": "LOGGING_COMPRESS", | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | for key, env := range bindings { | |||
| 129 | + | if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil { | |||
| 130 | + | return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | return &Manager{ | |||
| 135 | + | viper: v, | |||
| 136 | + | callbacks: make([]func(*Config), 0), | |||
| 137 | + | }, nil | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Load loads the configuration from file and environment variables. | |||
| 141 | + | func (m *Manager) Load() error { | |||
| 142 | + | m.mu.Lock() | |||
| 143 | + | defer m.mu.Unlock() | |||
| 144 | + | ||||
| 145 | + | // Ensure directories exist | |||
| 146 | + | if err := EnsureDirectories(); err != nil { | |||
| 147 | + | return fmt.Errorf("failed to ensure directories: %w", err) | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // Set defaults | |||
| 151 | + | m.setDefaults() | |||
| 152 | + | ||||
| 153 | + | // Read config file if it exists | |||
| 154 | + | if err := m.viper.ReadInConfig(); err != nil { | |||
| 155 | + | if _, ok := err.(viper.ConfigFileNotFoundError); ok { | |||
| 156 | + | // Config file not found, create default one | |||
| 157 | + | if err := m.createDefaultConfig(); err != nil { | |||
| 158 | + | return fmt.Errorf("failed to create default config: %w", err) | |||
| 159 | + | } | |||
| 160 | + | } else { | |||
| 161 | + | return fmt.Errorf("failed to read config file: %w", err) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // Unmarshal into config struct | |||
| 166 | + | config := &Config{} | |||
| 167 | + | if err := m.viper.Unmarshal(config); err != nil { | |||
| 168 | + | return fmt.Errorf("failed to unmarshal config: %w", err) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // Set database path if not specified | |||
| 172 | + | if config.Database.Path == "" { | |||
| 173 | + | dbPath, err := GetDatabaseFile() | |||
| 174 | + | if err != nil { | |||
| 175 | + | return fmt.Errorf("failed to get database path: %w", err) | |||
| 176 | + | } | |||
| 177 | + | config.Database.Path = dbPath | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | m.config = config | |||
| 181 | + | return nil | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // Get returns the current configuration (thread-safe). | |||
| 185 | + | func (m *Manager) Get() *Config { | |||
| 186 | + | m.mu.RLock() | |||
| 187 | + | defer m.mu.RUnlock() | |||
| 188 | + | ||||
| 189 | + | // Return a copy to prevent external modification | |||
| 190 | + | configCopy := *m.config | |||
| 191 | + | return &configCopy | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // Watch starts watching the config file for changes and reloads automatically. | |||
| 195 | + | func (m *Manager) Watch() error { | |||
| 196 | + | m.mu.Lock() | |||
| 197 | + | defer m.mu.Unlock() | |||
| 198 | + | ||||
| 199 | + | if m.watching { | |||
| 200 | + | return nil // Already watching | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | m.viper.WatchConfig() | |||
| 204 | + | m.viper.OnConfigChange(func(_ fsnotify.Event) { | |||
| 205 | + | // Reload config | |||
| 206 | + | if err := m.reload(); err != nil { | |||
| 207 | + | fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err) | |||
| 208 | + | return | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // Notify callbacks | |||
| 212 | + | m.mu.RLock() | |||
| 213 | + | config := m.config | |||
| 214 | + | callbacks := make([]func(*Config), len(m.callbacks)) | |||
| 215 | + | copy(callbacks, m.callbacks) | |||
| 216 | + | m.mu.RUnlock() | |||
| 217 | + | ||||
| 218 | + | for _, callback := range callbacks { | |||
| 219 | + | callback(config) | |||
| 220 | + | } | |||
| 221 | + | }) | |||
| 222 | + | ||||
| 223 | + | m.watching = true | |||
| 224 | + | return nil | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // OnConfigChange registers a callback function to be called when config changes. | |||
| 228 | + | func (m *Manager) OnConfigChange(callback func(*Config)) { | |||
| 229 | + | m.mu.Lock() | |||
| 230 | + | defer m.mu.Unlock() | |||
| 231 | + | ||||
| 232 | + | m.callbacks = append(m.callbacks, callback) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // reload reloads the configuration (internal method, must be called with lock). | |||
| 236 | + | func (m *Manager) reload() error { | |||
| 237 | + | if err := m.viper.ReadInConfig(); err != nil { | |||
| 238 | + | return err | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | config := &Config{} | |||
| 242 | + | if err := m.viper.Unmarshal(config); err != nil { | |||
| 243 | + | return err | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // Set database path if not specified | |||
| 247 | + | if config.Database.Path == "" { | |||
| 248 | + | dbPath, err := GetDatabaseFile() | |||
| 249 | + | if err != nil { | |||
| 250 | + | return fmt.Errorf("failed to get database path: %w", err) | |||
| 251 | + | } | |||
| 252 | + | config.Database.Path = dbPath | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | m.config = config | |||
| 256 | + | return nil | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // setDefaults sets default configuration values in Viper. | |||
| 260 | + | func (m *Manager) setDefaults() { | |||
| 261 | + | defaults := DefaultConfig() | |||
| 262 | + | ||||
| 263 | + | // Database defaults | |||
| 264 | + | m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections) | |||
| 265 | + | m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime) | |||
| 266 | + | m.viper.SetDefault("database.query_timeout", defaults.Database.QueryTimeout) | |||
| 267 | + | ||||
| 268 | + | // Note: Browser config removed - we build our own browser with Wails | |||
| 269 | + | ||||
| 270 | + | // History defaults | |||
| 271 | + | m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries) | |||
| 272 | + | m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays) | |||
| 273 | + | m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays) | |||
| 274 | + | ||||
| 275 | + | // Search shortcuts defaults | |||
| 276 | + | m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts) | |||
| 277 | + | ||||
| 278 | + | // Dmenu defaults | |||
| 279 | + | m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems) | |||
| 280 | + | m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount) | |||
| 281 | + | m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited) | |||
| 282 | + | m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix) | |||
| 283 | + | m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix) | |||
| 284 | + | m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix) | |||
| 285 | + | m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat) | |||
| 286 | + | m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount) | |||
| 287 | + | ||||
| 288 | + | // Logging defaults | |||
| 289 | + | m.viper.SetDefault("logging.level", defaults.Logging.Level) | |||
| 290 | + | m.viper.SetDefault("logging.format", defaults.Logging.Format) | |||
| 291 | + | m.viper.SetDefault("logging.filename", defaults.Logging.Filename) | |||
| 292 | + | m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize) | |||
| 293 | + | m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups) | |||
| 294 | + | m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge) | |||
| 295 | + | m.viper.SetDefault("logging.compress", defaults.Logging.Compress) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // createDefaultConfig creates a default configuration file. | |||
| 299 | + | func (m *Manager) createDefaultConfig() error { | |||
| 300 | + | configFile, err := GetConfigFile() | |||
| 301 | + | if err != nil { | |||
| 302 | + | return err | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // Create config directory if it doesn't exist | |||
| 306 | + | if err := os.MkdirAll(filepath.Dir(configFile), 0755); err != nil { | |||
| 307 | + | return err | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | // Get the default configuration | |||
| 311 | + | defaultConfig := DefaultConfig() | |||
| 312 | + | ||||
| 313 | + | // Marshal to JSON with proper indentation | |||
| 314 | + | configData, err := json.MarshalIndent(defaultConfig, "", " ") | |||
| 315 | + | if err != nil { | |||
| 316 | + | return fmt.Errorf("failed to marshal default config: %w", err) | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // Write JSON config file | |||
| 320 | + | if err := os.WriteFile(configFile, configData, 0644); err != nil { | |||
| 321 | + | return fmt.Errorf("failed to write config file: %w", err) | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | fmt.Printf("Created default configuration file: %s\n", configFile) | |||
| 325 | + | return nil | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | // GetConfigFile returns the path to the configuration file being used. | |||
| 329 | + | func (m *Manager) GetConfigFile() string { | |||
| 330 | + | return m.viper.ConfigFileUsed() | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | // Global configuration manager instance | |||
| 334 | + | var globalManager *Manager | |||
| 335 | + | var globalManagerOnce sync.Once | |||
| 336 | + | ||||
| 337 | + | // Init initializes the global configuration manager. | |||
| 338 | + | func Init() error { | |||
| 339 | + | var err error | |||
| 340 | + | globalManagerOnce.Do(func() { | |||
| 341 | + | globalManager, err = NewManager() | |||
| 342 | + | if err != nil { | |||
| 343 | + | return | |||
| 344 | + | } | |||
| 345 | + | err = globalManager.Load() | |||
| 346 | + | }) | |||
| 347 | + | return err | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | // Get returns the global configuration. | |||
| 351 | + | func Get() *Config { | |||
| 352 | + | if globalManager == nil { | |||
| 353 | + | // Return defaults if not initialized | |||
| 354 | + | return DefaultConfig() | |||
| 355 | + | } | |||
| 356 | + | return globalManager.Get() | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | // Watch starts watching the global configuration for changes. | |||
| 360 | + | func Watch() error { | |||
| 361 | + | if globalManager == nil { | |||
| 362 | + | return fmt.Errorf("configuration not initialized") | |||
| 363 | + | } | |||
| 364 | + | return globalManager.Watch() | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | // OnConfigChange registers a callback for global configuration changes. | |||
| 368 | + | func OnConfigChange(callback func(*Config)) { | |||
| 369 | + | if globalManager == nil { | |||
| 370 | + | return | |||
| 371 | + | } | |||
| 372 | + | globalManager.OnConfigChange(callback) | |||
| 373 | + | } | |||
A internal/config/defaults.go +107 -0
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | // Package config provides default configuration values for dumber. | |||
| 2 | + | package config | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // DefaultConfig returns the default configuration values for dumber. | |||
| 9 | + | func DefaultConfig() *Config { | |||
| 10 | + | return &Config{ | |||
| 11 | + | Database: DatabaseConfig{ | |||
| 12 | + | MaxConnections: 1, | |||
| 13 | + | MaxIdleTime: time.Minute * 5, | |||
| 14 | + | QueryTimeout: time.Second * 30, | |||
| 15 | + | }, | |||
| 16 | + | History: HistoryConfig{ | |||
| 17 | + | MaxEntries: 10000, | |||
| 18 | + | RetentionPeriodDays: 365, // 1 year | |||
| 19 | + | CleanupIntervalDays: 1, // daily cleanup | |||
| 20 | + | }, | |||
| 21 | + | SearchShortcuts: map[string]SearchShortcut{ | |||
| 22 | + | "g": { | |||
| 23 | + | URL: "https://www.google.com/search?q=%s", | |||
| 24 | + | Description: "Google search", | |||
| 25 | + | }, | |||
| 26 | + | "gh": { | |||
| 27 | + | URL: "https://github.com/search?q=%s", | |||
| 28 | + | Description: "GitHub search", | |||
| 29 | + | }, | |||
| 30 | + | "yt": { | |||
| 31 | + | URL: "https://www.youtube.com/results?search_query=%s", | |||
| 32 | + | Description: "YouTube search", | |||
| 33 | + | }, | |||
| 34 | + | "w": { | |||
| 35 | + | URL: "https://en.wikipedia.org/wiki/%s", | |||
| 36 | + | Description: "Wikipedia search", | |||
| 37 | + | }, | |||
| 38 | + | }, | |||
| 39 | + | Dmenu: DmenuConfig{ | |||
| 40 | + | MaxHistoryItems: 20, | |||
| 41 | + | ShowVisitCount: true, | |||
| 42 | + | ShowLastVisited: true, | |||
| 43 | + | HistoryPrefix: "🕒", | |||
| 44 | + | ShortcutPrefix: "🔍", | |||
| 45 | + | URLPrefix: "🌐", | |||
| 46 | + | DateFormat: "2006-01-02 15:04", | |||
| 47 | + | SortByVisitCount: true, | |||
| 48 | + | }, | |||
| 49 | + | Logging: LoggingConfig{ | |||
| 50 | + | Level: "info", | |||
| 51 | + | Format: "text", // text or json | |||
| 52 | + | Filename: "", // empty means stdout | |||
| 53 | + | MaxSize: 100, // MB | |||
| 54 | + | MaxBackups: 3, | |||
| 55 | + | MaxAge: 7, // days | |||
| 56 | + | Compress: true, | |||
| 57 | + | }, | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // GetDefaultSearchShortcuts returns the default search shortcuts. | |||
| 62 | + | func GetDefaultSearchShortcuts() map[string]SearchShortcut { | |||
| 63 | + | return map[string]SearchShortcut{ | |||
| 64 | + | "g": { | |||
| 65 | + | URL: "https://www.google.com/search?q=%s", | |||
| 66 | + | Description: "Google search", | |||
| 67 | + | }, | |||
| 68 | + | "gh": { | |||
| 69 | + | URL: "https://github.com/search?q=%s", | |||
| 70 | + | Description: "GitHub search", | |||
| 71 | + | }, | |||
| 72 | + | "yt": { | |||
| 73 | + | URL: "https://www.youtube.com/results?search_query=%s", | |||
| 74 | + | Description: "YouTube search", | |||
| 75 | + | }, | |||
| 76 | + | "w": { | |||
| 77 | + | URL: "https://en.wikipedia.org/wiki/%s", | |||
| 78 | + | Description: "Wikipedia search", | |||
| 79 | + | }, | |||
| 80 | + | "ddg": { | |||
| 81 | + | URL: "https://duckduckgo.com/?q=%s", | |||
| 82 | + | Description: "DuckDuckGo search", | |||
| 83 | + | }, | |||
| 84 | + | "so": { | |||
| 85 | + | URL: "https://stackoverflow.com/search?q=%s", | |||
| 86 | + | Description: "Stack Overflow search", | |||
| 87 | + | }, | |||
| 88 | + | "r": { | |||
| 89 | + | URL: "https://www.reddit.com/search?q=%s", | |||
| 90 | + | Description: "Reddit search", | |||
| 91 | + | }, | |||
| 92 | + | "npm": { | |||
| 93 | + | URL: "https://www.npmjs.com/search?q=%s", | |||
| 94 | + | Description: "npm package search", | |||
| 95 | + | }, | |||
| 96 | + | "go": { | |||
| 97 | + | URL: "https://pkg.go.dev/search?q=%s", | |||
| 98 | + | Description: "Go package search", | |||
| 99 | + | }, | |||
| 100 | + | "mdn": { | |||
| 101 | + | URL: "https://developer.mozilla.org/en-US/search?q=%s", | |||
| 102 | + | Description: "MDN Web Docs search", | |||
| 103 | + | }, | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Note: We build our own browser with Wails, so no external browser commands needed | |||
A internal/config/xdg.go +123 -0
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | // Package config provides XDG Base Directory specification compliance utilities. | |||
| 2 | + | package config | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const appName = "dumber" | |||
| 10 | + | ||||
| 11 | + | // XDGDirs holds the XDG Base Directory paths for the application. | |||
| 12 | + | type XDGDirs struct { | |||
| 13 | + | ConfigHome string | |||
| 14 | + | DataHome string | |||
| 15 | + | StateHome string | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // GetXDGDirs returns the XDG Base Directory paths for dumber. | |||
| 19 | + | // It follows the XDG Base Directory specification: | |||
| 20 | + | // - $XDG_CONFIG_HOME/dumber (default: ~/.config/dumber) | |||
| 21 | + | // - $XDG_DATA_HOME/dumber (default: ~/.local/share/dumber) | |||
| 22 | + | // - $XDG_STATE_HOME/dumber (default: ~/.local/state/dumber) | |||
| 23 | + | func GetXDGDirs() (*XDGDirs, error) { | |||
| 24 | + | homeDir, err := os.UserHomeDir() | |||
| 25 | + | if err != nil { | |||
| 26 | + | return nil, err | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // XDG_CONFIG_HOME | |||
| 30 | + | configHome := os.Getenv("XDG_CONFIG_HOME") | |||
| 31 | + | if configHome == "" { | |||
| 32 | + | configHome = filepath.Join(homeDir, ".config") | |||
| 33 | + | } | |||
| 34 | + | configHome = filepath.Join(configHome, appName) | |||
| 35 | + | ||||
| 36 | + | // XDG_DATA_HOME | |||
| 37 | + | dataHome := os.Getenv("XDG_DATA_HOME") | |||
| 38 | + | if dataHome == "" { | |||
| 39 | + | dataHome = filepath.Join(homeDir, ".local", "share") | |||
| 40 | + | } | |||
| 41 | + | dataHome = filepath.Join(dataHome, appName) | |||
| 42 | + | ||||
| 43 | + | // XDG_STATE_HOME | |||
| 44 | + | stateHome := os.Getenv("XDG_STATE_HOME") | |||
| 45 | + | if stateHome == "" { | |||
| 46 | + | stateHome = filepath.Join(homeDir, ".local", "state") | |||
| 47 | + | } | |||
| 48 | + | stateHome = filepath.Join(stateHome, appName) | |||
| 49 | + | ||||
| 50 | + | return &XDGDirs{ | |||
| 51 | + | ConfigHome: configHome, | |||
| 52 | + | DataHome: dataHome, | |||
| 53 | + | StateHome: stateHome, | |||
| 54 | + | }, nil | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // GetConfigDir returns the XDG config directory for dumber. | |||
| 58 | + | func GetConfigDir() (string, error) { | |||
| 59 | + | dirs, err := GetXDGDirs() | |||
| 60 | + | if err != nil { | |||
| 61 | + | return "", err | |||
| 62 | + | } | |||
| 63 | + | return dirs.ConfigHome, nil | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // GetDataDir returns the XDG data directory for dumber. | |||
| 67 | + | func GetDataDir() (string, error) { | |||
| 68 | + | dirs, err := GetXDGDirs() | |||
| 69 | + | if err != nil { | |||
| 70 | + | return "", err | |||
| 71 | + | } | |||
| 72 | + | return dirs.DataHome, nil | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // GetStateDir returns the XDG state directory for dumber. | |||
| 76 | + | func GetStateDir() (string, error) { | |||
| 77 | + | dirs, err := GetXDGDirs() | |||
| 78 | + | if err != nil { | |||
| 79 | + | return "", err | |||
| 80 | + | } | |||
| 81 | + | return dirs.StateHome, nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // GetConfigFile returns the path to the main configuration file. | |||
| 85 | + | func GetConfigFile() (string, error) { | |||
| 86 | + | configDir, err := GetConfigDir() | |||
| 87 | + | if err != nil { | |||
| 88 | + | return "", err | |||
| 89 | + | } | |||
| 90 | + | return filepath.Join(configDir, "config.json"), nil | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // GetDatabaseFile returns the path to the database file in the state directory. | |||
| 94 | + | func GetDatabaseFile() (string, error) { | |||
| 95 | + | stateDir, err := GetStateDir() | |||
| 96 | + | if err != nil { | |||
| 97 | + | return "", err | |||
| 98 | + | } | |||
| 99 | + | return filepath.Join(stateDir, "history.db"), nil | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // EnsureDirectories creates the XDG directories if they don't exist. | |||
| 103 | + | func EnsureDirectories() error { | |||
| 104 | + | dirs, err := GetXDGDirs() | |||
| 105 | + | if err != nil { | |||
| 106 | + | return err | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Create all directories with proper permissions | |||
| 110 | + | directories := []string{ | |||
| 111 | + | dirs.ConfigHome, | |||
| 112 | + | dirs.DataHome, | |||
| 113 | + | dirs.StateHome, | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | for _, dir := range directories { | |||
| 117 | + | if err := os.MkdirAll(dir, 0755); err != nil { | |||
| 118 | + | return err | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | return nil | |||
| 123 | + | } | |||
A internal/parser/fuzzy.go +471 -0
| @@ -0,0 +1,471 @@ | |||||
| 1 | + | package parser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "database/sql" | |||
| 5 | + | "math" | |||
| 6 | + | "sort" | |||
| 7 | + | "strings" | |||
| 8 | + | "time" | |||
| 9 | + | "unicode/utf8" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/db" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // FuzzyMatcher implements efficient fuzzy string matching algorithms. | |||
| 15 | + | type FuzzyMatcher struct { | |||
| 16 | + | config *FuzzyConfig | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // NewFuzzyMatcher creates a new FuzzyMatcher with the given configuration. | |||
| 20 | + | func NewFuzzyMatcher(config *FuzzyConfig) *FuzzyMatcher { | |||
| 21 | + | return &FuzzyMatcher{ | |||
| 22 | + | config: config, | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // SearchHistory performs fuzzy search on history entries and returns ranked matches. | |||
| 27 | + | func (fm *FuzzyMatcher) SearchHistory(query string, history []*db.History) []FuzzyMatch { | |||
| 28 | + | if len(history) == 0 || query == "" { | |||
| 29 | + | return nil | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | query = strings.ToLower(strings.TrimSpace(query)) | |||
| 33 | + | matches := make([]FuzzyMatch, 0) | |||
| 34 | + | ||||
| 35 | + | for _, entry := range history { | |||
| 36 | + | match := fm.matchHistoryEntry(query, entry) | |||
| 37 | + | if match.Score >= fm.config.MinSimilarityThreshold { | |||
| 38 | + | matches = append(matches, match) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Sort by score (descending) | |||
| 43 | + | sort.Slice(matches, func(i, j int) bool { | |||
| 44 | + | return matches[i].Score > matches[j].Score | |||
| 45 | + | }) | |||
| 46 | + | ||||
| 47 | + | // Limit results | |||
| 48 | + | if len(matches) > fm.config.MaxResults { | |||
| 49 | + | matches = matches[:fm.config.MaxResults] | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | return matches | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // matchHistoryEntry calculates fuzzy match score for a history entry. | |||
| 56 | + | func (fm *FuzzyMatcher) matchHistoryEntry(query string, entry *db.History) FuzzyMatch { | |||
| 57 | + | match := FuzzyMatch{ | |||
| 58 | + | HistoryEntry: entry, | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | url := strings.ToLower(entry.Url) | |||
| 62 | + | title := "" | |||
| 63 | + | if entry.Title.Valid { | |||
| 64 | + | title = strings.ToLower(entry.Title.String) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Calculate URL similarity | |||
| 68 | + | match.URLScore = fm.calculateSimilarity(query, url) | |||
| 69 | + | urlSubstring := fm.substringMatch(query, url) | |||
| 70 | + | if urlSubstring > match.URLScore { | |||
| 71 | + | match.URLScore = urlSubstring | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // Calculate title similarity | |||
| 75 | + | if title != "" { | |||
| 76 | + | match.TitleScore = fm.calculateSimilarity(query, title) | |||
| 77 | + | titleSubstring := fm.substringMatch(query, title) | |||
| 78 | + | if titleSubstring > match.TitleScore { | |||
| 79 | + | match.TitleScore = titleSubstring | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // Calculate recency score | |||
| 84 | + | match.RecencyScore = fm.calculateRecencyScore(entry.LastVisited) | |||
| 85 | + | ||||
| 86 | + | // Calculate visit count score | |||
| 87 | + | match.VisitScore = fm.calculateVisitScore(entry.VisitCount) | |||
| 88 | + | ||||
| 89 | + | // Determine matched field | |||
| 90 | + | if match.URLScore >= match.TitleScore { | |||
| 91 | + | match.MatchedField = "url" | |||
| 92 | + | if match.URLScore > 0 && match.TitleScore > 0 { | |||
| 93 | + | match.MatchedField = "both" | |||
| 94 | + | } | |||
| 95 | + | } else { | |||
| 96 | + | match.MatchedField = "title" | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // Calculate weighted final score | |||
| 100 | + | match.Score = fm.config.URLWeight*match.URLScore + | |||
| 101 | + | fm.config.TitleWeight*match.TitleScore + | |||
| 102 | + | fm.config.RecencyWeight*match.RecencyScore + | |||
| 103 | + | fm.config.VisitWeight*match.VisitScore | |||
| 104 | + | ||||
| 105 | + | return match | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // calculateSimilarity computes similarity using Jaro-Winkler distance. | |||
| 109 | + | func (fm *FuzzyMatcher) calculateSimilarity(s1, s2 string) float64 { | |||
| 110 | + | if s1 == s2 { | |||
| 111 | + | return 1.0 | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | if len(s1) == 0 || len(s2) == 0 { | |||
| 115 | + | return 0.0 | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // Use Jaro-Winkler distance for better results | |||
| 119 | + | return fm.jaroWinklerSimilarity(s1, s2) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // substringMatch calculates substring match score with position weighting. | |||
| 123 | + | func (fm *FuzzyMatcher) substringMatch(query, text string) float64 { | |||
| 124 | + | if query == "" || text == "" { | |||
| 125 | + | return 0.0 | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // Check for exact substring match | |||
| 129 | + | index := strings.Index(text, query) | |||
| 130 | + | if index == -1 { | |||
| 131 | + | return 0.0 | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // Base score for substring match | |||
| 135 | + | baseScore := float64(len(query)) / float64(len(text)) | |||
| 136 | + | ||||
| 137 | + | // Boost score if match is at the beginning | |||
| 138 | + | positionWeight := 1.0 | |||
| 139 | + | if index == 0 { | |||
| 140 | + | positionWeight = 1.5 | |||
| 141 | + | } else if index < len(text)/3 { | |||
| 142 | + | positionWeight = 1.2 | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | score := baseScore * positionWeight | |||
| 146 | + | if score > 1.0 { | |||
| 147 | + | score = 1.0 | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | return score | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // jaroWinklerSimilarity calculates Jaro-Winkler similarity between two strings. | |||
| 154 | + | func (fm *FuzzyMatcher) jaroWinklerSimilarity(s1, s2 string) float64 { | |||
| 155 | + | jaroSim := fm.jaroSimilarity(s1, s2) | |||
| 156 | + | ||||
| 157 | + | if jaroSim < 0.7 { | |||
| 158 | + | return jaroSim | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // Calculate common prefix length (up to 4 characters) | |||
| 162 | + | prefixLength := 0 | |||
| 163 | + | maxPrefix := 4 | |||
| 164 | + | minLen := len(s1) | |||
| 165 | + | if len(s2) < minLen { | |||
| 166 | + | minLen = len(s2) | |||
| 167 | + | } | |||
| 168 | + | if maxPrefix > minLen { | |||
| 169 | + | maxPrefix = minLen | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | for i := 0; i < maxPrefix; i++ { | |||
| 173 | + | if s1[i] == s2[i] { | |||
| 174 | + | prefixLength++ | |||
| 175 | + | } else { | |||
| 176 | + | break | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // Winkler modification | |||
| 181 | + | return jaroSim + (0.1 * float64(prefixLength) * (1 - jaroSim)) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // jaroSimilarity calculates Jaro similarity between two strings. | |||
| 185 | + | func (fm *FuzzyMatcher) jaroSimilarity(s1, s2 string) float64 { | |||
| 186 | + | len1 := utf8.RuneCountInString(s1) | |||
| 187 | + | len2 := utf8.RuneCountInString(s2) | |||
| 188 | + | ||||
| 189 | + | if len1 == 0 && len2 == 0 { | |||
| 190 | + | return 1.0 | |||
| 191 | + | } | |||
| 192 | + | if len1 == 0 || len2 == 0 { | |||
| 193 | + | return 0.0 | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // Convert to rune slices for proper Unicode handling | |||
| 197 | + | runes1 := []rune(s1) | |||
| 198 | + | runes2 := []rune(s2) | |||
| 199 | + | ||||
| 200 | + | // Calculate match window | |||
| 201 | + | matchWindow := (maxInt(len1, len2) / 2) - 1 | |||
| 202 | + | if matchWindow < 0 { | |||
| 203 | + | matchWindow = 0 | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // Track matches | |||
| 207 | + | matches1 := make([]bool, len1) | |||
| 208 | + | matches2 := make([]bool, len2) | |||
| 209 | + | matchCount := 0 | |||
| 210 | + | ||||
| 211 | + | // Find matches | |||
| 212 | + | for i := 0; i < len1; i++ { | |||
| 213 | + | start := maxInt(0, i-matchWindow) | |||
| 214 | + | end := minInt(i+matchWindow+1, len2) | |||
| 215 | + | ||||
| 216 | + | for j := start; j < end; j++ { | |||
| 217 | + | if matches2[j] || runes1[i] != runes2[j] { | |||
| 218 | + | continue | |||
| 219 | + | } | |||
| 220 | + | matches1[i] = true | |||
| 221 | + | matches2[j] = true | |||
| 222 | + | matchCount++ | |||
| 223 | + | break | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | if matchCount == 0 { | |||
| 228 | + | return 0.0 | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // Count transpositions | |||
| 232 | + | transpositions := 0 | |||
| 233 | + | k := 0 | |||
| 234 | + | for i := 0; i < len1; i++ { | |||
| 235 | + | if !matches1[i] { | |||
| 236 | + | continue | |||
| 237 | + | } | |||
| 238 | + | for !matches2[k] { | |||
| 239 | + | k++ | |||
| 240 | + | } | |||
| 241 | + | if runes1[i] != runes2[k] { | |||
| 242 | + | transpositions++ | |||
| 243 | + | } | |||
| 244 | + | k++ | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // Calculate Jaro similarity | |||
| 248 | + | return (float64(matchCount)/float64(len1) + | |||
| 249 | + | float64(matchCount)/float64(len2) + | |||
| 250 | + | float64(matchCount-transpositions/2)/float64(matchCount)) / 3.0 | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | // calculateRecencyScore calculates score based on how recently the entry was visited. | |||
| 254 | + | func (fm *FuzzyMatcher) calculateRecencyScore(lastVisited sql.NullTime) float64 { | |||
| 255 | + | if !lastVisited.Valid { | |||
| 256 | + | return 0.0 | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | now := time.Now() | |||
| 260 | + | daysSince := now.Sub(lastVisited.Time).Hours() / 24 | |||
| 261 | + | ||||
| 262 | + | // Exponential decay based on days since last visit | |||
| 263 | + | decayRate := float64(fm.config.RecencyDecayDays) | |||
| 264 | + | score := math.Exp(-daysSince / decayRate) | |||
| 265 | + | ||||
| 266 | + | return score | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | // calculateVisitScore calculates score based on visit count with logarithmic scaling. | |||
| 270 | + | func (fm *FuzzyMatcher) calculateVisitScore(visitCount sql.NullInt64) float64 { | |||
| 271 | + | if !visitCount.Valid || visitCount.Int64 <= 0 { | |||
| 272 | + | return 0.0 | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // Logarithmic scaling to prevent highly visited sites from dominating | |||
| 276 | + | score := math.Log1p(float64(visitCount.Int64)) / math.Log1p(1000) // Normalize to max 1000 visits | |||
| 277 | + | if score > 1.0 { | |||
| 278 | + | score = 1.0 | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | return score | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // LevenshteinDistance calculates the Levenshtein distance between two strings. | |||
| 285 | + | func (fm *FuzzyMatcher) LevenshteinDistance(s1, s2 string) int { | |||
| 286 | + | runes1 := []rune(s1) | |||
| 287 | + | runes2 := []rune(s2) | |||
| 288 | + | ||||
| 289 | + | len1 := len(runes1) | |||
| 290 | + | len2 := len(runes2) | |||
| 291 | + | ||||
| 292 | + | // Create a matrix to store distances | |||
| 293 | + | matrix := make([][]int, len1+1) | |||
| 294 | + | for i := range matrix { | |||
| 295 | + | matrix[i] = make([]int, len2+1) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // Initialize first row and column | |||
| 299 | + | for i := 0; i <= len1; i++ { | |||
| 300 | + | matrix[i][0] = i | |||
| 301 | + | } | |||
| 302 | + | for j := 0; j <= len2; j++ { | |||
| 303 | + | matrix[0][j] = j | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // Fill the matrix | |||
| 307 | + | for i := 1; i <= len1; i++ { | |||
| 308 | + | for j := 1; j <= len2; j++ { | |||
| 309 | + | cost := 1 | |||
| 310 | + | if runes1[i-1] == runes2[j-1] { | |||
| 311 | + | cost = 0 | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | deletion := matrix[i-1][j] + 1 | |||
| 315 | + | insertion := matrix[i][j-1] + 1 | |||
| 316 | + | substitution := matrix[i-1][j-1] + cost | |||
| 317 | + | ||||
| 318 | + | matrix[i][j] = minInt(deletion, minInt(insertion, substitution)) | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | return matrix[len1][len2] | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | // LevenshteinSimilarity converts Levenshtein distance to similarity score (0.0-1.0). | |||
| 326 | + | func (fm *FuzzyMatcher) LevenshteinSimilarity(s1, s2 string) float64 { | |||
| 327 | + | if s1 == s2 { | |||
| 328 | + | return 1.0 | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | distance := fm.LevenshteinDistance(s1, s2) | |||
| 332 | + | maxLen := maxInt(utf8.RuneCountInString(s1), utf8.RuneCountInString(s2)) | |||
| 333 | + | ||||
| 334 | + | if maxLen == 0 { | |||
| 335 | + | return 1.0 | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | return 1.0 - float64(distance)/float64(maxLen) | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | // TokenizedMatch performs token-based fuzzy matching for better phrase matching. | |||
| 342 | + | func (fm *FuzzyMatcher) TokenizedMatch(query, text string) float64 { | |||
| 343 | + | queryTokens := strings.Fields(strings.ToLower(query)) | |||
| 344 | + | textTokens := strings.Fields(strings.ToLower(text)) | |||
| 345 | + | ||||
| 346 | + | if len(queryTokens) == 0 || len(textTokens) == 0 { | |||
| 347 | + | return 0.0 | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | totalScore := 0.0 | |||
| 351 | + | matchedTokens := 0 | |||
| 352 | + | ||||
| 353 | + | for _, queryToken := range queryTokens { | |||
| 354 | + | bestScore := 0.0 | |||
| 355 | + | for _, textToken := range textTokens { | |||
| 356 | + | score := fm.calculateSimilarity(queryToken, textToken) | |||
| 357 | + | if score > bestScore { | |||
| 358 | + | bestScore = score | |||
| 359 | + | } | |||
| 360 | + | } | |||
| 361 | + | if bestScore >= 0.5 { // Threshold for token match | |||
| 362 | + | totalScore += bestScore | |||
| 363 | + | matchedTokens++ | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | if matchedTokens == 0 { | |||
| 368 | + | return 0.0 | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | // Average score weighted by matched token ratio | |||
| 372 | + | avgScore := totalScore / float64(matchedTokens) | |||
| 373 | + | tokenRatio := float64(matchedTokens) / float64(len(queryTokens)) | |||
| 374 | + | ||||
| 375 | + | return avgScore * tokenRatio | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | // RankMatches re-ranks fuzzy matches based on additional criteria. | |||
| 379 | + | func (fm *FuzzyMatcher) RankMatches(matches []FuzzyMatch, query string) []FuzzyMatch { | |||
| 380 | + | if len(matches) == 0 { | |||
| 381 | + | return matches | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | // Apply additional ranking factors | |||
| 385 | + | for i := range matches { | |||
| 386 | + | match := &matches[i] | |||
| 387 | + | ||||
| 388 | + | // Boost exact matches | |||
| 389 | + | if strings.Contains(strings.ToLower(match.HistoryEntry.Url), strings.ToLower(query)) { | |||
| 390 | + | match.Score *= 1.2 | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | // Boost domain matches | |||
| 394 | + | if fm.isDomainMatch(query, match.HistoryEntry.Url) { | |||
| 395 | + | match.Score *= 1.15 | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | // Boost short URLs (likely more relevant) | |||
| 399 | + | if len(match.HistoryEntry.Url) < 50 { | |||
| 400 | + | match.Score *= 1.05 | |||
| 401 | + | } | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | // Re-sort by updated scores | |||
| 405 | + | sort.Slice(matches, func(i, j int) bool { | |||
| 406 | + | return matches[i].Score > matches[j].Score | |||
| 407 | + | }) | |||
| 408 | + | ||||
| 409 | + | return matches | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | // isDomainMatch checks if the query matches the domain of the URL. | |||
| 413 | + | func (fm *FuzzyMatcher) isDomainMatch(query, url string) bool { | |||
| 414 | + | query = strings.ToLower(strings.TrimSpace(query)) | |||
| 415 | + | ||||
| 416 | + | // Extract domain from URL | |||
| 417 | + | domain := url | |||
| 418 | + | if strings.HasPrefix(url, "http://") { | |||
| 419 | + | domain = url[7:] | |||
| 420 | + | } else if strings.HasPrefix(url, "https://") { | |||
| 421 | + | domain = url[8:] | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | // Remove path | |||
| 425 | + | if idx := strings.Index(domain, "/"); idx != -1 { | |||
| 426 | + | domain = domain[:idx] | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | // Remove port | |||
| 430 | + | if idx := strings.Index(domain, ":"); idx != -1 { | |||
| 431 | + | domain = domain[:idx] | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | domain = strings.ToLower(domain) | |||
| 435 | + | ||||
| 436 | + | // Check for exact domain match | |||
| 437 | + | if domain == query { | |||
| 438 | + | return true | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | // Check if query is part of domain (e.g., "github" in "github.com" or "www.github.com") | |||
| 442 | + | if strings.HasSuffix(domain, "."+query) || strings.HasPrefix(domain, query+".") { | |||
| 443 | + | return true | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | // Check if query matches a significant part of domain (not just a substring) | |||
| 447 | + | // Split domain by dots and check if any part matches query | |||
| 448 | + | domainParts := strings.Split(domain, ".") | |||
| 449 | + | for _, part := range domainParts { | |||
| 450 | + | if part == query { | |||
| 451 | + | return true | |||
| 452 | + | } | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | return false | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | // Helper functions | |||
| 459 | + | func minInt(a, b int) int { | |||
| 460 | + | if a < b { | |||
| 461 | + | return a | |||
| 462 | + | } | |||
| 463 | + | return b | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | func maxInt(a, b int) int { | |||
| 467 | + | if a > b { | |||
| 468 | + | return a | |||
| 469 | + | } | |||
| 470 | + | return b | |||
| 471 | + | } | |||
A internal/parser/fuzzy_test.go +520 -0
| @@ -0,0 +1,520 @@ | |||||
| 1 | + | package parser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "database/sql" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/db" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestFuzzyMatcher_JaroWinklerSimilarity(t *testing.T) { | |||
| 12 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 13 | + | ||||
| 14 | + | tests := []struct { | |||
| 15 | + | s1 string | |||
| 16 | + | s2 string | |||
| 17 | + | expected float64 | |||
| 18 | + | delta float64 // Acceptable difference for float comparison | |||
| 19 | + | }{ | |||
| 20 | + | {"", "", 1.0, 0.0}, | |||
| 21 | + | {"", "test", 0.0, 0.0}, | |||
| 22 | + | {"test", "", 0.0, 0.0}, | |||
| 23 | + | {"test", "test", 1.0, 0.0}, | |||
| 24 | + | {"github", "github", 1.0, 0.0}, | |||
| 25 | + | {"github", "gihub", 0.95, 0.05}, // High similarity | |||
| 26 | + | {"github", "bitbucket", 0.62, 0.15}, // Lower similarity | |||
| 27 | + | {"stackoverflow", "stack", 0.87, 0.05}, // Partial match | |||
| 28 | + | {"google", "goggle", 0.9, 0.1}, // Single char difference | |||
| 29 | + | {"programming", "programing", 0.95, 0.05}, // Missing char | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | for _, tt := range tests { | |||
| 33 | + | t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) { | |||
| 34 | + | result := fuzzyMatcher.jaroWinklerSimilarity(tt.s1, tt.s2) | |||
| 35 | + | ||||
| 36 | + | if result < 0.0 || result > 1.0 { | |||
| 37 | + | t.Errorf("jaroWinklerSimilarity() = %v, should be between 0.0 and 1.0", result) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | diff := result - tt.expected | |||
| 41 | + | if diff < 0 { | |||
| 42 | + | diff = -diff | |||
| 43 | + | } | |||
| 44 | + | if diff > tt.delta { | |||
| 45 | + | t.Errorf("jaroWinklerSimilarity(%q, %q) = %v, want %v ±%v", tt.s1, tt.s2, result, tt.expected, tt.delta) | |||
| 46 | + | } | |||
| 47 | + | }) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestFuzzyMatcher_LevenshteinDistance(t *testing.T) { | |||
| 52 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 53 | + | ||||
| 54 | + | tests := []struct { | |||
| 55 | + | s1 string | |||
| 56 | + | s2 string | |||
| 57 | + | expected int | |||
| 58 | + | }{ | |||
| 59 | + | {"", "", 0}, | |||
| 60 | + | {"", "test", 4}, | |||
| 61 | + | {"test", "", 4}, | |||
| 62 | + | {"test", "test", 0}, | |||
| 63 | + | {"github", "github", 0}, | |||
| 64 | + | {"github", "gihub", 1}, // 1 deletion | |||
| 65 | + | {"cat", "bat", 1}, // 1 substitution | |||
| 66 | + | {"kitten", "sitting", 3}, // Classic example | |||
| 67 | + | {"programming", "programing", 1}, // 1 deletion | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | for _, tt := range tests { | |||
| 71 | + | t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) { | |||
| 72 | + | result := fuzzyMatcher.LevenshteinDistance(tt.s1, tt.s2) | |||
| 73 | + | if result != tt.expected { | |||
| 74 | + | t.Errorf("LevenshteinDistance(%q, %q) = %v, want %v", tt.s1, tt.s2, result, tt.expected) | |||
| 75 | + | } | |||
| 76 | + | }) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestFuzzyMatcher_LevenshteinSimilarity(t *testing.T) { | |||
| 81 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 82 | + | ||||
| 83 | + | tests := []struct { | |||
| 84 | + | s1 string | |||
| 85 | + | s2 string | |||
| 86 | + | expected float64 | |||
| 87 | + | delta float64 | |||
| 88 | + | }{ | |||
| 89 | + | {"test", "test", 1.0, 0.0}, | |||
| 90 | + | {"", "", 1.0, 0.0}, | |||
| 91 | + | {"github", "gihub", 0.83, 0.05}, // 1 error out of 6 chars | |||
| 92 | + | {"cat", "bat", 0.67, 0.05}, // 1 error out of 3 chars | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | for _, tt := range tests { | |||
| 96 | + | t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) { | |||
| 97 | + | result := fuzzyMatcher.LevenshteinSimilarity(tt.s1, tt.s2) | |||
| 98 | + | ||||
| 99 | + | if result < 0.0 || result > 1.0 { | |||
| 100 | + | t.Errorf("LevenshteinSimilarity() = %v, should be between 0.0 and 1.0", result) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | diff := result - tt.expected | |||
| 104 | + | if diff < 0 { | |||
| 105 | + | diff = -diff | |||
| 106 | + | } | |||
| 107 | + | if diff > tt.delta { | |||
| 108 | + | t.Errorf("LevenshteinSimilarity(%q, %q) = %v, want %v ±%v", tt.s1, tt.s2, result, tt.expected, tt.delta) | |||
| 109 | + | } | |||
| 110 | + | }) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestFuzzyMatcher_SubstringMatch(t *testing.T) { | |||
| 115 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 116 | + | ||||
| 117 | + | tests := []struct { | |||
| 118 | + | query string | |||
| 119 | + | text string | |||
| 120 | + | expected float64 | |||
| 121 | + | delta float64 | |||
| 122 | + | }{ | |||
| 123 | + | {"", "test", 0.0, 0.0}, | |||
| 124 | + | {"test", "", 0.0, 0.0}, | |||
| 125 | + | {"github", "github.com", 0.9, 0.1}, // Full match at start gets boost | |||
| 126 | + | {"hub", "github.com", 0.3, 0.1}, // Partial match | |||
| 127 | + | {"git", "github.com", 0.45, 0.1}, // Match at beginning gets boost | |||
| 128 | + | {"com", "github.com", 0.3, 0.1}, // Match at end | |||
| 129 | + | {"xyz", "github.com", 0.0, 0.0}, // No match | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | for _, tt := range tests { | |||
| 133 | + | t.Run(tt.query+"_in_"+tt.text, func(t *testing.T) { | |||
| 134 | + | result := fuzzyMatcher.substringMatch(tt.query, tt.text) | |||
| 135 | + | ||||
| 136 | + | if result < 0.0 || result > 1.0 { | |||
| 137 | + | t.Errorf("substringMatch() = %v, should be between 0.0 and 1.0", result) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | diff := result - tt.expected | |||
| 141 | + | if diff < 0 { | |||
| 142 | + | diff = -diff | |||
| 143 | + | } | |||
| 144 | + | if diff > tt.delta { | |||
| 145 | + | t.Errorf("substringMatch(%q, %q) = %v, want %v ±%v", tt.query, tt.text, result, tt.expected, tt.delta) | |||
| 146 | + | } | |||
| 147 | + | }) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestFuzzyMatcher_TokenizedMatch(t *testing.T) { | |||
| 152 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 153 | + | ||||
| 154 | + | tests := []struct { | |||
| 155 | + | query string | |||
| 156 | + | text string | |||
| 157 | + | expected float64 | |||
| 158 | + | delta float64 | |||
| 159 | + | }{ | |||
| 160 | + | {"go programming", "The Go Programming Language", 0.8, 0.2}, | |||
| 161 | + | {"stack overflow", "Stack Overflow - Where Developers Learn", 0.8, 0.2}, | |||
| 162 | + | {"github repo", "GitHub Repository", 0.94, 0.1}, | |||
| 163 | + | {"missing words", "Something completely different", 0.34, 0.1}, | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | for _, tt := range tests { | |||
| 167 | + | t.Run(tt.query+"_vs_"+tt.text, func(t *testing.T) { | |||
| 168 | + | result := fuzzyMatcher.TokenizedMatch(tt.query, tt.text) | |||
| 169 | + | ||||
| 170 | + | if result < 0.0 || result > 1.0 { | |||
| 171 | + | t.Errorf("TokenizedMatch() = %v, should be between 0.0 and 1.0", result) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | diff := result - tt.expected | |||
| 175 | + | if diff < 0 { | |||
| 176 | + | diff = -diff | |||
| 177 | + | } | |||
| 178 | + | if diff > tt.delta { | |||
| 179 | + | t.Errorf("TokenizedMatch(%q, %q) = %v, want %v ±%v", tt.query, tt.text, result, tt.expected, tt.delta) | |||
| 180 | + | } | |||
| 181 | + | }) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func TestFuzzyMatcher_SearchHistory(t *testing.T) { | |||
| 186 | + | config := DefaultFuzzyConfig() | |||
| 187 | + | config.MinSimilarityThreshold = 0.3 | |||
| 188 | + | config.MaxResults = 10 | |||
| 189 | + | fuzzyMatcher := NewFuzzyMatcher(config) | |||
| 190 | + | ||||
| 191 | + | now := time.Now() | |||
| 192 | + | history := []*db.History{ | |||
| 193 | + | { | |||
| 194 | + | ID: 1, | |||
| 195 | + | Url: "https://github.com", | |||
| 196 | + | Title: sql.NullString{String: "GitHub", Valid: true}, | |||
| 197 | + | VisitCount: sql.NullInt64{Int64: 25, Valid: true}, | |||
| 198 | + | LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true}, | |||
| 199 | + | }, | |||
| 200 | + | { | |||
| 201 | + | ID: 2, | |||
| 202 | + | Url: "https://stackoverflow.com", | |||
| 203 | + | Title: sql.NullString{String: "Stack Overflow", Valid: true}, | |||
| 204 | + | VisitCount: sql.NullInt64{Int64: 15, Valid: true}, | |||
| 205 | + | LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true}, | |||
| 206 | + | }, | |||
| 207 | + | { | |||
| 208 | + | ID: 3, | |||
| 209 | + | Url: "https://golang.org", | |||
| 210 | + | Title: sql.NullString{String: "The Go Programming Language", Valid: true}, | |||
| 211 | + | VisitCount: sql.NullInt64{Int64: 8, Valid: true}, | |||
| 212 | + | LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true}, | |||
| 213 | + | }, | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | tests := []struct { | |||
| 217 | + | name string | |||
| 218 | + | query string | |||
| 219 | + | expectMatches int | |||
| 220 | + | expectFirst string // Expected first match URL | |||
| 221 | + | }{ | |||
| 222 | + | { | |||
| 223 | + | name: "Exact domain match", | |||
| 224 | + | query: "github", | |||
| 225 | + | expectMatches: 1, | |||
| 226 | + | expectFirst: "https://github.com", | |||
| 227 | + | }, | |||
| 228 | + | { | |||
| 229 | + | name: "Partial title match", | |||
| 230 | + | query: "programming", | |||
| 231 | + | expectMatches: 1, | |||
| 232 | + | expectFirst: "https://golang.org", | |||
| 233 | + | }, | |||
| 234 | + | { | |||
| 235 | + | name: "Multiple matches", | |||
| 236 | + | query: "o", // Should match github.com, stackoverflow.com, golang.org | |||
| 237 | + | expectMatches: 3, | |||
| 238 | + | }, | |||
| 239 | + | { | |||
| 240 | + | name: "No matches", | |||
| 241 | + | query: "nonexistent", | |||
| 242 | + | expectMatches: 0, | |||
| 243 | + | }, | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | for _, tt := range tests { | |||
| 247 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 248 | + | matches := fuzzyMatcher.SearchHistory(tt.query, history) | |||
| 249 | + | ||||
| 250 | + | if len(matches) < tt.expectMatches { | |||
| 251 | + | t.Errorf("SearchHistory() returned %d matches, want >= %d", len(matches), tt.expectMatches) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | if len(matches) > 0 && tt.expectFirst != "" { | |||
| 255 | + | if matches[0].HistoryEntry.Url != tt.expectFirst { | |||
| 256 | + | t.Errorf("SearchHistory() first match = %q, want %q", matches[0].HistoryEntry.Url, tt.expectFirst) | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // Verify scores are in descending order | |||
| 261 | + | for i := 1; i < len(matches); i++ { | |||
| 262 | + | if matches[i].Score > matches[i-1].Score { | |||
| 263 | + | t.Errorf("SearchHistory() results not sorted by score: %f > %f", matches[i].Score, matches[i-1].Score) | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // Verify all scores meet threshold | |||
| 268 | + | for i, match := range matches { | |||
| 269 | + | if match.Score < config.MinSimilarityThreshold { | |||
| 270 | + | t.Errorf("SearchHistory() match %d score %f below threshold %f", i, match.Score, config.MinSimilarityThreshold) | |||
| 271 | + | } | |||
| 272 | + | } | |||
| 273 | + | }) | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func TestFuzzyMatcher_CalculateRecencyScore(t *testing.T) { | |||
| 278 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 279 | + | now := time.Now() | |||
| 280 | + | ||||
| 281 | + | tests := []struct { | |||
| 282 | + | name string | |||
| 283 | + | lastVisit sql.NullTime | |||
| 284 | + | expected float64 | |||
| 285 | + | delta float64 | |||
| 286 | + | }{ | |||
| 287 | + | { | |||
| 288 | + | name: "Invalid time", | |||
| 289 | + | lastVisit: sql.NullTime{Valid: false}, | |||
| 290 | + | expected: 0.0, | |||
| 291 | + | delta: 0.0, | |||
| 292 | + | }, | |||
| 293 | + | { | |||
| 294 | + | name: "Recent visit (1 hour ago)", | |||
| 295 | + | lastVisit: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true}, | |||
| 296 | + | expected: 0.95, | |||
| 297 | + | delta: 0.1, | |||
| 298 | + | }, | |||
| 299 | + | { | |||
| 300 | + | name: "Old visit (30 days ago)", | |||
| 301 | + | lastVisit: sql.NullTime{Time: now.Add(-30 * 24 * time.Hour), Valid: true}, | |||
| 302 | + | expected: 0.37, | |||
| 303 | + | delta: 0.1, | |||
| 304 | + | }, | |||
| 305 | + | { | |||
| 306 | + | name: "Very old visit (90 days ago)", | |||
| 307 | + | lastVisit: sql.NullTime{Time: now.Add(-90 * 24 * time.Hour), Valid: true}, | |||
| 308 | + | expected: 0.05, | |||
| 309 | + | delta: 0.05, | |||
| 310 | + | }, | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | for _, tt := range tests { | |||
| 314 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 315 | + | result := fuzzyMatcher.calculateRecencyScore(tt.lastVisit) | |||
| 316 | + | ||||
| 317 | + | if result < 0.0 || result > 1.0 { | |||
| 318 | + | t.Errorf("calculateRecencyScore() = %v, should be between 0.0 and 1.0", result) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | diff := result - tt.expected | |||
| 322 | + | if diff < 0 { | |||
| 323 | + | diff = -diff | |||
| 324 | + | } | |||
| 325 | + | if diff > tt.delta { | |||
| 326 | + | t.Errorf("calculateRecencyScore() = %v, want %v ±%v", result, tt.expected, tt.delta) | |||
| 327 | + | } | |||
| 328 | + | }) | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | func TestFuzzyMatcher_CalculateVisitScore(t *testing.T) { | |||
| 333 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 334 | + | ||||
| 335 | + | tests := []struct { | |||
| 336 | + | name string | |||
| 337 | + | visitCount sql.NullInt64 | |||
| 338 | + | expected float64 | |||
| 339 | + | delta float64 | |||
| 340 | + | }{ | |||
| 341 | + | { | |||
| 342 | + | name: "Invalid count", | |||
| 343 | + | visitCount: sql.NullInt64{Valid: false}, | |||
| 344 | + | expected: 0.0, | |||
| 345 | + | delta: 0.0, | |||
| 346 | + | }, | |||
| 347 | + | { | |||
| 348 | + | name: "Zero visits", | |||
| 349 | + | visitCount: sql.NullInt64{Int64: 0, Valid: true}, | |||
| 350 | + | expected: 0.0, | |||
| 351 | + | delta: 0.0, | |||
| 352 | + | }, | |||
| 353 | + | { | |||
| 354 | + | name: "Single visit", | |||
| 355 | + | visitCount: sql.NullInt64{Int64: 1, Valid: true}, | |||
| 356 | + | expected: 0.1, | |||
| 357 | + | delta: 0.05, | |||
| 358 | + | }, | |||
| 359 | + | { | |||
| 360 | + | name: "Many visits", | |||
| 361 | + | visitCount: sql.NullInt64{Int64: 100, Valid: true}, | |||
| 362 | + | expected: 0.67, | |||
| 363 | + | delta: 0.1, | |||
| 364 | + | }, | |||
| 365 | + | { | |||
| 366 | + | name: "Very many visits", | |||
| 367 | + | visitCount: sql.NullInt64{Int64: 1000, Valid: true}, | |||
| 368 | + | expected: 1.0, | |||
| 369 | + | delta: 0.01, | |||
| 370 | + | }, | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | for _, tt := range tests { | |||
| 374 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 375 | + | result := fuzzyMatcher.calculateVisitScore(tt.visitCount) | |||
| 376 | + | ||||
| 377 | + | if result < 0.0 || result > 1.0 { | |||
| 378 | + | t.Errorf("calculateVisitScore() = %v, should be between 0.0 and 1.0", result) | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | diff := result - tt.expected | |||
| 382 | + | if diff < 0 { | |||
| 383 | + | diff = -diff | |||
| 384 | + | } | |||
| 385 | + | if diff > tt.delta { | |||
| 386 | + | t.Errorf("calculateVisitScore() = %v, want %v ±%v", result, tt.expected, tt.delta) | |||
| 387 | + | } | |||
| 388 | + | }) | |||
| 389 | + | } | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | func TestFuzzyMatcher_IsDomainMatch(t *testing.T) { | |||
| 393 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 394 | + | ||||
| 395 | + | tests := []struct { | |||
| 396 | + | query string | |||
| 397 | + | url string | |||
| 398 | + | expected bool | |||
| 399 | + | }{ | |||
| 400 | + | {"github", "https://github.com", true}, | |||
| 401 | + | {"github", "https://www.github.com", true}, | |||
| 402 | + | {"github", "https://api.github.com", true}, | |||
| 403 | + | {"github", "https://stackoverflow.com", false}, | |||
| 404 | + | {"stackoverflow", "https://stackoverflow.com/questions", true}, | |||
| 405 | + | {"google", "https://www.google.com/search", true}, | |||
| 406 | + | {"localhost", "http://localhost:8080", true}, | |||
| 407 | + | {"example", "https://example.org", true}, // Matches domain part | |||
| 408 | + | {"sub.example", "https://sub.example.com", true}, | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | for _, tt := range tests { | |||
| 412 | + | t.Run(tt.query+"_vs_"+tt.url, func(t *testing.T) { | |||
| 413 | + | result := fuzzyMatcher.isDomainMatch(tt.query, tt.url) | |||
| 414 | + | if result != tt.expected { | |||
| 415 | + | t.Errorf("isDomainMatch(%q, %q) = %v, want %v", tt.query, tt.url, result, tt.expected) | |||
| 416 | + | } | |||
| 417 | + | }) | |||
| 418 | + | } | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | func TestFuzzyMatcher_RankMatches(t *testing.T) { | |||
| 422 | + | config := DefaultFuzzyConfig() | |||
| 423 | + | fuzzyMatcher := NewFuzzyMatcher(config) | |||
| 424 | + | ||||
| 425 | + | now := time.Now() | |||
| 426 | + | matches := []FuzzyMatch{ | |||
| 427 | + | { | |||
| 428 | + | HistoryEntry: &db.History{ | |||
| 429 | + | Url: "https://example.com/very/long/path/that/makes/url/longer", | |||
| 430 | + | VisitCount: sql.NullInt64{Int64: 5, Valid: true}, | |||
| 431 | + | LastVisited: sql.NullTime{Time: now.Add(-10 * time.Hour), Valid: true}, | |||
| 432 | + | }, | |||
| 433 | + | Score: 0.5, | |||
| 434 | + | }, | |||
| 435 | + | { | |||
| 436 | + | HistoryEntry: &db.History{ | |||
| 437 | + | Url: "https://github.com", | |||
| 438 | + | VisitCount: sql.NullInt64{Int64: 25, Valid: true}, | |||
| 439 | + | LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true}, | |||
| 440 | + | }, | |||
| 441 | + | Score: 0.7, | |||
| 442 | + | }, | |||
| 443 | + | { | |||
| 444 | + | HistoryEntry: &db.History{ | |||
| 445 | + | Url: "https://test.com", | |||
| 446 | + | VisitCount: sql.NullInt64{Int64: 1, Valid: true}, | |||
| 447 | + | LastVisited: sql.NullTime{Time: now.Add(-24 * time.Hour), Valid: true}, | |||
| 448 | + | }, | |||
| 449 | + | Score: 0.6, | |||
| 450 | + | }, | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | rankedMatches := fuzzyMatcher.RankMatches(matches, "github") | |||
| 454 | + | ||||
| 455 | + | // Verify the matches are still sorted by score (descending) | |||
| 456 | + | for i := 1; i < len(rankedMatches); i++ { | |||
| 457 | + | if rankedMatches[i].Score > rankedMatches[i-1].Score { | |||
| 458 | + | t.Errorf("RankMatches() results not sorted: %f > %f", rankedMatches[i].Score, rankedMatches[i-1].Score) | |||
| 459 | + | } | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | // The github.com match should get a boost and likely be first | |||
| 463 | + | githubFound := false | |||
| 464 | + | for i, match := range rankedMatches { | |||
| 465 | + | if match.HistoryEntry.Url == "https://github.com" { | |||
| 466 | + | githubFound = true | |||
| 467 | + | // Should be boosted due to domain match | |||
| 468 | + | if i > 0 && rankedMatches[0].HistoryEntry.Url != "https://github.com" { | |||
| 469 | + | // It's possible another match scored higher even after boost | |||
| 470 | + | t.Logf("GitHub match at position %d with score %f", i, match.Score) | |||
| 471 | + | } | |||
| 472 | + | break | |||
| 473 | + | } | |||
| 474 | + | } | |||
| 475 | + | ||||
| 476 | + | if !githubFound { | |||
| 477 | + | t.Errorf("RankMatches() didn't preserve GitHub match") | |||
| 478 | + | } | |||
| 479 | + | } | |||
| 480 | + | ||||
| 481 | + | func BenchmarkFuzzyMatcher_JaroWinklerSimilarity(b *testing.B) { | |||
| 482 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 483 | + | ||||
| 484 | + | b.ResetTimer() | |||
| 485 | + | for i := 0; i < b.N; i++ { | |||
| 486 | + | fuzzyMatcher.jaroWinklerSimilarity("github", "gihub") | |||
| 487 | + | } | |||
| 488 | + | } | |||
| 489 | + | ||||
| 490 | + | func BenchmarkFuzzyMatcher_LevenshteinDistance(b *testing.B) { | |||
| 491 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 492 | + | ||||
| 493 | + | b.ResetTimer() | |||
| 494 | + | for i := 0; i < b.N; i++ { | |||
| 495 | + | fuzzyMatcher.LevenshteinDistance("programming", "programing") | |||
| 496 | + | } | |||
| 497 | + | } | |||
| 498 | + | ||||
| 499 | + | func BenchmarkFuzzyMatcher_SearchHistory(b *testing.B) { | |||
| 500 | + | config := DefaultFuzzyConfig() | |||
| 501 | + | fuzzyMatcher := NewFuzzyMatcher(config) | |||
| 502 | + | ||||
| 503 | + | now := time.Now() | |||
| 504 | + | // Create larger history set for more realistic benchmark | |||
| 505 | + | history := make([]*db.History, 100) | |||
| 506 | + | for i := 0; i < 100; i++ { | |||
| 507 | + | history[i] = &db.History{ | |||
| 508 | + | ID: int64(i + 1), | |||
| 509 | + | Url: "https://example" + string(rune(i%26+'a')) + ".com", | |||
| 510 | + | Title: sql.NullString{String: "Test Site " + string(rune(i%26+'A')), Valid: true}, | |||
| 511 | + | VisitCount: sql.NullInt64{Int64: int64(i % 50), Valid: true}, | |||
| 512 | + | LastVisited: sql.NullTime{Time: now.Add(-time.Duration(i) * time.Hour), Valid: true}, | |||
| 513 | + | } | |||
| 514 | + | } | |||
| 515 | + | ||||
| 516 | + | b.ResetTimer() | |||
| 517 | + | for i := 0; i < b.N; i++ { | |||
| 518 | + | fuzzyMatcher.SearchHistory("example", history) | |||
| 519 | + | } | |||
| 520 | + | } | |||
A internal/parser/history.go +407 -0
| @@ -0,0 +1,407 @@ | |||||
| 1 | + | package parser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/db" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // HistorySearcher provides history search integration with fuzzy matching. | |||
| 12 | + | type HistorySearcher struct { | |||
| 13 | + | provider HistoryProvider | |||
| 14 | + | fuzzyMatcher *FuzzyMatcher | |||
| 15 | + | fuzzyConfig *FuzzyConfig | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // NewHistorySearcher creates a new HistorySearcher. | |||
| 19 | + | func NewHistorySearcher(provider HistoryProvider, fuzzyConfig *FuzzyConfig) *HistorySearcher { | |||
| 20 | + | if fuzzyConfig == nil { | |||
| 21 | + | fuzzyConfig = DefaultFuzzyConfig() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | return &HistorySearcher{ | |||
| 25 | + | provider: provider, | |||
| 26 | + | fuzzyMatcher: NewFuzzyMatcher(fuzzyConfig), | |||
| 27 | + | fuzzyConfig: fuzzyConfig, | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // SearchWithFuzzy performs fuzzy search on history entries. | |||
| 32 | + | func (hs *HistorySearcher) SearchWithFuzzy(query string, options ...SearchOption) (*HistorySearchResult, error) { | |||
| 33 | + | if query == "" { | |||
| 34 | + | return &HistorySearchResult{}, nil | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Apply search options | |||
| 38 | + | searchConfig := &SearchConfig{ | |||
| 39 | + | Limit: hs.fuzzyConfig.MaxResults, | |||
| 40 | + | MinSimilarityThreshold: hs.fuzzyConfig.MinSimilarityThreshold, | |||
| 41 | + | IncludeExactMatches: true, | |||
| 42 | + | IncludeFuzzyMatches: true, | |||
| 43 | + | SortByRelevance: true, | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | for _, opt := range options { | |||
| 47 | + | opt(searchConfig) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | startTime := time.Now() | |||
| 51 | + | ||||
| 52 | + | // Get all history for fuzzy search | |||
| 53 | + | allHistory, err := hs.provider.GetAllHistory() | |||
| 54 | + | if err != nil { | |||
| 55 | + | return nil, fmt.Errorf("failed to retrieve history: %w", err) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | result := &HistorySearchResult{ | |||
| 59 | + | Query: query, | |||
| 60 | + | TotalEntries: len(allHistory), | |||
| 61 | + | SearchTime: 0, // Will be set below | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // Perform exact search first if requested | |||
| 65 | + | if searchConfig.IncludeExactMatches { | |||
| 66 | + | exactMatches, err := hs.provider.SearchHistory(query, searchConfig.Limit) | |||
| 67 | + | if err == nil && len(exactMatches) > 0 { | |||
| 68 | + | // Convert to FuzzyMatch format with perfect scores | |||
| 69 | + | for _, entry := range exactMatches { | |||
| 70 | + | match := FuzzyMatch{ | |||
| 71 | + | HistoryEntry: entry, | |||
| 72 | + | Score: 1.0, | |||
| 73 | + | URLScore: 1.0, | |||
| 74 | + | TitleScore: 1.0, | |||
| 75 | + | RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited), | |||
| 76 | + | VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount), | |||
| 77 | + | MatchedField: "exact", | |||
| 78 | + | } | |||
| 79 | + | result.ExactMatches = append(result.ExactMatches, match) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // Perform fuzzy search if requested | |||
| 85 | + | if searchConfig.IncludeFuzzyMatches { | |||
| 86 | + | // Create temporary config with search options | |||
| 87 | + | tempConfig := *hs.fuzzyConfig | |||
| 88 | + | tempConfig.MinSimilarityThreshold = searchConfig.MinSimilarityThreshold | |||
| 89 | + | tempConfig.MaxResults = searchConfig.Limit | |||
| 90 | + | ||||
| 91 | + | tempMatcher := NewFuzzyMatcher(&tempConfig) | |||
| 92 | + | fuzzyMatches := tempMatcher.SearchHistory(query, allHistory) | |||
| 93 | + | ||||
| 94 | + | // Filter out matches that are already in exact matches | |||
| 95 | + | exactURLs := make(map[string]bool) | |||
| 96 | + | for _, exact := range result.ExactMatches { | |||
| 97 | + | exactURLs[exact.HistoryEntry.Url] = true | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | for _, match := range fuzzyMatches { | |||
| 101 | + | if !exactURLs[match.HistoryEntry.Url] { | |||
| 102 | + | result.FuzzyMatches = append(result.FuzzyMatches, match) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Combine and sort results if requested | |||
| 108 | + | if searchConfig.SortByRelevance { | |||
| 109 | + | result.RankedMatches = hs.combineAndRankMatches(result.ExactMatches, result.FuzzyMatches, query) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Limit final results | |||
| 113 | + | if len(result.RankedMatches) > searchConfig.Limit { | |||
| 114 | + | result.RankedMatches = result.RankedMatches[:searchConfig.Limit] | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | result.MatchCount = len(result.RankedMatches) | |||
| 118 | + | result.SearchTime = time.Since(startTime) | |||
| 119 | + | ||||
| 120 | + | return result, nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // SearchByDomain searches history entries by domain with fuzzy matching. | |||
| 124 | + | func (hs *HistorySearcher) SearchByDomain(domain string, options ...SearchOption) (*HistorySearchResult, error) { | |||
| 125 | + | // Get all history entries | |||
| 126 | + | allHistory, err := hs.provider.GetAllHistory() | |||
| 127 | + | if err != nil { | |||
| 128 | + | return nil, fmt.Errorf("failed to retrieve history: %w", err) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // Filter by domain and apply fuzzy matching | |||
| 132 | + | domainEntries := make([]*db.History, 0) | |||
| 133 | + | domain = strings.ToLower(domain) | |||
| 134 | + | ||||
| 135 | + | for _, entry := range allHistory { | |||
| 136 | + | entryDomain := hs.extractDomain(entry.Url) | |||
| 137 | + | if strings.Contains(strings.ToLower(entryDomain), domain) { | |||
| 138 | + | domainEntries = append(domainEntries, entry) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // If we have domain matches, rank them by fuzzy score | |||
| 143 | + | matches := make([]FuzzyMatch, 0) | |||
| 144 | + | for _, entry := range domainEntries { | |||
| 145 | + | match := hs.fuzzyMatcher.matchHistoryEntry(domain, entry) | |||
| 146 | + | matches = append(matches, match) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Sort by score | |||
| 150 | + | matches = hs.fuzzyMatcher.RankMatches(matches, domain) | |||
| 151 | + | ||||
| 152 | + | return &HistorySearchResult{ | |||
| 153 | + | Query: domain, | |||
| 154 | + | TotalEntries: len(allHistory), | |||
| 155 | + | MatchCount: len(matches), | |||
| 156 | + | RankedMatches: matches, | |||
| 157 | + | SearchTime: time.Since(time.Now()), | |||
| 158 | + | }, nil | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // SearchByTimeRange searches history entries within a time range with fuzzy matching. | |||
| 162 | + | func (hs *HistorySearcher) SearchByTimeRange(query string, startTime, endTime time.Time) (*HistorySearchResult, error) { | |||
| 163 | + | // Get all history entries | |||
| 164 | + | allHistory, err := hs.provider.GetAllHistory() | |||
| 165 | + | if err != nil { | |||
| 166 | + | return nil, fmt.Errorf("failed to retrieve history: %w", err) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // Filter by time range | |||
| 170 | + | filteredHistory := make([]*db.History, 0) | |||
| 171 | + | for _, entry := range allHistory { | |||
| 172 | + | if entry.LastVisited.Valid { | |||
| 173 | + | visitTime := entry.LastVisited.Time | |||
| 174 | + | if visitTime.After(startTime) && visitTime.Before(endTime) { | |||
| 175 | + | filteredHistory = append(filteredHistory, entry) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // Perform fuzzy search on filtered entries | |||
| 181 | + | matches := hs.fuzzyMatcher.SearchHistory(query, filteredHistory) | |||
| 182 | + | ||||
| 183 | + | return &HistorySearchResult{ | |||
| 184 | + | Query: query, | |||
| 185 | + | TotalEntries: len(filteredHistory), | |||
| 186 | + | MatchCount: len(matches), | |||
| 187 | + | RankedMatches: matches, | |||
| 188 | + | SearchTime: time.Since(time.Now()), | |||
| 189 | + | }, nil | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // GetTopVisited returns the most visited sites with optional fuzzy filtering. | |||
| 193 | + | func (hs *HistorySearcher) GetTopVisited(limit int, filterQuery string) ([]FuzzyMatch, error) { | |||
| 194 | + | allHistory, err := hs.provider.GetAllHistory() | |||
| 195 | + | if err != nil { | |||
| 196 | + | return nil, fmt.Errorf("failed to retrieve history: %w", err) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // Sort by visit count | |||
| 200 | + | matches := make([]FuzzyMatch, 0) | |||
| 201 | + | for _, entry := range allHistory { | |||
| 202 | + | // Apply filter if provided | |||
| 203 | + | if filterQuery != "" { | |||
| 204 | + | match := hs.fuzzyMatcher.matchHistoryEntry(filterQuery, entry) | |||
| 205 | + | if match.Score < hs.fuzzyConfig.MinSimilarityThreshold { | |||
| 206 | + | continue | |||
| 207 | + | } | |||
| 208 | + | matches = append(matches, match) | |||
| 209 | + | } else { | |||
| 210 | + | // No filter, just create basic match with visit score | |||
| 211 | + | match := FuzzyMatch{ | |||
| 212 | + | HistoryEntry: entry, | |||
| 213 | + | Score: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount), | |||
| 214 | + | VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount), | |||
| 215 | + | RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited), | |||
| 216 | + | MatchedField: "visit_count", | |||
| 217 | + | } | |||
| 218 | + | matches = append(matches, match) | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // Sort by visit count (descending) | |||
| 223 | + | for i := 0; i < len(matches); i++ { | |||
| 224 | + | for j := i + 1; j < len(matches); j++ { | |||
| 225 | + | iVisits := int64(0) | |||
| 226 | + | jVisits := int64(0) | |||
| 227 | + | if matches[i].HistoryEntry.VisitCount.Valid { | |||
| 228 | + | iVisits = matches[i].HistoryEntry.VisitCount.Int64 | |||
| 229 | + | } | |||
| 230 | + | if matches[j].HistoryEntry.VisitCount.Valid { | |||
| 231 | + | jVisits = matches[j].HistoryEntry.VisitCount.Int64 | |||
| 232 | + | } | |||
| 233 | + | if jVisits > iVisits { | |||
| 234 | + | matches[i], matches[j] = matches[j], matches[i] | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // Limit results | |||
| 240 | + | if len(matches) > limit { | |||
| 241 | + | matches = matches[:limit] | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | return matches, nil | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // GetRecentWithFuzzy returns recent entries with optional fuzzy filtering. | |||
| 248 | + | func (hs *HistorySearcher) GetRecentWithFuzzy(limit int, filterQuery string) ([]FuzzyMatch, error) { | |||
| 249 | + | recentHistory, err := hs.provider.GetRecentHistory(limit * 2) // Get more to filter | |||
| 250 | + | if err != nil { | |||
| 251 | + | return nil, fmt.Errorf("failed to retrieve recent history: %w", err) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | if filterQuery == "" { | |||
| 255 | + | // No filter, convert to FuzzyMatch format | |||
| 256 | + | matches := make([]FuzzyMatch, 0, len(recentHistory)) | |||
| 257 | + | for _, entry := range recentHistory { | |||
| 258 | + | match := FuzzyMatch{ | |||
| 259 | + | HistoryEntry: entry, | |||
| 260 | + | Score: 1.0, // Recent entries get full score | |||
| 261 | + | RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited), | |||
| 262 | + | VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount), | |||
| 263 | + | MatchedField: "recent", | |||
| 264 | + | } | |||
| 265 | + | matches = append(matches, match) | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | if len(matches) > limit { | |||
| 269 | + | matches = matches[:limit] | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | return matches, nil | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // Apply fuzzy filter | |||
| 276 | + | matches := hs.fuzzyMatcher.SearchHistory(filterQuery, recentHistory) | |||
| 277 | + | if len(matches) > limit { | |||
| 278 | + | matches = matches[:limit] | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | return matches, nil | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // combineAndRankMatches combines exact and fuzzy matches with proper ranking. | |||
| 285 | + | func (hs *HistorySearcher) combineAndRankMatches(exactMatches, fuzzyMatches []FuzzyMatch, query string) []FuzzyMatch { | |||
| 286 | + | combined := make([]FuzzyMatch, 0, len(exactMatches)+len(fuzzyMatches)) | |||
| 287 | + | ||||
| 288 | + | // Add exact matches first (they get priority) | |||
| 289 | + | combined = append(combined, exactMatches...) | |||
| 290 | + | ||||
| 291 | + | // Add fuzzy matches | |||
| 292 | + | combined = append(combined, fuzzyMatches...) | |||
| 293 | + | ||||
| 294 | + | // Re-rank all matches | |||
| 295 | + | return hs.fuzzyMatcher.RankMatches(combined, query) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // extractDomain extracts domain from URL for domain-based searches. | |||
| 299 | + | func (hs *HistorySearcher) extractDomain(url string) string { | |||
| 300 | + | // Simple domain extraction | |||
| 301 | + | domain := url | |||
| 302 | + | if strings.HasPrefix(domain, "http://") { | |||
| 303 | + | domain = domain[7:] | |||
| 304 | + | } else if strings.HasPrefix(domain, "https://") { | |||
| 305 | + | domain = domain[8:] | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // Remove path | |||
| 309 | + | if idx := strings.Index(domain, "/"); idx != -1 { | |||
| 310 | + | domain = domain[:idx] | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | // Remove port | |||
| 314 | + | if idx := strings.LastIndex(domain, ":"); idx != -1 { | |||
| 315 | + | domain = domain[:idx] | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | return domain | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | // HistorySearchResult represents the result of a history search operation. | |||
| 322 | + | type HistorySearchResult struct { | |||
| 323 | + | Query string `json:"query"` | |||
| 324 | + | TotalEntries int `json:"total_entries"` | |||
| 325 | + | MatchCount int `json:"match_count"` | |||
| 326 | + | ExactMatches []FuzzyMatch `json:"exact_matches,omitempty"` | |||
| 327 | + | FuzzyMatches []FuzzyMatch `json:"fuzzy_matches,omitempty"` | |||
| 328 | + | RankedMatches []FuzzyMatch `json:"ranked_matches"` | |||
| 329 | + | SearchTime time.Duration `json:"search_time"` | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | // SearchConfig holds configuration for history search operations. | |||
| 333 | + | type SearchConfig struct { | |||
| 334 | + | Limit int `json:"limit"` | |||
| 335 | + | MinSimilarityThreshold float64 `json:"min_similarity_threshold"` | |||
| 336 | + | IncludeExactMatches bool `json:"include_exact_matches"` | |||
| 337 | + | IncludeFuzzyMatches bool `json:"include_fuzzy_matches"` | |||
| 338 | + | SortByRelevance bool `json:"sort_by_relevance"` | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | // SearchOption is a functional option for configuring search operations. | |||
| 342 | + | type SearchOption func(*SearchConfig) | |||
| 343 | + | ||||
| 344 | + | // WithLimit sets the maximum number of results to return. | |||
| 345 | + | func WithLimit(limit int) SearchOption { | |||
| 346 | + | return func(config *SearchConfig) { | |||
| 347 | + | config.Limit = limit | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | // WithSimilarityThreshold sets the minimum similarity threshold for matches. | |||
| 352 | + | func WithSimilarityThreshold(threshold float64) SearchOption { | |||
| 353 | + | return func(config *SearchConfig) { | |||
| 354 | + | config.MinSimilarityThreshold = threshold | |||
| 355 | + | } | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | // WithExactMatches enables/disables exact match inclusion. | |||
| 359 | + | func WithExactMatches(include bool) SearchOption { | |||
| 360 | + | return func(config *SearchConfig) { | |||
| 361 | + | config.IncludeExactMatches = include | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | // WithFuzzyMatches enables/disables fuzzy match inclusion. | |||
| 366 | + | func WithFuzzyMatches(include bool) SearchOption { | |||
| 367 | + | return func(config *SearchConfig) { | |||
| 368 | + | config.IncludeFuzzyMatches = include | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | // WithRelevanceSort enables/disables relevance-based sorting. | |||
| 373 | + | func WithRelevanceSort(enable bool) SearchOption { | |||
| 374 | + | return func(config *SearchConfig) { | |||
| 375 | + | config.SortByRelevance = enable | |||
| 376 | + | } | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | // GetBestMatch returns the single best match for a query. | |||
| 380 | + | func (hs *HistorySearcher) GetBestMatch(query string) (*FuzzyMatch, error) { | |||
| 381 | + | result, err := hs.SearchWithFuzzy(query, WithLimit(1)) | |||
| 382 | + | if err != nil { | |||
| 383 | + | return nil, err | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | if len(result.RankedMatches) == 0 { | |||
| 387 | + | return nil, nil | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | return &result.RankedMatches[0], nil | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | // GetMatchesByScore returns matches above a specific score threshold. | |||
| 394 | + | func (hs *HistorySearcher) GetMatchesByScore(query string, minScore float64) ([]FuzzyMatch, error) { | |||
| 395 | + | result, err := hs.SearchWithFuzzy(query, WithSimilarityThreshold(minScore)) | |||
| 396 | + | if err != nil { | |||
| 397 | + | return nil, err | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | return result.RankedMatches, nil | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | // UpdateFuzzyConfig updates the fuzzy matching configuration. | |||
| 404 | + | func (hs *HistorySearcher) UpdateFuzzyConfig(config *FuzzyConfig) { | |||
| 405 | + | hs.fuzzyConfig = config | |||
| 406 | + | hs.fuzzyMatcher = NewFuzzyMatcher(config) | |||
| 407 | + | } | |||
A internal/parser/parser.go +355 -0
| @@ -0,0 +1,355 @@ | |||||
| 1 | + | package parser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/config" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // ParseInput parses user input and returns a ParseResult with URL and metadata. | |||
| 12 | + | // | |||
| 13 | + | // Learning Behavior: When users search with shortcuts like "g: viper cli" and visit | |||
| 14 | + | // results, those URLs are recorded in history. Future searches for "viper cli" will | |||
| 15 | + | // find the GitHub page in history via fuzzy matching, creating intelligent learning. | |||
| 16 | + | func (p *Parser) ParseInput(input string) (*ParseResult, error) { | |||
| 17 | + | startTime := time.Now() | |||
| 18 | + | ||||
| 19 | + | // Sanitize input | |||
| 20 | + | validator := NewURLValidator() | |||
| 21 | + | cleanInput := validator.SanitizeInput(input) | |||
| 22 | + | ||||
| 23 | + | if cleanInput == "" { | |||
| 24 | + | return &ParseResult{ | |||
| 25 | + | Type: InputTypeFallbackSearch, | |||
| 26 | + | URL: p.buildSearchURL("", cleanInput), | |||
| 27 | + | Query: input, | |||
| 28 | + | Confidence: 0.0, | |||
| 29 | + | ProcessingTime: time.Since(startTime), | |||
| 30 | + | }, nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // Determine input type | |||
| 34 | + | inputType := validator.GetURLType(cleanInput) | |||
| 35 | + | ||||
| 36 | + | switch inputType { | |||
| 37 | + | case InputTypeDirectURL: | |||
| 38 | + | return p.parseDirectURL(cleanInput, input, startTime) | |||
| 39 | + | ||||
| 40 | + | case InputTypeSearchShortcut: | |||
| 41 | + | return p.parseSearchShortcut(cleanInput, input, startTime) | |||
| 42 | + | ||||
| 43 | + | case InputTypeHistorySearch: | |||
| 44 | + | return p.parseHistorySearch(cleanInput, input, startTime) | |||
| 45 | + | ||||
| 46 | + | default: | |||
| 47 | + | return p.parseFallbackSearch(cleanInput, input, startTime) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // parseDirectURL handles direct URL inputs. | |||
| 52 | + | func (p *Parser) parseDirectURL(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) { | |||
| 53 | + | validator := NewURLValidator() | |||
| 54 | + | normalizedURL := validator.NormalizeURL(cleanInput) | |||
| 55 | + | ||||
| 56 | + | return &ParseResult{ | |||
| 57 | + | Type: InputTypeDirectURL, | |||
| 58 | + | URL: normalizedURL, | |||
| 59 | + | Query: originalInput, | |||
| 60 | + | Confidence: 1.0, // High confidence for direct URLs | |||
| 61 | + | ProcessingTime: time.Since(startTime), | |||
| 62 | + | }, nil | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // parseSearchShortcut handles search shortcut inputs. | |||
| 66 | + | func (p *Parser) parseSearchShortcut(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) { | |||
| 67 | + | validator := NewURLValidator() | |||
| 68 | + | isShortcut, shortcutKey, query := validator.IsSearchShortcut(cleanInput) | |||
| 69 | + | ||||
| 70 | + | if !isShortcut { | |||
| 71 | + | return nil, fmt.Errorf("input is not a valid search shortcut") | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // Look up the shortcut in configuration | |||
| 75 | + | shortcuts := p.config.SearchShortcuts | |||
| 76 | + | shortcutConfig, exists := shortcuts[shortcutKey] | |||
| 77 | + | if !exists { | |||
| 78 | + | // Unknown shortcut, treat as search query | |||
| 79 | + | return p.parseHistorySearch(cleanInput, originalInput, startTime) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Build URL from shortcut template | |||
| 83 | + | finalURL, err := p.processShortcut(shortcutKey, query, shortcutConfig) | |||
| 84 | + | if err != nil { | |||
| 85 | + | return nil, fmt.Errorf("failed to process shortcut: %w", err) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | return &ParseResult{ | |||
| 89 | + | Type: InputTypeSearchShortcut, | |||
| 90 | + | URL: finalURL, | |||
| 91 | + | Query: originalInput, | |||
| 92 | + | Confidence: 0.95, // High confidence for known shortcuts | |||
| 93 | + | Shortcut: &DetectedShortcut{ | |||
| 94 | + | Key: shortcutKey, | |||
| 95 | + | Query: query, | |||
| 96 | + | URL: finalURL, | |||
| 97 | + | Description: shortcutConfig.Description, | |||
| 98 | + | }, | |||
| 99 | + | ProcessingTime: time.Since(startTime), | |||
| 100 | + | }, nil | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // parseHistorySearch handles history search with fuzzy finding. | |||
| 104 | + | func (p *Parser) parseHistorySearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) { | |||
| 105 | + | // Get history entries for fuzzy search | |||
| 106 | + | history, err := p.historyProvider.GetAllHistory() | |||
| 107 | + | if err != nil { | |||
| 108 | + | // If history search fails, fall back to web search | |||
| 109 | + | return p.parseFallbackSearch(cleanInput, originalInput, startTime) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Perform fuzzy search | |||
| 113 | + | fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig) | |||
| 114 | + | matches := fuzzyMatcher.SearchHistory(cleanInput, history) | |||
| 115 | + | ||||
| 116 | + | // Check if we have good matches | |||
| 117 | + | if len(matches) > 0 && matches[0].Score >= p.fuzzyConfig.MinSimilarityThreshold { | |||
| 118 | + | // Use the best match | |||
| 119 | + | bestMatch := matches[0] | |||
| 120 | + | ||||
| 121 | + | return &ParseResult{ | |||
| 122 | + | Type: InputTypeHistorySearch, | |||
| 123 | + | URL: bestMatch.HistoryEntry.Url, | |||
| 124 | + | Query: originalInput, | |||
| 125 | + | Confidence: bestMatch.Score, | |||
| 126 | + | FuzzyMatches: matches, | |||
| 127 | + | ProcessingTime: time.Since(startTime), | |||
| 128 | + | }, nil | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // No good history matches, fall back to search | |||
| 132 | + | fallbackResult, err := p.parseFallbackSearch(cleanInput, originalInput, startTime) | |||
| 133 | + | if err != nil { | |||
| 134 | + | return nil, err | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // Include the fuzzy matches even though we're falling back | |||
| 138 | + | fallbackResult.FuzzyMatches = matches | |||
| 139 | + | ||||
| 140 | + | return fallbackResult, nil | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // parseFallbackSearch handles fallback web search. | |||
| 144 | + | func (p *Parser) parseFallbackSearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) { | |||
| 145 | + | // Use default search shortcut (usually "g" for Google) | |||
| 146 | + | searchURL := p.buildSearchURL("", cleanInput) | |||
| 147 | + | ||||
| 148 | + | return &ParseResult{ | |||
| 149 | + | Type: InputTypeFallbackSearch, | |||
| 150 | + | URL: searchURL, | |||
| 151 | + | Query: originalInput, | |||
| 152 | + | Confidence: 0.1, // Low confidence for fallback | |||
| 153 | + | ProcessingTime: time.Since(startTime), | |||
| 154 | + | }, nil | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // processShortcut processes a search shortcut and builds the final URL. | |||
| 158 | + | func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) { | |||
| 159 | + | urlTemplate := shortcut.URL | |||
| 160 | + | ||||
| 161 | + | // Replace placeholder with query | |||
| 162 | + | finalURL := strings.ReplaceAll(urlTemplate, "{query}", query) | |||
| 163 | + | ||||
| 164 | + | // Validate the resulting URL | |||
| 165 | + | validator := NewURLValidator() | |||
| 166 | + | if !validator.IsValidURL(finalURL) && !strings.HasPrefix(finalURL, "http") { | |||
| 167 | + | return "", fmt.Errorf("invalid URL generated from shortcut template: %s", finalURL) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | return finalURL, nil | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // buildSearchURL builds a search URL using the default search engine. | |||
| 174 | + | func (p *Parser) buildSearchURL(searchEngine, query string) string { | |||
| 175 | + | // If no specific search engine, use Google as default | |||
| 176 | + | if searchEngine == "" { | |||
| 177 | + | defaultShortcut, exists := p.config.SearchShortcuts["g"] | |||
| 178 | + | if exists { | |||
| 179 | + | url := strings.ReplaceAll(defaultShortcut.URL, "{query}", query) | |||
| 180 | + | return url | |||
| 181 | + | } | |||
| 182 | + | // Fallback to Google if no "g" shortcut configured | |||
| 183 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", query) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // Use specified search engine | |||
| 187 | + | shortcut, exists := p.config.SearchShortcuts[searchEngine] | |||
| 188 | + | if !exists { | |||
| 189 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", query) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | return strings.ReplaceAll(shortcut.URL, "{query}", query) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // FuzzySearchHistory performs fuzzy search on history and returns ranked matches. | |||
| 196 | + | func (p *Parser) FuzzySearchHistory(query string, threshold float64) ([]FuzzyMatch, error) { | |||
| 197 | + | if query == "" { | |||
| 198 | + | return nil, nil | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // Get all history entries | |||
| 202 | + | history, err := p.historyProvider.GetAllHistory() | |||
| 203 | + | if err != nil { | |||
| 204 | + | return nil, fmt.Errorf("failed to retrieve history: %w", err) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // Create fuzzy config with custom threshold if provided | |||
| 208 | + | fuzzyConfig := *p.fuzzyConfig | |||
| 209 | + | if threshold > 0 && threshold <= 1.0 { | |||
| 210 | + | fuzzyConfig.MinSimilarityThreshold = threshold | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // Perform fuzzy search | |||
| 214 | + | fuzzyMatcher := NewFuzzyMatcher(&fuzzyConfig) | |||
| 215 | + | matches := fuzzyMatcher.SearchHistory(query, history) | |||
| 216 | + | ||||
| 217 | + | // Apply additional ranking | |||
| 218 | + | matches = fuzzyMatcher.RankMatches(matches, query) | |||
| 219 | + | ||||
| 220 | + | return matches, nil | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // CalculateSimilarity calculates similarity between two strings. | |||
| 224 | + | func (p *Parser) CalculateSimilarity(s1, s2 string) float64 { | |||
| 225 | + | fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig) | |||
| 226 | + | return fuzzyMatcher.calculateSimilarity(s1, s2) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // RankMatches re-ranks fuzzy matches based on additional criteria. | |||
| 230 | + | func (p *Parser) RankMatches(matches []FuzzyMatch, query string) []FuzzyMatch { | |||
| 231 | + | fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig) | |||
| 232 | + | return fuzzyMatcher.RankMatches(matches, query) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // IsValidURL checks if the input represents a valid URL. | |||
| 236 | + | func (p *Parser) IsValidURL(input string) bool { | |||
| 237 | + | validator := NewURLValidator() | |||
| 238 | + | return validator.IsValidURL(input) | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // ProcessShortcut processes a shortcut and returns the resulting URL. | |||
| 242 | + | func (p *Parser) ProcessShortcut(shortcut, query string, shortcuts map[string]config.SearchShortcut) (string, error) { | |||
| 243 | + | shortcutConfig, exists := shortcuts[shortcut] | |||
| 244 | + | if !exists { | |||
| 245 | + | return "", fmt.Errorf("shortcut '%s' not found", shortcut) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | return p.processShortcut(shortcut, query, shortcutConfig) | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | // GetSupportedShortcuts returns a list of configured search shortcuts. | |||
| 252 | + | func (p *Parser) GetSupportedShortcuts() map[string]config.SearchShortcut { | |||
| 253 | + | return p.config.SearchShortcuts | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // UpdateFuzzyConfig updates the fuzzy matching configuration. | |||
| 257 | + | func (p *Parser) UpdateFuzzyConfig(newConfig *FuzzyConfig) error { | |||
| 258 | + | if !newConfig.IsValid() { | |||
| 259 | + | return fmt.Errorf("invalid fuzzy configuration") | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | p.fuzzyConfig = newConfig | |||
| 263 | + | return nil | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // GetFuzzyConfig returns the current fuzzy matching configuration. | |||
| 267 | + | func (p *Parser) GetFuzzyConfig() *FuzzyConfig { | |||
| 268 | + | return p.fuzzyConfig | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | // PreviewParse provides a preview of what ParseInput would return without side effects. | |||
| 272 | + | func (p *Parser) PreviewParse(input string) (*ParseResult, error) { | |||
| 273 | + | // This is essentially the same as ParseInput but could be extended | |||
| 274 | + | // to avoid any side effects if needed in the future | |||
| 275 | + | return p.ParseInput(input) | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | // SuggestCompletions provides URL/search completions based on input. | |||
| 279 | + | func (p *Parser) SuggestCompletions(input string, limit int) ([]string, error) { | |||
| 280 | + | if input == "" || limit <= 0 { | |||
| 281 | + | return nil, nil | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // Get fuzzy matches from history | |||
| 285 | + | matches, err := p.FuzzySearchHistory(input, 0.2) // Lower threshold for suggestions | |||
| 286 | + | if err != nil { | |||
| 287 | + | return nil, err | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | completions := make([]string, 0, limit) | |||
| 291 | + | seen := make(map[string]bool) | |||
| 292 | + | ||||
| 293 | + | // Add URLs from fuzzy matches | |||
| 294 | + | for _, match := range matches { | |||
| 295 | + | if len(completions) >= limit { | |||
| 296 | + | break | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | url := match.HistoryEntry.Url | |||
| 300 | + | if !seen[url] { | |||
| 301 | + | completions = append(completions, url) | |||
| 302 | + | seen[url] = true | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // Add search shortcuts if input looks like it might be a shortcut | |||
| 307 | + | if strings.Contains(input, ":") || len(input) <= 3 { | |||
| 308 | + | for shortcut, config := range p.config.SearchShortcuts { | |||
| 309 | + | if len(completions) >= limit { | |||
| 310 | + | break | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | if strings.HasPrefix(shortcut, strings.ToLower(input)) { | |||
| 314 | + | suggestion := fmt.Sprintf("%s: %s", shortcut, config.Description) | |||
| 315 | + | if !seen[suggestion] { | |||
| 316 | + | completions = append(completions, suggestion) | |||
| 317 | + | seen[suggestion] = true | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | return completions, nil | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | // ValidateConfig validates the parser configuration. | |||
| 327 | + | func (p *Parser) ValidateConfig() error { | |||
| 328 | + | if p.config == nil { | |||
| 329 | + | return fmt.Errorf("parser config is nil") | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | if p.fuzzyConfig == nil { | |||
| 333 | + | return fmt.Errorf("fuzzy config is nil") | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | if !p.fuzzyConfig.IsValid() { | |||
| 337 | + | return fmt.Errorf("invalid fuzzy config") | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | if p.historyProvider == nil { | |||
| 341 | + | return fmt.Errorf("history provider is nil") | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | // Validate search shortcuts | |||
| 345 | + | if len(p.config.SearchShortcuts) == 0 { | |||
| 346 | + | return fmt.Errorf("no search shortcuts configured") | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | // Check for default search shortcut | |||
| 350 | + | if _, hasGoogle := p.config.SearchShortcuts["g"]; !hasGoogle { | |||
| 351 | + | // Warning: no default search configured | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | return nil | |||
| 355 | + | } | |||
A internal/parser/parser_test.go +611 -0
| @@ -0,0 +1,611 @@ | |||||
| 1 | + | package parser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "database/sql" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/config" | |||
| 9 | + | "github.com/bnema/dumber/internal/db" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // MockHistoryProvider implements HistoryProvider for testing. | |||
| 13 | + | type MockHistoryProvider struct { | |||
| 14 | + | history []*db.History | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func (m *MockHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) { | |||
| 18 | + | if limit >= len(m.history) { | |||
| 19 | + | return m.history, nil | |||
| 20 | + | } | |||
| 21 | + | return m.history[:limit], nil | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (m *MockHistoryProvider) GetAllHistory() ([]*db.History, error) { | |||
| 25 | + | return m.history, nil | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (m *MockHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) { | |||
| 29 | + | // Simple mock search - just return entries that contain the query | |||
| 30 | + | result := make([]*db.History, 0) | |||
| 31 | + | for _, entry := range m.history { | |||
| 32 | + | if len(result) >= limit { | |||
| 33 | + | break | |||
| 34 | + | } | |||
| 35 | + | // Simple contains check | |||
| 36 | + | result = append(result, entry) | |||
| 37 | + | } | |||
| 38 | + | return result, nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (m *MockHistoryProvider) GetHistoryByURL(url string) (*db.History, error) { | |||
| 42 | + | for _, entry := range m.history { | |||
| 43 | + | if entry.Url == url { | |||
| 44 | + | return entry, nil | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | return nil, sql.ErrNoRows | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // createMockHistory creates mock history data for testing. | |||
| 51 | + | func createMockHistory() []*db.History { | |||
| 52 | + | now := time.Now() | |||
| 53 | + | return []*db.History{ | |||
| 54 | + | { | |||
| 55 | + | ID: 1, | |||
| 56 | + | Url: "https://github.com", | |||
| 57 | + | Title: sql.NullString{String: "GitHub", Valid: true}, | |||
| 58 | + | VisitCount: sql.NullInt64{Int64: 25, Valid: true}, | |||
| 59 | + | LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true}, | |||
| 60 | + | CreatedAt: sql.NullTime{Time: now.Add(-30 * 24 * time.Hour), Valid: true}, | |||
| 61 | + | }, | |||
| 62 | + | { | |||
| 63 | + | ID: 2, | |||
| 64 | + | Url: "https://stackoverflow.com", | |||
| 65 | + | Title: sql.NullString{String: "Stack Overflow", Valid: true}, | |||
| 66 | + | VisitCount: sql.NullInt64{Int64: 15, Valid: true}, | |||
| 67 | + | LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true}, | |||
| 68 | + | CreatedAt: sql.NullTime{Time: now.Add(-20 * 24 * time.Hour), Valid: true}, | |||
| 69 | + | }, | |||
| 70 | + | { | |||
| 71 | + | ID: 3, | |||
| 72 | + | Url: "https://reddit.com", | |||
| 73 | + | Title: sql.NullString{String: "Reddit", Valid: true}, | |||
| 74 | + | VisitCount: sql.NullInt64{Int64: 50, Valid: true}, | |||
| 75 | + | LastVisited: sql.NullTime{Time: now.Add(-3 * time.Hour), Valid: true}, | |||
| 76 | + | CreatedAt: sql.NullTime{Time: now.Add(-10 * 24 * time.Hour), Valid: true}, | |||
| 77 | + | }, | |||
| 78 | + | { | |||
| 79 | + | ID: 4, | |||
| 80 | + | Url: "https://golang.org", | |||
| 81 | + | Title: sql.NullString{String: "The Go Programming Language", Valid: true}, | |||
| 82 | + | VisitCount: sql.NullInt64{Int64: 8, Valid: true}, | |||
| 83 | + | LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true}, | |||
| 84 | + | CreatedAt: sql.NullTime{Time: now.Add(-5 * 24 * time.Hour), Valid: true}, | |||
| 85 | + | }, | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // createTestConfig creates a test configuration. | |||
| 90 | + | func createTestConfig() *config.Config { | |||
| 91 | + | return &config.Config{ | |||
| 92 | + | SearchShortcuts: map[string]config.SearchShortcut{ | |||
| 93 | + | "g": { | |||
| 94 | + | URL: "https://www.google.com/search?q={query}", | |||
| 95 | + | Description: "Google Search", | |||
| 96 | + | }, | |||
| 97 | + | "gh": { | |||
| 98 | + | URL: "https://github.com/search?q={query}", | |||
| 99 | + | Description: "GitHub Search", | |||
| 100 | + | }, | |||
| 101 | + | "so": { | |||
| 102 | + | URL: "https://stackoverflow.com/search?q={query}", | |||
| 103 | + | Description: "Stack Overflow Search", | |||
| 104 | + | }, | |||
| 105 | + | }, | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestParser_ParseInput_DirectURL(t *testing.T) { | |||
| 110 | + | mockHistory := createMockHistory() | |||
| 111 | + | provider := &MockHistoryProvider{history: mockHistory} | |||
| 112 | + | config := createTestConfig() | |||
| 113 | + | parser := NewParser(config, provider) | |||
| 114 | + | ||||
| 115 | + | tests := []struct { | |||
| 116 | + | name string | |||
| 117 | + | input string | |||
| 118 | + | expectType InputType | |||
| 119 | + | expectURL string | |||
| 120 | + | expectConf float64 | |||
| 121 | + | }{ | |||
| 122 | + | { | |||
| 123 | + | name: "HTTPS URL", | |||
| 124 | + | input: "https://example.com", | |||
| 125 | + | expectType: InputTypeDirectURL, | |||
| 126 | + | expectURL: "https://example.com", | |||
| 127 | + | expectConf: 1.0, | |||
| 128 | + | }, | |||
| 129 | + | { | |||
| 130 | + | name: "HTTP URL", | |||
| 131 | + | input: "http://example.com", | |||
| 132 | + | expectType: InputTypeDirectURL, | |||
| 133 | + | expectURL: "http://example.com", | |||
| 134 | + | expectConf: 1.0, | |||
| 135 | + | }, | |||
| 136 | + | { | |||
| 137 | + | name: "Domain only", | |||
| 138 | + | input: "example.com", | |||
| 139 | + | expectType: InputTypeDirectURL, | |||
| 140 | + | expectURL: "https://example.com", | |||
| 141 | + | expectConf: 1.0, | |||
| 142 | + | }, | |||
| 143 | + | { | |||
| 144 | + | name: "Subdomain", | |||
| 145 | + | input: "www.example.com", | |||
| 146 | + | expectType: InputTypeDirectURL, | |||
| 147 | + | expectURL: "https://www.example.com", | |||
| 148 | + | expectConf: 1.0, | |||
| 149 | + | }, | |||
| 150 | + | { | |||
| 151 | + | name: "Domain with path", | |||
| 152 | + | input: "example.com/path", | |||
| 153 | + | expectType: InputTypeDirectURL, | |||
| 154 | + | expectURL: "https://example.com/path", | |||
| 155 | + | expectConf: 1.0, | |||
| 156 | + | }, | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | for _, tt := range tests { | |||
| 160 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 161 | + | result, err := parser.ParseInput(tt.input) | |||
| 162 | + | if err != nil { | |||
| 163 | + | t.Fatalf("ParseInput() error = %v", err) | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | if result.Type != tt.expectType { | |||
| 167 | + | t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | if result.URL != tt.expectURL { | |||
| 171 | + | t.Errorf("ParseInput() URL = %v, want %v", result.URL, tt.expectURL) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | if result.Confidence != tt.expectConf { | |||
| 175 | + | t.Errorf("ParseInput() confidence = %v, want %v", result.Confidence, tt.expectConf) | |||
| 176 | + | } | |||
| 177 | + | }) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestParser_ParseInput_SearchShortcut(t *testing.T) { | |||
| 182 | + | mockHistory := createMockHistory() | |||
| 183 | + | provider := &MockHistoryProvider{history: mockHistory} | |||
| 184 | + | config := createTestConfig() | |||
| 185 | + | parser := NewParser(config, provider) | |||
| 186 | + | ||||
| 187 | + | tests := []struct { | |||
| 188 | + | name string | |||
| 189 | + | input string | |||
| 190 | + | expectType InputType | |||
| 191 | + | expectURL string | |||
| 192 | + | expectKey string | |||
| 193 | + | expectQuery string | |||
| 194 | + | }{ | |||
| 195 | + | { | |||
| 196 | + | name: "Google search", | |||
| 197 | + | input: "g: golang tutorial", | |||
| 198 | + | expectType: InputTypeSearchShortcut, | |||
| 199 | + | expectURL: "https://www.google.com/search?q=golang tutorial", | |||
| 200 | + | expectKey: "g", | |||
| 201 | + | expectQuery: "golang tutorial", | |||
| 202 | + | }, | |||
| 203 | + | { | |||
| 204 | + | name: "GitHub search", | |||
| 205 | + | input: "gh: cobra cli", | |||
| 206 | + | expectType: InputTypeSearchShortcut, | |||
| 207 | + | expectURL: "https://github.com/search?q=cobra cli", | |||
| 208 | + | expectKey: "gh", | |||
| 209 | + | expectQuery: "cobra cli", | |||
| 210 | + | }, | |||
| 211 | + | { | |||
| 212 | + | name: "Stack Overflow search", | |||
| 213 | + | input: "so: how to fuzzy search", | |||
| 214 | + | expectType: InputTypeSearchShortcut, | |||
| 215 | + | expectURL: "https://stackoverflow.com/search?q=how to fuzzy search", | |||
| 216 | + | expectKey: "so", | |||
| 217 | + | expectQuery: "how to fuzzy search", | |||
| 218 | + | }, | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | for _, tt := range tests { | |||
| 222 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 223 | + | result, err := parser.ParseInput(tt.input) | |||
| 224 | + | if err != nil { | |||
| 225 | + | t.Fatalf("ParseInput() error = %v", err) | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | if result.Type != tt.expectType { | |||
| 229 | + | t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | if result.URL != tt.expectURL { | |||
| 233 | + | t.Errorf("ParseInput() URL = %v, want %v", result.URL, tt.expectURL) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | if result.Shortcut == nil { | |||
| 237 | + | t.Fatalf("ParseInput() shortcut is nil") | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | if result.Shortcut.Key != tt.expectKey { | |||
| 241 | + | t.Errorf("ParseInput() shortcut key = %v, want %v", result.Shortcut.Key, tt.expectKey) | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | if result.Shortcut.Query != tt.expectQuery { | |||
| 245 | + | t.Errorf("ParseInput() shortcut query = %v, want %v", result.Shortcut.Query, tt.expectQuery) | |||
| 246 | + | } | |||
| 247 | + | }) | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func TestParser_ParseInput_HistorySearch(t *testing.T) { | |||
| 252 | + | mockHistory := createMockHistory() | |||
| 253 | + | provider := &MockHistoryProvider{history: mockHistory} | |||
| 254 | + | config := createTestConfig() | |||
| 255 | + | parser := NewParser(config, provider) | |||
| 256 | + | ||||
| 257 | + | tests := []struct { | |||
| 258 | + | name string | |||
| 259 | + | input string | |||
| 260 | + | expectType InputType | |||
| 261 | + | expectMatches bool | |||
| 262 | + | minConfidence float64 | |||
| 263 | + | }{ | |||
| 264 | + | { | |||
| 265 | + | name: "Exact domain match", | |||
| 266 | + | input: "github", | |||
| 267 | + | expectType: InputTypeHistorySearch, | |||
| 268 | + | expectMatches: true, | |||
| 269 | + | minConfidence: 0.5, | |||
| 270 | + | }, | |||
| 271 | + | { | |||
| 272 | + | name: "Partial title match", | |||
| 273 | + | input: "stack", | |||
| 274 | + | expectType: InputTypeHistorySearch, | |||
| 275 | + | expectMatches: true, | |||
| 276 | + | minConfidence: 0.3, | |||
| 277 | + | }, | |||
| 278 | + | { | |||
| 279 | + | name: "Programming language search", | |||
| 280 | + | input: "golang", | |||
| 281 | + | expectType: InputTypeHistorySearch, | |||
| 282 | + | expectMatches: true, | |||
| 283 | + | minConfidence: 0.3, | |||
| 284 | + | }, | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | for _, tt := range tests { | |||
| 288 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 289 | + | result, err := parser.ParseInput(tt.input) | |||
| 290 | + | if err != nil { | |||
| 291 | + | t.Fatalf("ParseInput() error = %v", err) | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | if result.Type != tt.expectType { | |||
| 295 | + | t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | if tt.expectMatches && len(result.FuzzyMatches) == 0 { | |||
| 299 | + | t.Errorf("ParseInput() expected fuzzy matches but got none") | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | if result.Confidence < tt.minConfidence { | |||
| 303 | + | t.Errorf("ParseInput() confidence = %v, want >= %v", result.Confidence, tt.minConfidence) | |||
| 304 | + | } | |||
| 305 | + | }) | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func TestParser_FuzzySearchHistory(t *testing.T) { | |||
| 310 | + | mockHistory := createMockHistory() | |||
| 311 | + | provider := &MockHistoryProvider{history: mockHistory} | |||
| 312 | + | config := createTestConfig() | |||
| 313 | + | parser := NewParser(config, provider) | |||
| 314 | + | ||||
| 315 | + | tests := []struct { | |||
| 316 | + | name string | |||
| 317 | + | query string | |||
| 318 | + | threshold float64 | |||
| 319 | + | expectMatches int | |||
| 320 | + | expectFirst string // Expected first match URL | |||
| 321 | + | }{ | |||
| 322 | + | { | |||
| 323 | + | name: "GitHub search", | |||
| 324 | + | query: "github", | |||
| 325 | + | threshold: 0.3, | |||
| 326 | + | expectMatches: 1, | |||
| 327 | + | expectFirst: "https://github.com", | |||
| 328 | + | }, | |||
| 329 | + | { | |||
| 330 | + | name: "Programming search", | |||
| 331 | + | query: "programming", | |||
| 332 | + | threshold: 0.2, | |||
| 333 | + | expectMatches: 1, | |||
| 334 | + | expectFirst: "https://golang.org", | |||
| 335 | + | }, | |||
| 336 | + | { | |||
| 337 | + | name: "Stack search", | |||
| 338 | + | query: "stack", | |||
| 339 | + | threshold: 0.3, | |||
| 340 | + | expectMatches: 1, | |||
| 341 | + | expectFirst: "https://stackoverflow.com", | |||
| 342 | + | }, | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | for _, tt := range tests { | |||
| 346 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 347 | + | matches, err := parser.FuzzySearchHistory(tt.query, tt.threshold) | |||
| 348 | + | if err != nil { | |||
| 349 | + | t.Fatalf("FuzzySearchHistory() error = %v", err) | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | if len(matches) < tt.expectMatches { | |||
| 353 | + | t.Errorf("FuzzySearchHistory() matches = %d, want >= %d", len(matches), tt.expectMatches) | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | if len(matches) > 0 && tt.expectFirst != "" { | |||
| 357 | + | if matches[0].HistoryEntry.Url != tt.expectFirst { | |||
| 358 | + | t.Errorf("FuzzySearchHistory() first match = %v, want %v", matches[0].HistoryEntry.Url, tt.expectFirst) | |||
| 359 | + | } | |||
| 360 | + | } | |||
| 361 | + | }) | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func TestParser_SuggestCompletions(t *testing.T) { | |||
| 366 | + | mockHistory := createMockHistory() | |||
| 367 | + | provider := &MockHistoryProvider{history: mockHistory} | |||
| 368 | + | config := createTestConfig() | |||
| 369 | + | parser := NewParser(config, provider) | |||
| 370 | + | ||||
| 371 | + | tests := []struct { | |||
| 372 | + | name string | |||
| 373 | + | input string | |||
| 374 | + | limit int | |||
| 375 | + | expectCount int | |||
| 376 | + | }{ | |||
| 377 | + | { | |||
| 378 | + | name: "GitHub completion", | |||
| 379 | + | input: "gith", | |||
| 380 | + | limit: 5, | |||
| 381 | + | expectCount: 1, | |||
| 382 | + | }, | |||
| 383 | + | { | |||
| 384 | + | name: "Shortcut completion", | |||
| 385 | + | input: "g", | |||
| 386 | + | limit: 5, | |||
| 387 | + | expectCount: 2, // "g" and "gh" shortcuts | |||
| 388 | + | }, | |||
| 389 | + | { | |||
| 390 | + | name: "Empty input", | |||
| 391 | + | input: "", | |||
| 392 | + | limit: 5, | |||
| 393 | + | expectCount: 0, | |||
| 394 | + | }, | |||
| 395 | + | } | |||
| 396 | + | ||||
| 397 | + | for _, tt := range tests { | |||
| 398 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 399 | + | completions, err := parser.SuggestCompletions(tt.input, tt.limit) | |||
| 400 | + | if err != nil { | |||
| 401 | + | t.Fatalf("SuggestCompletions() error = %v", err) | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | if len(completions) != tt.expectCount { | |||
| 405 | + | t.Errorf("SuggestCompletions() count = %d, want %d", len(completions), tt.expectCount) | |||
| 406 | + | } | |||
| 407 | + | }) | |||
| 408 | + | } | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | func TestParser_ProcessShortcut(t *testing.T) { | |||
| 412 | + | config := createTestConfig() | |||
| 413 | + | mockHistory := createMockHistory() | |||
| 414 | + | provider := &MockHistoryProvider{history: mockHistory} | |||
| 415 | + | parser := NewParser(config, provider) | |||
| 416 | + | ||||
| 417 | + | tests := []struct { | |||
| 418 | + | name string | |||
| 419 | + | shortcut string | |||
| 420 | + | query string | |||
| 421 | + | expectURL string | |||
| 422 | + | expectErr bool | |||
| 423 | + | }{ | |||
| 424 | + | { | |||
| 425 | + | name: "Google shortcut", | |||
| 426 | + | shortcut: "g", | |||
| 427 | + | query: "test query", | |||
| 428 | + | expectURL: "https://www.google.com/search?q=test query", | |||
| 429 | + | expectErr: false, | |||
| 430 | + | }, | |||
| 431 | + | { | |||
| 432 | + | name: "GitHub shortcut", | |||
| 433 | + | shortcut: "gh", | |||
| 434 | + | query: "golang", | |||
| 435 | + | expectURL: "https://github.com/search?q=golang", | |||
| 436 | + | expectErr: false, | |||
| 437 | + | }, | |||
| 438 | + | { | |||
| 439 | + | name: "Unknown shortcut", | |||
| 440 | + | shortcut: "unknown", | |||
| 441 | + | query: "test", | |||
| 442 | + | expectURL: "", | |||
| 443 | + | expectErr: true, | |||
| 444 | + | }, | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | for _, tt := range tests { | |||
| 448 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 449 | + | url, err := parser.ProcessShortcut(tt.shortcut, tt.query, config.SearchShortcuts) | |||
| 450 | + | ||||
| 451 | + | if tt.expectErr { | |||
| 452 | + | if err == nil { | |||
| 453 | + | t.Errorf("ProcessShortcut() expected error but got none") | |||
| 454 | + | } | |||
| 455 | + | return | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | if err != nil { | |||
| 459 | + | t.Fatalf("ProcessShortcut() error = %v", err) | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | if url != tt.expectURL { | |||
| 463 | + | t.Errorf("ProcessShortcut() URL = %v, want %v", url, tt.expectURL) | |||
| 464 | + | } | |||
| 465 | + | }) | |||
| 466 | + | } | |||
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | func TestParser_ValidateConfig(t *testing.T) { | |||
| 470 | + | mockHistory := createMockHistory() | |||
| 471 | + | provider := &MockHistoryProvider{history: mockHistory} | |||
| 472 | + | ||||
| 473 | + | tests := []struct { | |||
| 474 | + | name string | |||
| 475 | + | config *config.Config | |||
| 476 | + | fuzzy *FuzzyConfig | |||
| 477 | + | provider HistoryProvider | |||
| 478 | + | expectErr bool | |||
| 479 | + | }{ | |||
| 480 | + | { | |||
| 481 | + | name: "Valid configuration", | |||
| 482 | + | config: createTestConfig(), | |||
| 483 | + | fuzzy: DefaultFuzzyConfig(), | |||
| 484 | + | provider: provider, | |||
| 485 | + | expectErr: false, | |||
| 486 | + | }, | |||
| 487 | + | { | |||
| 488 | + | name: "Nil config", | |||
| 489 | + | config: nil, | |||
| 490 | + | fuzzy: DefaultFuzzyConfig(), | |||
| 491 | + | provider: provider, | |||
| 492 | + | expectErr: true, | |||
| 493 | + | }, | |||
| 494 | + | { | |||
| 495 | + | name: "Nil fuzzy config", | |||
| 496 | + | config: createTestConfig(), | |||
| 497 | + | fuzzy: nil, | |||
| 498 | + | provider: provider, | |||
| 499 | + | expectErr: true, | |||
| 500 | + | }, | |||
| 501 | + | { | |||
| 502 | + | name: "Nil history provider", | |||
| 503 | + | config: createTestConfig(), | |||
| 504 | + | fuzzy: DefaultFuzzyConfig(), | |||
| 505 | + | provider: nil, | |||
| 506 | + | expectErr: true, | |||
| 507 | + | }, | |||
| 508 | + | } | |||
| 509 | + | ||||
| 510 | + | for _, tt := range tests { | |||
| 511 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 512 | + | parser := NewParser(tt.config, tt.provider, WithFuzzyConfig(tt.fuzzy)) | |||
| 513 | + | ||||
| 514 | + | err := parser.ValidateConfig() | |||
| 515 | + | ||||
| 516 | + | if tt.expectErr { | |||
| 517 | + | if err == nil { | |||
| 518 | + | t.Errorf("ValidateConfig() expected error but got none") | |||
| 519 | + | } | |||
| 520 | + | } else { | |||
| 521 | + | if err != nil { | |||
| 522 | + | t.Errorf("ValidateConfig() unexpected error = %v", err) | |||
| 523 | + | } | |||
| 524 | + | } | |||
| 525 | + | }) | |||
| 526 | + | } | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func TestFuzzyConfig_IsValid(t *testing.T) { | |||
| 530 | + | tests := []struct { | |||
| 531 | + | name string | |||
| 532 | + | config *FuzzyConfig | |||
| 533 | + | expect bool | |||
| 534 | + | }{ | |||
| 535 | + | { | |||
| 536 | + | name: "Default config", | |||
| 537 | + | config: DefaultFuzzyConfig(), | |||
| 538 | + | expect: true, | |||
| 539 | + | }, | |||
| 540 | + | { | |||
| 541 | + | name: "Invalid threshold (negative)", | |||
| 542 | + | config: &FuzzyConfig{ | |||
| 543 | + | MinSimilarityThreshold: -0.1, | |||
| 544 | + | MaxResults: 10, | |||
| 545 | + | URLWeight: 0.4, | |||
| 546 | + | TitleWeight: 0.3, | |||
| 547 | + | RecencyWeight: 0.2, | |||
| 548 | + | VisitWeight: 0.1, | |||
| 549 | + | RecencyDecayDays: 30, | |||
| 550 | + | }, | |||
| 551 | + | expect: false, | |||
| 552 | + | }, | |||
| 553 | + | { | |||
| 554 | + | name: "Invalid threshold (>1.0)", | |||
| 555 | + | config: &FuzzyConfig{ | |||
| 556 | + | MinSimilarityThreshold: 1.1, | |||
| 557 | + | MaxResults: 10, | |||
| 558 | + | URLWeight: 0.4, | |||
| 559 | + | TitleWeight: 0.3, | |||
| 560 | + | RecencyWeight: 0.2, | |||
| 561 | + | VisitWeight: 0.1, | |||
| 562 | + | RecencyDecayDays: 30, | |||
| 563 | + | }, | |||
| 564 | + | expect: false, | |||
| 565 | + | }, | |||
| 566 | + | { | |||
| 567 | + | name: "Invalid MaxResults", | |||
| 568 | + | config: &FuzzyConfig{ | |||
| 569 | + | MinSimilarityThreshold: 0.3, | |||
| 570 | + | MaxResults: 0, | |||
| 571 | + | URLWeight: 0.4, | |||
| 572 | + | TitleWeight: 0.3, | |||
| 573 | + | RecencyWeight: 0.2, | |||
| 574 | + | VisitWeight: 0.1, | |||
| 575 | + | RecencyDecayDays: 30, | |||
| 576 | + | }, | |||
| 577 | + | expect: false, | |||
| 578 | + | }, | |||
| 579 | + | } | |||
| 580 | + | ||||
| 581 | + | for _, tt := range tests { | |||
| 582 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 583 | + | result := tt.config.IsValid() | |||
| 584 | + | if result != tt.expect { | |||
| 585 | + | t.Errorf("FuzzyConfig.IsValid() = %v, want %v", result, tt.expect) | |||
| 586 | + | } | |||
| 587 | + | }) | |||
| 588 | + | } | |||
| 589 | + | } | |||
| 590 | + | ||||
| 591 | + | func TestInputType_String(t *testing.T) { | |||
| 592 | + | tests := []struct { | |||
| 593 | + | inputType InputType | |||
| 594 | + | expected string | |||
| 595 | + | }{ | |||
| 596 | + | {InputTypeDirectURL, "direct_url"}, | |||
| 597 | + | {InputTypeSearchShortcut, "search_shortcut"}, | |||
| 598 | + | {InputTypeHistorySearch, "history_search"}, | |||
| 599 | + | {InputTypeFallbackSearch, "fallback_search"}, | |||
| 600 | + | {InputType(99), "unknown"}, // Unknown type | |||
| 601 | + | } | |||
| 602 | + | ||||
| 603 | + | for _, tt := range tests { | |||
| 604 | + | t.Run(tt.expected, func(t *testing.T) { | |||
| 605 | + | result := tt.inputType.String() | |||
| 606 | + | if result != tt.expected { | |||
| 607 | + | t.Errorf("InputType.String() = %v, want %v", result, tt.expected) | |||
| 608 | + | } | |||
| 609 | + | }) | |||
| 610 | + | } | |||
| 611 | + | } | |||
A internal/parser/types.go +171 -0
| @@ -0,0 +1,171 @@ | |||||
| 1 | + | // Package parser provides URL parsing and fuzzy finding functionality for dumber. | |||
| 2 | + | package parser | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/db" | |||
| 8 | + | "github.com/bnema/dumber/internal/config" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // InputType represents the type of user input detected. | |||
| 12 | + | type InputType int | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | // InputTypeDirectURL represents a direct URL input (e.g., "reddit.com", "https://example.com") | |||
| 16 | + | InputTypeDirectURL InputType = iota | |||
| 17 | + | // InputTypeSearchShortcut represents a search shortcut (e.g., "g: golang tutorial") | |||
| 18 | + | InputTypeSearchShortcut | |||
| 19 | + | // InputTypeHistorySearch represents a history search query | |||
| 20 | + | InputTypeHistorySearch | |||
| 21 | + | // InputTypeFallbackSearch represents a fallback web search | |||
| 22 | + | InputTypeFallbackSearch | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // ParseResult represents the result of parsing user input. | |||
| 26 | + | type ParseResult struct { | |||
| 27 | + | // Type indicates the detected input type | |||
| 28 | + | Type InputType `json:"type"` | |||
| 29 | + | // URL is the final URL to navigate to | |||
| 30 | + | URL string `json:"url"` | |||
| 31 | + | // Query is the original user query | |||
| 32 | + | Query string `json:"query"` | |||
| 33 | + | // Confidence is the confidence score of the parsing (0.0-1.0) | |||
| 34 | + | Confidence float64 `json:"confidence"` | |||
| 35 | + | // FuzzyMatches contains fuzzy search results if applicable | |||
| 36 | + | FuzzyMatches []FuzzyMatch `json:"fuzzy_matches,omitempty"` | |||
| 37 | + | // Shortcut contains the detected shortcut information | |||
| 38 | + | Shortcut *DetectedShortcut `json:"shortcut,omitempty"` | |||
| 39 | + | // ProcessingTime is the time taken to process the input | |||
| 40 | + | ProcessingTime time.Duration `json:"processing_time"` | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // DetectedShortcut represents a detected search shortcut. | |||
| 44 | + | type DetectedShortcut struct { | |||
| 45 | + | // Key is the shortcut key (e.g., "g", "gh") | |||
| 46 | + | Key string `json:"key"` | |||
| 47 | + | // Query is the search query part | |||
| 48 | + | Query string `json:"query"` | |||
| 49 | + | // URL is the constructed URL | |||
| 50 | + | URL string `json:"url"` | |||
| 51 | + | // Description is the shortcut description | |||
| 52 | + | Description string `json:"description"` | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // FuzzyMatch represents a fuzzy search match from history. | |||
| 56 | + | type FuzzyMatch struct { | |||
| 57 | + | // HistoryEntry is the matched history entry | |||
| 58 | + | HistoryEntry *db.History `json:"history_entry"` | |||
| 59 | + | // Score is the similarity score (0.0-1.0, higher is better) | |||
| 60 | + | Score float64 `json:"score"` | |||
| 61 | + | // URLScore is the URL similarity score | |||
| 62 | + | URLScore float64 `json:"url_score"` | |||
| 63 | + | // TitleScore is the title similarity score | |||
| 64 | + | TitleScore float64 `json:"title_score"` | |||
| 65 | + | // RecencyScore is the recency score based on last visit | |||
| 66 | + | RecencyScore float64 `json:"recency_score"` | |||
| 67 | + | // VisitScore is the score based on visit count | |||
| 68 | + | VisitScore float64 `json:"visit_score"` | |||
| 69 | + | // MatchedField indicates which field matched (url, title, or both) | |||
| 70 | + | MatchedField string `json:"matched_field"` | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // HistoryProvider defines the interface for history data access. | |||
| 74 | + | type HistoryProvider interface { | |||
| 75 | + | // GetRecentHistory returns recent history entries limited by count | |||
| 76 | + | GetRecentHistory(limit int) ([]*db.History, error) | |||
| 77 | + | // GetAllHistory returns all history entries for fuzzy search | |||
| 78 | + | GetAllHistory() ([]*db.History, error) | |||
| 79 | + | // SearchHistory performs a basic text search in history | |||
| 80 | + | SearchHistory(query string, limit int) ([]*db.History, error) | |||
| 81 | + | // GetHistoryByURL retrieves history entry by exact URL match | |||
| 82 | + | GetHistoryByURL(url string) (*db.History, error) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // FuzzyConfig holds configuration for fuzzy matching algorithms. | |||
| 86 | + | type FuzzyConfig struct { | |||
| 87 | + | // MinSimilarityThreshold is the minimum similarity score to consider a match (0.0-1.0) | |||
| 88 | + | MinSimilarityThreshold float64 `json:"min_similarity_threshold"` | |||
| 89 | + | // MaxResults is the maximum number of fuzzy results to return | |||
| 90 | + | MaxResults int `json:"max_results"` | |||
| 91 | + | // URLWeight is the weight for URL similarity in scoring | |||
| 92 | + | URLWeight float64 `json:"url_weight"` | |||
| 93 | + | // TitleWeight is the weight for title similarity in scoring | |||
| 94 | + | TitleWeight float64 `json:"title_weight"` | |||
| 95 | + | // RecencyWeight is the weight for recency in scoring | |||
| 96 | + | RecencyWeight float64 `json:"recency_weight"` | |||
| 97 | + | // VisitWeight is the weight for visit count in scoring | |||
| 98 | + | VisitWeight float64 `json:"visit_weight"` | |||
| 99 | + | // RecencyDecayDays is the number of days for recency score decay | |||
| 100 | + | RecencyDecayDays int `json:"recency_decay_days"` | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // Parser handles URL parsing and fuzzy finding operations. | |||
| 104 | + | type Parser struct { | |||
| 105 | + | config *config.Config | |||
| 106 | + | fuzzyConfig *FuzzyConfig | |||
| 107 | + | historyProvider HistoryProvider | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // ParserOption is a functional option for configuring the Parser. | |||
| 111 | + | type ParserOption func(*Parser) | |||
| 112 | + | ||||
| 113 | + | // NewParser creates a new Parser instance with the given configuration. | |||
| 114 | + | func NewParser(cfg *config.Config, historyProvider HistoryProvider, opts ...ParserOption) *Parser { | |||
| 115 | + | p := &Parser{ | |||
| 116 | + | config: cfg, | |||
| 117 | + | historyProvider: historyProvider, | |||
| 118 | + | fuzzyConfig: DefaultFuzzyConfig(), | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | for _, opt := range opts { | |||
| 122 | + | opt(p) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | return p | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // WithFuzzyConfig sets custom fuzzy matching configuration. | |||
| 129 | + | func WithFuzzyConfig(fuzzyConfig *FuzzyConfig) ParserOption { | |||
| 130 | + | return func(p *Parser) { | |||
| 131 | + | p.fuzzyConfig = fuzzyConfig | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // DefaultFuzzyConfig returns default fuzzy matching configuration. | |||
| 136 | + | func DefaultFuzzyConfig() *FuzzyConfig { | |||
| 137 | + | return &FuzzyConfig{ | |||
| 138 | + | MinSimilarityThreshold: 0.3, | |||
| 139 | + | MaxResults: 10, | |||
| 140 | + | URLWeight: 0.4, | |||
| 141 | + | TitleWeight: 0.3, | |||
| 142 | + | RecencyWeight: 0.2, | |||
| 143 | + | VisitWeight: 0.1, | |||
| 144 | + | RecencyDecayDays: 30, | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // String returns a string representation of InputType. | |||
| 149 | + | func (t InputType) String() string { | |||
| 150 | + | switch t { | |||
| 151 | + | case InputTypeDirectURL: | |||
| 152 | + | return "direct_url" | |||
| 153 | + | case InputTypeSearchShortcut: | |||
| 154 | + | return "search_shortcut" | |||
| 155 | + | case InputTypeHistorySearch: | |||
| 156 | + | return "history_search" | |||
| 157 | + | case InputTypeFallbackSearch: | |||
| 158 | + | return "fallback_search" | |||
| 159 | + | default: | |||
| 160 | + | return "unknown" | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // IsValid returns true if the FuzzyConfig has valid values. | |||
| 165 | + | func (fc *FuzzyConfig) IsValid() bool { | |||
| 166 | + | return fc.MinSimilarityThreshold >= 0.0 && fc.MinSimilarityThreshold <= 1.0 && | |||
| 167 | + | fc.MaxResults > 0 && | |||
| 168 | + | fc.URLWeight >= 0.0 && fc.TitleWeight >= 0.0 && | |||
| 169 | + | fc.RecencyWeight >= 0.0 && fc.VisitWeight >= 0.0 && | |||
| 170 | + | fc.RecencyDecayDays > 0 | |||
| 171 | + | } | |||
A internal/parser/validation.go +407 -0
| @@ -0,0 +1,407 @@ | |||||
| 1 | + | package parser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "net/url" | |||
| 6 | + | "regexp" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | var ( | |||
| 11 | + | // domainRegex matches valid domain names | |||
| 12 | + | domainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$`) | |||
| 13 | + | ||||
| 14 | + | // ipRegex matches IPv4 addresses | |||
| 15 | + | ipRegex = regexp.MustCompile(`^(\d{1,3}\.){3}\d{1,3}$`) | |||
| 16 | + | ||||
| 17 | + | // urlSchemeRegex matches URL schemes | |||
| 18 | + | urlSchemeRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*://`) | |||
| 19 | + | ||||
| 20 | + | // shortcutRegex matches search shortcuts (e.g., "g:", "gh:") | |||
| 21 | + | shortcutRegex = regexp.MustCompile(`^([a-zA-Z0-9]+):\s*(.*)$`) | |||
| 22 | + | ||||
| 23 | + | // Common top-level domains for validation | |||
| 24 | + | commonTLDs = map[string]bool{ | |||
| 25 | + | "com": true, "org": true, "net": true, "edu": true, "gov": true, "mil": true, | |||
| 26 | + | "int": true, "co": true, "io": true, "ai": true, "ly": true, "me": true, | |||
| 27 | + | "tv": true, "cc": true, "to": true, "app": true, "dev": true, "tech": true, | |||
| 28 | + | "info": true, "biz": true, "name": true, "museum": true, "travel": true, | |||
| 29 | + | "xyz": true, "fr": true, "de": true, "uk": true, "ca": true, "us": true, | |||
| 30 | + | "au": true, "jp": true, "cn": true, "in": true, "ru": true, "br": true, | |||
| 31 | + | } | |||
| 32 | + | ) | |||
| 33 | + | ||||
| 34 | + | // URLValidator provides URL validation utilities. | |||
| 35 | + | type URLValidator struct{} | |||
| 36 | + | ||||
| 37 | + | // NewURLValidator creates a new URLValidator. | |||
| 38 | + | func NewURLValidator() *URLValidator { | |||
| 39 | + | return &URLValidator{} | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // IsValidURL checks if the input represents a valid URL. | |||
| 43 | + | func (v *URLValidator) IsValidURL(input string) bool { | |||
| 44 | + | if input == "" { | |||
| 45 | + | return false | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Check if it already has a scheme | |||
| 49 | + | if urlSchemeRegex.MatchString(input) { | |||
| 50 | + | return v.isWellFormedURL(input) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // Check if it looks like a domain or IP | |||
| 54 | + | return v.isDomain(input) || v.isIPAddress(input) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // IsDirectURL checks if the input should be treated as a direct URL. | |||
| 58 | + | func (v *URLValidator) IsDirectURL(input string) bool { | |||
| 59 | + | if input == "" { | |||
| 60 | + | return false | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | input = strings.TrimSpace(input) | |||
| 64 | + | ||||
| 65 | + | // Has explicit scheme | |||
| 66 | + | if urlSchemeRegex.MatchString(input) { | |||
| 67 | + | return v.isWellFormedURL(input) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Looks like a domain | |||
| 71 | + | if v.isDomain(input) { | |||
| 72 | + | return true | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Looks like an IP address | |||
| 76 | + | if v.isIPAddress(input) { | |||
| 77 | + | return true | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // Contains domain-like patterns | |||
| 81 | + | if v.looksLikeDomain(input) { | |||
| 82 | + | return true | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | return false | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // IsSearchShortcut checks if the input matches a search shortcut pattern. | |||
| 89 | + | func (v *URLValidator) IsSearchShortcut(input string) (bool, string, string) { | |||
| 90 | + | input = strings.TrimSpace(input) | |||
| 91 | + | ||||
| 92 | + | // Don't match URLs with schemes (like https://example.com) | |||
| 93 | + | if urlSchemeRegex.MatchString(input) { | |||
| 94 | + | return false, "", "" | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | matches := shortcutRegex.FindStringSubmatch(input) | |||
| 98 | + | if len(matches) != 3 { | |||
| 99 | + | return false, "", "" | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | shortcutKey := strings.ToLower(matches[1]) | |||
| 103 | + | query := strings.TrimSpace(matches[2]) | |||
| 104 | + | ||||
| 105 | + | return true, shortcutKey, query | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // NormalizeURL normalizes a URL by adding scheme if missing. | |||
| 109 | + | func (v *URLValidator) NormalizeURL(input string) string { | |||
| 110 | + | input = strings.TrimSpace(input) | |||
| 111 | + | ||||
| 112 | + | if input == "" { | |||
| 113 | + | return input | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // Already has a scheme | |||
| 117 | + | if urlSchemeRegex.MatchString(input) { | |||
| 118 | + | return input | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // Add https:// for domains and IPs | |||
| 122 | + | if v.isDomain(input) || v.isIPAddress(input) || v.looksLikeDomain(input) { | |||
| 123 | + | return "https://" + input | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | return input | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // ExtractDomain extracts the domain from a URL. | |||
| 130 | + | func (v *URLValidator) ExtractDomain(rawURL string) string { | |||
| 131 | + | // Parse the URL | |||
| 132 | + | parsedURL, err := url.Parse(v.NormalizeURL(rawURL)) | |||
| 133 | + | if err != nil { | |||
| 134 | + | // Fallback: try to extract domain manually | |||
| 135 | + | return v.extractDomainManual(rawURL) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // Extract hostname (without port) from Host | |||
| 139 | + | host := parsedURL.Host | |||
| 140 | + | if host == "" { | |||
| 141 | + | return v.extractDomainManual(rawURL) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // Remove port from host if present | |||
| 145 | + | hostname := host | |||
| 146 | + | if idx := strings.LastIndex(host, ":"); idx != -1 { | |||
| 147 | + | // Check if this looks like a port (numeric) | |||
| 148 | + | portPart := host[idx+1:] | |||
| 149 | + | if isNumeric(portPart) { | |||
| 150 | + | hostname = host[:idx] | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // Handle IPv6 addresses in brackets | |||
| 155 | + | if strings.HasPrefix(hostname, "[") && strings.HasSuffix(hostname, "]") { | |||
| 156 | + | hostname = hostname[1 : len(hostname)-1] | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | return hostname | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // extractDomainManual manually extracts domain when URL parsing fails. | |||
| 163 | + | func (v *URLValidator) extractDomainManual(rawURL string) string { | |||
| 164 | + | // Handle empty or invalid input | |||
| 165 | + | if rawURL == "" { | |||
| 166 | + | return "" | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // Remove protocol | |||
| 170 | + | domain := rawURL | |||
| 171 | + | if strings.HasPrefix(domain, "http://") { | |||
| 172 | + | domain = domain[7:] | |||
| 173 | + | } else if strings.HasPrefix(domain, "https://") { | |||
| 174 | + | domain = domain[8:] | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // Handle IPv6 addresses in brackets | |||
| 178 | + | if strings.HasPrefix(domain, "[") { | |||
| 179 | + | if idx := strings.Index(domain, "]"); idx != -1 { | |||
| 180 | + | return domain[1:idx] // Return IPv6 address without brackets | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // Remove path | |||
| 185 | + | if idx := strings.Index(domain, "/"); idx != -1 { | |||
| 186 | + | domain = domain[:idx] | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // Remove query and fragment | |||
| 190 | + | if idx := strings.Index(domain, "?"); idx != -1 { | |||
| 191 | + | domain = domain[:idx] | |||
| 192 | + | } | |||
| 193 | + | if idx := strings.Index(domain, "#"); idx != -1 { | |||
| 194 | + | domain = domain[:idx] | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // Remove port - be careful with IPv6 addresses | |||
| 198 | + | if !strings.Contains(domain, "::") { // Not IPv6 | |||
| 199 | + | if idx := strings.LastIndex(domain, ":"); idx != -1 { | |||
| 200 | + | // Check if this is actually a port (numeric) | |||
| 201 | + | portPart := domain[idx+1:] | |||
| 202 | + | if isNumeric(portPart) { | |||
| 203 | + | domain = domain[:idx] | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | return domain | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // isDomain checks if the input is a valid domain name. | |||
| 212 | + | func (v *URLValidator) isDomain(input string) bool { | |||
| 213 | + | if !domainRegex.MatchString(input) { | |||
| 214 | + | return false | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // Check for at least one dot (to distinguish from single words) | |||
| 218 | + | if !strings.Contains(input, ".") { | |||
| 219 | + | return false | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // Validate TLD | |||
| 223 | + | parts := strings.Split(input, ".") | |||
| 224 | + | if len(parts) < 2 { | |||
| 225 | + | return false | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | tld := strings.ToLower(parts[len(parts)-1]) | |||
| 229 | + | ||||
| 230 | + | // Check against common TLDs or validate length/format | |||
| 231 | + | return commonTLDs[tld] || (len(tld) >= 2 && len(tld) <= 6) | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | // isIPAddress checks if the input is a valid IP address. | |||
| 235 | + | func (v *URLValidator) isIPAddress(input string) bool { | |||
| 236 | + | // Try parsing as IP | |||
| 237 | + | ip := net.ParseIP(input) | |||
| 238 | + | return ip != nil | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // looksLikeDomain checks if the input looks like a domain (more lenient). | |||
| 242 | + | func (v *URLValidator) looksLikeDomain(input string) bool { | |||
| 243 | + | // Extract just the domain part if there's a path | |||
| 244 | + | domainPart := input | |||
| 245 | + | if slashIndex := strings.Index(input, "/"); slashIndex != -1 { | |||
| 246 | + | domainPart = input[:slashIndex] | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // Must contain at least one dot | |||
| 250 | + | if !strings.Contains(domainPart, ".") { | |||
| 251 | + | return false | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // Must not contain spaces | |||
| 255 | + | if strings.Contains(domainPart, " ") { | |||
| 256 | + | return false | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // Must not be too long | |||
| 260 | + | if len(domainPart) > 253 { | |||
| 261 | + | return false | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | // Check basic domain-like structure | |||
| 265 | + | parts := strings.Split(domainPart, ".") | |||
| 266 | + | if len(parts) < 2 { | |||
| 267 | + | return false | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | // Each part should be reasonable | |||
| 271 | + | for _, part := range parts { | |||
| 272 | + | if len(part) == 0 || len(part) > 63 { | |||
| 273 | + | return false | |||
| 274 | + | } | |||
| 275 | + | // Should start and end with alphanumeric | |||
| 276 | + | if len(part) > 0 { | |||
| 277 | + | if !isAlphaNumeric(part[0]) || !isAlphaNumeric(part[len(part)-1]) { | |||
| 278 | + | return false | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | return true | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | // isWellFormedURL checks if a URL with scheme is well-formed. | |||
| 287 | + | func (v *URLValidator) isWellFormedURL(rawURL string) bool { | |||
| 288 | + | parsedURL, err := url.Parse(rawURL) | |||
| 289 | + | if err != nil { | |||
| 290 | + | return false | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // Must have a scheme and host | |||
| 294 | + | if parsedURL.Scheme == "" || parsedURL.Host == "" { | |||
| 295 | + | return false | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // Validate scheme | |||
| 299 | + | validSchemes := map[string]bool{ | |||
| 300 | + | "http": true, "https": true, "ftp": true, "ftps": true, | |||
| 301 | + | "file": true, "mailto": true, "tel": true, "sms": true, | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | return validSchemes[strings.ToLower(parsedURL.Scheme)] | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | // HasFileExtension checks if the URL path has a file extension. | |||
| 308 | + | func (v *URLValidator) HasFileExtension(rawURL string) bool { | |||
| 309 | + | parsedURL, err := url.Parse(v.NormalizeURL(rawURL)) | |||
| 310 | + | if err != nil { | |||
| 311 | + | return false | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | path := parsedURL.Path | |||
| 315 | + | if path == "" || path == "/" { | |||
| 316 | + | return false | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // Check for file extension | |||
| 320 | + | lastSlash := strings.LastIndex(path, "/") | |||
| 321 | + | fileName := path | |||
| 322 | + | if lastSlash != -1 && lastSlash < len(path)-1 { | |||
| 323 | + | fileName = path[lastSlash+1:] | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | return strings.Contains(fileName, ".") && !strings.HasSuffix(fileName, ".") | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | // IsLocalhost checks if the URL is pointing to localhost. | |||
| 330 | + | func (v *URLValidator) IsLocalhost(rawURL string) bool { | |||
| 331 | + | // Handle direct localhost strings (no protocol) | |||
| 332 | + | rawURL = strings.ToLower(strings.TrimSpace(rawURL)) | |||
| 333 | + | if rawURL == "localhost" || rawURL == "127.0.0.1" || rawURL == "::1" { | |||
| 334 | + | return true | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | domain := v.ExtractDomain(rawURL) | |||
| 338 | + | domain = strings.ToLower(domain) | |||
| 339 | + | ||||
| 340 | + | // Remove port if present | |||
| 341 | + | if idx := strings.LastIndex(domain, ":"); idx != -1 { | |||
| 342 | + | portPart := domain[idx+1:] | |||
| 343 | + | if isNumeric(portPart) { | |||
| 344 | + | domain = domain[:idx] | |||
| 345 | + | } | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | return domain == "localhost" || | |||
| 349 | + | domain == "127.0.0.1" || | |||
| 350 | + | domain == "::1" || | |||
| 351 | + | strings.HasSuffix(domain, ".local") | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | // SanitizeInput sanitizes user input for URL parsing. | |||
| 355 | + | func (v *URLValidator) SanitizeInput(input string) string { | |||
| 356 | + | // Trim whitespace | |||
| 357 | + | input = strings.TrimSpace(input) | |||
| 358 | + | ||||
| 359 | + | // Remove control characters | |||
| 360 | + | result := make([]rune, 0, len(input)) | |||
| 361 | + | for _, r := range input { | |||
| 362 | + | if r >= 32 && r < 127 || r > 127 { // Keep printable ASCII and non-ASCII | |||
| 363 | + | result = append(result, r) | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | return string(result) | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | // Helper functions | |||
| 371 | + | ||||
| 372 | + | // isNumeric checks if a string contains only digits. | |||
| 373 | + | func isNumeric(s string) bool { | |||
| 374 | + | if s == "" { | |||
| 375 | + | return false | |||
| 376 | + | } | |||
| 377 | + | for _, r := range s { | |||
| 378 | + | if r < '0' || r > '9' { | |||
| 379 | + | return false | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | return true | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | // isAlphaNumeric checks if a character is alphanumeric. | |||
| 386 | + | func isAlphaNumeric(b byte) bool { | |||
| 387 | + | return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | // GetURLType determines the type of URL (direct, search, etc.). | |||
| 391 | + | func (v *URLValidator) GetURLType(input string) InputType { | |||
| 392 | + | input = v.SanitizeInput(input) | |||
| 393 | + | ||||
| 394 | + | // Check for direct URL first (this includes URLs with schemes) | |||
| 395 | + | if v.IsDirectURL(input) { | |||
| 396 | + | return InputTypeDirectURL | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | // Check for search shortcut (but exclude URL schemes) | |||
| 400 | + | if isShortcut, _, _ := v.IsSearchShortcut(input); isShortcut { | |||
| 401 | + | return InputTypeSearchShortcut | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | // Everything else is treated as search query initially | |||
| 405 | + | // The parser will determine if it should be history search or web search | |||
| 406 | + | return InputTypeHistorySearch | |||
| 407 | + | } | |||
A internal/parser/validation_test.go +526 -0
| @@ -0,0 +1,526 @@ | |||||
| 1 | + | package parser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ) | |||
| 6 | + | ||||
| 7 | + | func TestURLValidator_IsValidURL(t *testing.T) { | |||
| 8 | + | validator := NewURLValidator() | |||
| 9 | + | ||||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | input string | |||
| 13 | + | expected bool | |||
| 14 | + | }{ | |||
| 15 | + | // Valid URLs with scheme | |||
| 16 | + | {"HTTPS URL", "https://example.com", true}, | |||
| 17 | + | {"HTTP URL", "http://example.com", true}, | |||
| 18 | + | {"URL with path", "https://example.com/path", true}, | |||
| 19 | + | {"URL with query", "https://example.com?q=test", true}, | |||
| 20 | + | ||||
| 21 | + | // Valid domains | |||
| 22 | + | {"Simple domain", "example.com", true}, | |||
| 23 | + | {"Subdomain", "www.example.com", true}, | |||
| 24 | + | {"Multiple subdomains", "api.v1.example.com", true}, | |||
| 25 | + | {"Domain with path", "example.com/path", true}, | |||
| 26 | + | ||||
| 27 | + | // Valid IP addresses | |||
| 28 | + | {"IPv4 address", "192.168.1.1", true}, | |||
| 29 | + | {"Localhost IP", "127.0.0.1", true}, | |||
| 30 | + | ||||
| 31 | + | // Invalid inputs | |||
| 32 | + | {"Empty string", "", false}, | |||
| 33 | + | {"Just text", "hello world", false}, | |||
| 34 | + | {"Single word", "test", false}, | |||
| 35 | + | {"Space in domain", "exam ple.com", false}, | |||
| 36 | + | {"Invalid TLD", "example.toolongtobevalid", false}, | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | for _, tt := range tests { | |||
| 40 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 41 | + | result := validator.IsValidURL(tt.input) | |||
| 42 | + | if result != tt.expected { | |||
| 43 | + | t.Errorf("IsValidURL(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 44 | + | } | |||
| 45 | + | }) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestURLValidator_IsDirectURL(t *testing.T) { | |||
| 50 | + | validator := NewURLValidator() | |||
| 51 | + | ||||
| 52 | + | tests := []struct { | |||
| 53 | + | name string | |||
| 54 | + | input string | |||
| 55 | + | expected bool | |||
| 56 | + | }{ | |||
| 57 | + | // Should be treated as direct URLs | |||
| 58 | + | {"Complete HTTPS URL", "https://github.com", true}, | |||
| 59 | + | {"Complete HTTP URL", "http://example.com", true}, | |||
| 60 | + | {"Domain only", "github.com", true}, | |||
| 61 | + | {"Subdomain", "api.github.com", true}, | |||
| 62 | + | {"Domain with path", "github.com/user/repo", true}, | |||
| 63 | + | {"IP address", "192.168.1.1", true}, | |||
| 64 | + | {"Localhost", "127.0.0.1", true}, | |||
| 65 | + | ||||
| 66 | + | // Should NOT be treated as direct URLs | |||
| 67 | + | {"Single word", "github", false}, | |||
| 68 | + | {"Search query", "golang tutorial", false}, | |||
| 69 | + | {"Shortcut format", "g: search query", false}, | |||
| 70 | + | {"Empty string", "", false}, | |||
| 71 | + | {"Just spaces", " ", false}, | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | for _, tt := range tests { | |||
| 75 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 76 | + | result := validator.IsDirectURL(tt.input) | |||
| 77 | + | if result != tt.expected { | |||
| 78 | + | t.Errorf("IsDirectURL(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 79 | + | } | |||
| 80 | + | }) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestURLValidator_IsSearchShortcut(t *testing.T) { | |||
| 85 | + | validator := NewURLValidator() | |||
| 86 | + | ||||
| 87 | + | tests := []struct { | |||
| 88 | + | name string | |||
| 89 | + | input string | |||
| 90 | + | expectShortcut bool | |||
| 91 | + | expectKey string | |||
| 92 | + | expectQuery string | |||
| 93 | + | }{ | |||
| 94 | + | { | |||
| 95 | + | name: "Google shortcut", | |||
| 96 | + | input: "g: golang tutorial", | |||
| 97 | + | expectShortcut: true, | |||
| 98 | + | expectKey: "g", | |||
| 99 | + | expectQuery: "golang tutorial", | |||
| 100 | + | }, | |||
| 101 | + | { | |||
| 102 | + | name: "GitHub shortcut", | |||
| 103 | + | input: "gh: cobra cli", | |||
| 104 | + | expectShortcut: true, | |||
| 105 | + | expectKey: "gh", | |||
| 106 | + | expectQuery: "cobra cli", | |||
| 107 | + | }, | |||
| 108 | + | { | |||
| 109 | + | name: "Stack Overflow shortcut", | |||
| 110 | + | input: "so: fuzzy search algorithm", | |||
| 111 | + | expectShortcut: true, | |||
| 112 | + | expectKey: "so", | |||
| 113 | + | expectQuery: "fuzzy search algorithm", | |||
| 114 | + | }, | |||
| 115 | + | { | |||
| 116 | + | name: "Empty query", | |||
| 117 | + | input: "g: ", | |||
| 118 | + | expectShortcut: true, | |||
| 119 | + | expectKey: "g", | |||
| 120 | + | expectQuery: "", | |||
| 121 | + | }, | |||
| 122 | + | { | |||
| 123 | + | name: "Numeric shortcut", | |||
| 124 | + | input: "123: test", | |||
| 125 | + | expectShortcut: true, | |||
| 126 | + | expectKey: "123", | |||
| 127 | + | expectQuery: "test", | |||
| 128 | + | }, | |||
| 129 | + | // Not shortcuts | |||
| 130 | + | { | |||
| 131 | + | name: "Just colon", | |||
| 132 | + | input: ":", | |||
| 133 | + | expectShortcut: false, | |||
| 134 | + | }, | |||
| 135 | + | { | |||
| 136 | + | name: "No colon", | |||
| 137 | + | input: "github search", | |||
| 138 | + | expectShortcut: false, | |||
| 139 | + | }, | |||
| 140 | + | { | |||
| 141 | + | name: "URL with colon", | |||
| 142 | + | input: "https://example.com", | |||
| 143 | + | expectShortcut: false, | |||
| 144 | + | }, | |||
| 145 | + | { | |||
| 146 | + | name: "Empty string", | |||
| 147 | + | input: "", | |||
| 148 | + | expectShortcut: false, | |||
| 149 | + | }, | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | for _, tt := range tests { | |||
| 153 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 154 | + | isShortcut, key, query := validator.IsSearchShortcut(tt.input) | |||
| 155 | + | ||||
| 156 | + | if isShortcut != tt.expectShortcut { | |||
| 157 | + | t.Errorf("IsSearchShortcut(%q) isShortcut = %v, want %v", tt.input, isShortcut, tt.expectShortcut) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | if tt.expectShortcut { | |||
| 161 | + | if key != tt.expectKey { | |||
| 162 | + | t.Errorf("IsSearchShortcut(%q) key = %q, want %q", tt.input, key, tt.expectKey) | |||
| 163 | + | } | |||
| 164 | + | if query != tt.expectQuery { | |||
| 165 | + | t.Errorf("IsSearchShortcut(%q) query = %q, want %q", tt.input, query, tt.expectQuery) | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | }) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestURLValidator_NormalizeURL(t *testing.T) { | |||
| 173 | + | validator := NewURLValidator() | |||
| 174 | + | ||||
| 175 | + | tests := []struct { | |||
| 176 | + | name string | |||
| 177 | + | input string | |||
| 178 | + | expected string | |||
| 179 | + | }{ | |||
| 180 | + | // Already have scheme | |||
| 181 | + | {"HTTPS URL", "https://example.com", "https://example.com"}, | |||
| 182 | + | {"HTTP URL", "http://example.com", "http://example.com"}, | |||
| 183 | + | ||||
| 184 | + | // Need scheme added | |||
| 185 | + | {"Domain only", "example.com", "https://example.com"}, | |||
| 186 | + | {"Subdomain", "www.example.com", "https://www.example.com"}, | |||
| 187 | + | {"Domain with path", "example.com/path", "https://example.com/path"}, | |||
| 188 | + | {"IP address", "192.168.1.1", "https://192.168.1.1"}, | |||
| 189 | + | ||||
| 190 | + | // Special cases | |||
| 191 | + | {"Empty string", "", ""}, | |||
| 192 | + | {"Just spaces", " ", ""}, | |||
| 193 | + | {"Non-URL text", "hello world", "hello world"}, // Should remain unchanged | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | for _, tt := range tests { | |||
| 197 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 198 | + | result := validator.NormalizeURL(tt.input) | |||
| 199 | + | if result != tt.expected { | |||
| 200 | + | t.Errorf("NormalizeURL(%q) = %q, want %q", tt.input, result, tt.expected) | |||
| 201 | + | } | |||
| 202 | + | }) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func TestURLValidator_ExtractDomain(t *testing.T) { | |||
| 207 | + | validator := NewURLValidator() | |||
| 208 | + | ||||
| 209 | + | tests := []struct { | |||
| 210 | + | name string | |||
| 211 | + | input string | |||
| 212 | + | expected string | |||
| 213 | + | }{ | |||
| 214 | + | // URLs with scheme | |||
| 215 | + | {"HTTPS URL", "https://github.com", "github.com"}, | |||
| 216 | + | {"HTTP URL", "http://example.com", "example.com"}, | |||
| 217 | + | {"URL with path", "https://github.com/user/repo", "github.com"}, | |||
| 218 | + | {"URL with query", "https://google.com/search?q=test", "google.com"}, | |||
| 219 | + | {"URL with port", "https://localhost:8080", "localhost"}, | |||
| 220 | + | ||||
| 221 | + | // Domains without scheme | |||
| 222 | + | {"Domain only", "github.com", "github.com"}, | |||
| 223 | + | {"Subdomain", "api.github.com", "api.github.com"}, | |||
| 224 | + | {"Domain with path", "github.com/user/repo", "github.com"}, | |||
| 225 | + | ||||
| 226 | + | // IP addresses | |||
| 227 | + | {"IPv4", "192.168.1.1", "192.168.1.1"}, | |||
| 228 | + | {"IPv4 with port", "192.168.1.1:8080", "192.168.1.1"}, | |||
| 229 | + | ||||
| 230 | + | // Edge cases | |||
| 231 | + | {"Empty string", "", ""}, | |||
| 232 | + | {"Just path", "/path/to/resource", ""}, // Invalid input returns empty | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | for _, tt := range tests { | |||
| 236 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 237 | + | result := validator.ExtractDomain(tt.input) | |||
| 238 | + | if result != tt.expected { | |||
| 239 | + | t.Errorf("ExtractDomain(%q) = %q, want %q", tt.input, result, tt.expected) | |||
| 240 | + | } | |||
| 241 | + | }) | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func TestURLValidator_HasFileExtension(t *testing.T) { | |||
| 246 | + | validator := NewURLValidator() | |||
| 247 | + | ||||
| 248 | + | tests := []struct { | |||
| 249 | + | name string | |||
| 250 | + | input string | |||
| 251 | + | expected bool | |||
| 252 | + | }{ | |||
| 253 | + | // URLs with file extensions | |||
| 254 | + | {"HTML file", "https://example.com/index.html", true}, | |||
| 255 | + | {"PDF file", "https://example.com/document.pdf", true}, | |||
| 256 | + | {"Image file", "https://example.com/image.jpg", true}, | |||
| 257 | + | {"File in subdirectory", "https://example.com/docs/readme.txt", true}, | |||
| 258 | + | ||||
| 259 | + | // URLs without file extensions | |||
| 260 | + | {"Root URL", "https://example.com", false}, | |||
| 261 | + | {"Root with slash", "https://example.com/", false}, | |||
| 262 | + | {"Directory path", "https://example.com/docs/", false}, | |||
| 263 | + | {"Path without extension", "https://example.com/about", false}, | |||
| 264 | + | ||||
| 265 | + | // Edge cases | |||
| 266 | + | {"Dot at end", "https://example.com/file.", false}, | |||
| 267 | + | {"Multiple dots", "https://example.com/file.name.ext", true}, | |||
| 268 | + | {"Query params", "https://example.com/file.html?q=test", true}, | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | for _, tt := range tests { | |||
| 272 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 273 | + | result := validator.HasFileExtension(tt.input) | |||
| 274 | + | if result != tt.expected { | |||
| 275 | + | t.Errorf("HasFileExtension(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 276 | + | } | |||
| 277 | + | }) | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | func TestURLValidator_IsLocalhost(t *testing.T) { | |||
| 282 | + | validator := NewURLValidator() | |||
| 283 | + | ||||
| 284 | + | tests := []struct { | |||
| 285 | + | name string | |||
| 286 | + | input string | |||
| 287 | + | expected bool | |||
| 288 | + | }{ | |||
| 289 | + | // Localhost variants | |||
| 290 | + | {"localhost", "http://localhost", true}, | |||
| 291 | + | {"localhost with port", "http://localhost:3000", true}, | |||
| 292 | + | {"localhost HTTPS", "https://localhost:8080", true}, | |||
| 293 | + | {"127.0.0.1", "http://127.0.0.1", true}, | |||
| 294 | + | {"127.0.0.1 with port", "http://127.0.0.1:8080", true}, | |||
| 295 | + | {"IPv6 localhost", "http://[::1]", true}, | |||
| 296 | + | {"Local domain", "http://app.local", true}, | |||
| 297 | + | ||||
| 298 | + | // Non-localhost | |||
| 299 | + | {"example.com", "https://example.com", false}, | |||
| 300 | + | {"github.com", "https://github.com", false}, | |||
| 301 | + | {"127.0.0.2", "http://127.0.0.2", false}, // Different local IP | |||
| 302 | + | {"192.168.1.1", "http://192.168.1.1", false}, // Private network but not localhost | |||
| 303 | + | ||||
| 304 | + | // Edge cases | |||
| 305 | + | {"Empty string", "", false}, | |||
| 306 | + | {"Just localhost", "localhost", true}, | |||
| 307 | + | {"Just IP", "127.0.0.1", true}, | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | for _, tt := range tests { | |||
| 311 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 312 | + | result := validator.IsLocalhost(tt.input) | |||
| 313 | + | if result != tt.expected { | |||
| 314 | + | t.Errorf("IsLocalhost(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 315 | + | } | |||
| 316 | + | }) | |||
| 317 | + | } | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | func TestURLValidator_SanitizeInput(t *testing.T) { | |||
| 321 | + | validator := NewURLValidator() | |||
| 322 | + | ||||
| 323 | + | tests := []struct { | |||
| 324 | + | name string | |||
| 325 | + | input string | |||
| 326 | + | expected string | |||
| 327 | + | }{ | |||
| 328 | + | {"Normal text", "github.com", "github.com"}, | |||
| 329 | + | {"Text with spaces", " github.com ", "github.com"}, | |||
| 330 | + | {"Text with tabs", "\tgithub.com\t", "github.com"}, | |||
| 331 | + | {"Text with newlines", "\ngithub.com\n", "github.com"}, | |||
| 332 | + | {"Unicode text", "github.com/ñoño", "github.com/ñoño"}, | |||
| 333 | + | {"Empty string", "", ""}, | |||
| 334 | + | {"Only whitespace", " \t\n ", ""}, | |||
| 335 | + | {"Mixed whitespace and text", " \t github.com \n ", "github.com"}, | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | for _, tt := range tests { | |||
| 339 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 340 | + | result := validator.SanitizeInput(tt.input) | |||
| 341 | + | if result != tt.expected { | |||
| 342 | + | t.Errorf("SanitizeInput(%q) = %q, want %q", tt.input, result, tt.expected) | |||
| 343 | + | } | |||
| 344 | + | }) | |||
| 345 | + | } | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | func TestURLValidator_GetURLType(t *testing.T) { | |||
| 349 | + | validator := NewURLValidator() | |||
| 350 | + | ||||
| 351 | + | tests := []struct { | |||
| 352 | + | name string | |||
| 353 | + | input string | |||
| 354 | + | expected InputType | |||
| 355 | + | }{ | |||
| 356 | + | // Direct URLs | |||
| 357 | + | {"HTTPS URL", "https://github.com", InputTypeDirectURL}, | |||
| 358 | + | {"Domain", "github.com", InputTypeDirectURL}, | |||
| 359 | + | {"IP address", "192.168.1.1", InputTypeDirectURL}, | |||
| 360 | + | ||||
| 361 | + | // Search shortcuts | |||
| 362 | + | {"Google shortcut", "g: golang tutorial", InputTypeSearchShortcut}, | |||
| 363 | + | {"GitHub shortcut", "gh: cobra cli", InputTypeSearchShortcut}, | |||
| 364 | + | ||||
| 365 | + | // History search (everything else initially) | |||
| 366 | + | {"Single word", "github", InputTypeHistorySearch}, | |||
| 367 | + | {"Search phrase", "golang tutorial", InputTypeHistorySearch}, | |||
| 368 | + | {"Question", "how to use fuzzy search", InputTypeHistorySearch}, | |||
| 369 | + | {"Empty string", "", InputTypeHistorySearch}, | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | for _, tt := range tests { | |||
| 373 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 374 | + | result := validator.GetURLType(tt.input) | |||
| 375 | + | if result != tt.expected { | |||
| 376 | + | t.Errorf("GetURLType(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 377 | + | } | |||
| 378 | + | }) | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | func TestURLValidator_IsDomain(t *testing.T) { | |||
| 383 | + | validator := NewURLValidator() | |||
| 384 | + | ||||
| 385 | + | tests := []struct { | |||
| 386 | + | name string | |||
| 387 | + | input string | |||
| 388 | + | expected bool | |||
| 389 | + | }{ | |||
| 390 | + | // Valid domains | |||
| 391 | + | {"Simple domain", "example.com", true}, | |||
| 392 | + | {"Subdomain", "www.example.com", true}, | |||
| 393 | + | {"Multiple subdomains", "api.v1.example.com", true}, | |||
| 394 | + | {"Country TLD", "example.co.uk", true}, | |||
| 395 | + | {"New TLD", "example.dev", true}, | |||
| 396 | + | ||||
| 397 | + | // Invalid domains | |||
| 398 | + | {"Single word", "example", false}, | |||
| 399 | + | {"No TLD", "www.example", false}, | |||
| 400 | + | {"Starts with dot", ".example.com", false}, | |||
| 401 | + | {"Ends with dot", "example.com.", false}, | |||
| 402 | + | {"Contains spaces", "exam ple.com", false}, | |||
| 403 | + | {"Too long segment", "verylongsubdomainthatexceedssixtyThreecharacterslimitfordomainsegments.com", false}, | |||
| 404 | + | {"Empty string", "", false}, | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | for _, tt := range tests { | |||
| 408 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 409 | + | result := validator.isDomain(tt.input) | |||
| 410 | + | if result != tt.expected { | |||
| 411 | + | t.Errorf("isDomain(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 412 | + | } | |||
| 413 | + | }) | |||
| 414 | + | } | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | func TestURLValidator_IsIPAddress(t *testing.T) { | |||
| 418 | + | validator := NewURLValidator() | |||
| 419 | + | ||||
| 420 | + | tests := []struct { | |||
| 421 | + | name string | |||
| 422 | + | input string | |||
| 423 | + | expected bool | |||
| 424 | + | }{ | |||
| 425 | + | // Valid IPv4 | |||
| 426 | + | {"Standard IPv4", "192.168.1.1", true}, | |||
| 427 | + | {"Localhost IPv4", "127.0.0.1", true}, | |||
| 428 | + | {"Zero IPv4", "0.0.0.0", true}, | |||
| 429 | + | {"Max IPv4", "255.255.255.255", true}, | |||
| 430 | + | ||||
| 431 | + | // Valid IPv6 | |||
| 432 | + | {"Standard IPv6", "2001:db8::1", true}, | |||
| 433 | + | {"Localhost IPv6", "::1", true}, | |||
| 434 | + | ||||
| 435 | + | // Invalid IPs | |||
| 436 | + | {"Invalid IPv4 (out of range)", "256.256.256.256", false}, | |||
| 437 | + | {"Invalid IPv4 (too many octets)", "192.168.1.1.1", false}, | |||
| 438 | + | {"Invalid IPv4 (too few octets)", "192.168.1", false}, | |||
| 439 | + | {"Not an IP", "example.com", false}, | |||
| 440 | + | {"Empty string", "", false}, | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | for _, tt := range tests { | |||
| 444 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 445 | + | result := validator.isIPAddress(tt.input) | |||
| 446 | + | if result != tt.expected { | |||
| 447 | + | t.Errorf("isIPAddress(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 448 | + | } | |||
| 449 | + | }) | |||
| 450 | + | } | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | func TestURLValidator_LooksLikeDomain(t *testing.T) { | |||
| 454 | + | validator := NewURLValidator() | |||
| 455 | + | ||||
| 456 | + | tests := []struct { | |||
| 457 | + | name string | |||
| 458 | + | input string | |||
| 459 | + | expected bool | |||
| 460 | + | }{ | |||
| 461 | + | // Should look like domains | |||
| 462 | + | {"Standard domain", "example.com", true}, | |||
| 463 | + | {"Subdomain", "www.example.com", true}, | |||
| 464 | + | {"Uncommon TLD", "example.xyz", true}, | |||
| 465 | + | {"Multiple levels", "a.b.c.example.com", true}, | |||
| 466 | + | ||||
| 467 | + | // Should not look like domains | |||
| 468 | + | {"No dot", "example", false}, | |||
| 469 | + | {"Has space", "exam ple.com", false}, | |||
| 470 | + | {"Too long", string(make([]rune, 300)), false}, // Very long string | |||
| 471 | + | {"Empty segment", "example..com", false}, | |||
| 472 | + | {"Starts with non-alphanumeric", "-example.com", false}, | |||
| 473 | + | {"Ends with non-alphanumeric", "example-.com", false}, | |||
| 474 | + | {"Empty string", "", false}, | |||
| 475 | + | } | |||
| 476 | + | ||||
| 477 | + | for _, tt := range tests { | |||
| 478 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 479 | + | result := validator.looksLikeDomain(tt.input) | |||
| 480 | + | if result != tt.expected { | |||
| 481 | + | t.Errorf("looksLikeDomain(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 482 | + | } | |||
| 483 | + | }) | |||
| 484 | + | } | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | func BenchmarkURLValidator_IsValidURL(b *testing.B) { | |||
| 488 | + | validator := NewURLValidator() | |||
| 489 | + | testInputs := []string{ | |||
| 490 | + | "https://github.com", | |||
| 491 | + | "github.com", | |||
| 492 | + | "192.168.1.1", | |||
| 493 | + | "g: search query", | |||
| 494 | + | "just some text", | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | b.ResetTimer() | |||
| 498 | + | for i := 0; i < b.N; i++ { | |||
| 499 | + | validator.IsValidURL(testInputs[i%len(testInputs)]) | |||
| 500 | + | } | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | func BenchmarkURLValidator_IsDirectURL(b *testing.B) { | |||
| 504 | + | validator := NewURLValidator() | |||
| 505 | + | testInputs := []string{ | |||
| 506 | + | "https://github.com", | |||
| 507 | + | "github.com", | |||
| 508 | + | "search query", | |||
| 509 | + | "g: shortcut", | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | b.ResetTimer() | |||
| 513 | + | for i := 0; i < b.N; i++ { | |||
| 514 | + | validator.IsDirectURL(testInputs[i%len(testInputs)]) | |||
| 515 | + | } | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | func BenchmarkURLValidator_ExtractDomain(b *testing.B) { | |||
| 519 | + | validator := NewURLValidator() | |||
| 520 | + | testURL := "https://api.github.com/users/octocat/repos" | |||
| 521 | + | ||||
| 522 | + | b.ResetTimer() | |||
| 523 | + | for i := 0; i < b.N; i++ { | |||
| 524 | + | validator.ExtractDomain(testURL) | |||
| 525 | + | } | |||
| 526 | + | } | |||
M memory/constitution.md +1 -1
| @@ -12,7 +12,7 @@ | |||||
| 12 | - Support any dmenu-compatible launcher (fuzzel, rofi, tofi, wofi, bemenu) | 12 | - Support any dmenu-compatible launcher (fuzzel, rofi, tofi, wofi, bemenu) | ||
| 13 | - Standard stdin/stdout protocol - no launcher-specific integrations | 13 | - Standard stdin/stdout protocol - no launcher-specific integrations | ||
| 14 | - Clean text-based format for history/suggestions | 14 | - Clean text-based format for history/suggestions | ||
| 15 | - | - Pipeline-friendly: `dumb-browser --dmenu | launcher | dumb-browser --dmenu` | 15 | + | - Pipeline-friendly: `dumber --dmenu | launcher | dumber --dmenu` |
| 16 | 16 | ||||
| 17 | ### III. Smart URL Handling | 17 | ### III. Smart URL Handling | ||
| 18 | - Direct URL detection: `reddit.com` → navigate directly | 18 | - Direct URL detection: `reddit.com` → navigate directly | ||
M migrations/001_initial.sql +1 -1
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | -- Initial database schema for dumb-browser | 1 | + | -- Initial database schema for dumber |
| 2 | -- History tracking for visited URLs | 2 | -- History tracking for visited URLs | ||
| 3 | 3 | ||||
| 4 | CREATE TABLE IF NOT EXISTS history ( | 4 | CREATE TABLE IF NOT EXISTS history ( | ||
M specs/001-init-project/contracts/cli-commands.md +36 -36
| @@ -5,12 +5,12 @@ | |||||
| 5 | 5 | ||||
| 6 | ## Root Command | 6 | ## Root Command | ||
| 7 | 7 | ||||
| 8 | - | ### `dumb-browser init` | 8 | + | ### `dumber init` |
| 9 | **Purpose**: Initialize the project with all dependencies | 9 | **Purpose**: Initialize the project with all dependencies | ||
| 10 | 10 | ||||
| 11 | **Syntax**: | 11 | **Syntax**: | ||
| 12 | ```bash | 12 | ```bash | ||
| 13 | - | dumb-browser init [OPTIONS] | 13 | + | dumber init [OPTIONS] |
| 14 | ``` | 14 | ``` | ||
| 15 | 15 | ||||
| 16 | **Options**: | 16 | **Options**: | ||
| @@ -22,16 +22,16 @@ dumb-browser init [OPTIONS] | |||||
| 22 | **Examples**: | 22 | **Examples**: | ||
| 23 | ```bash | 23 | ```bash | ||
| 24 | # Basic initialization | 24 | # Basic initialization | ||
| 25 | - | dumb-browser init | 25 | + | dumber init |
| 26 | 26 | ||||
| 27 | # Preview mode | 27 | # Preview mode | ||
| 28 | - | dumb-browser init --dry-run | 28 | + | dumber init --dry-run |
| 29 | 29 | ||||
| 30 | # Force reinitialize existing project | 30 | # Force reinitialize existing project | ||
| 31 | - | dumb-browser init --force --verbose | 31 | + | dumber init --force --verbose |
| 32 | 32 | ||||
| 33 | # Specify Go version | 33 | # Specify Go version | ||
| 34 | - | dumb-browser init --go-version=1.21 | 34 | + | dumber init --go-version=1.21 |
| 35 | ``` | 35 | ``` | ||
| 36 | 36 | ||||
| 37 | **Exit Codes**: | 37 | **Exit Codes**: | ||
| @@ -43,7 +43,7 @@ dumb-browser init --go-version=1.21 | |||||
| 43 | 43 | ||||
| 44 | **Output Format**: | 44 | **Output Format**: | ||
| 45 | ``` | 45 | ``` | ||
| 46 | - | ✓ Initializing Go module: dumb-browser | 46 | + | ✓ Initializing Go module: dumber |
| 47 | ✓ Installing CLI dependencies: cobra, viper | 47 | ✓ Installing CLI dependencies: cobra, viper | ||
| 48 | ✓ Installing database dependencies: sqlite3, sqlc | 48 | ✓ Installing database dependencies: sqlite3, sqlc | ||
| 49 | ✓ Installing validation: go-playground/validator | 49 | ✓ Installing validation: go-playground/validator | ||
| @@ -57,18 +57,18 @@ Project initialized successfully! | |||||
| 57 | 57 | ||||
| 58 | Next steps: | 58 | Next steps: | ||
| 59 | 1. Review generated configs/ | 59 | 1. Review generated configs/ | ||
| 60 | - | 2. Run: go build ./cmd/dumb-browser | 60 | + | 2. Run: go build ./cmd/dumber |
| 61 | - | 3. Start development: dumb-browser --help | 61 | + | 3. Start development: dumber --help |
| 62 | ``` | 62 | ``` | ||
| 63 | 63 | ||||
| 64 | ## Subcommands | 64 | ## Subcommands | ||
| 65 | 65 | ||||
| 66 | - | ### `dumb-browser init deps` | 66 | + | ### `dumber init deps` |
| 67 | **Purpose**: Install only dependencies without project structure | 67 | **Purpose**: Install only dependencies without project structure | ||
| 68 | 68 | ||||
| 69 | **Syntax**: | 69 | **Syntax**: | ||
| 70 | ```bash | 70 | ```bash | ||
| 71 | - | dumb-browser init deps [OPTIONS] | 71 | + | dumber init deps [OPTIONS] |
| 72 | ``` | 72 | ``` | ||
| 73 | 73 | ||||
| 74 | **Options**: | 74 | **Options**: | ||
| @@ -80,21 +80,21 @@ dumb-browser init deps [OPTIONS] | |||||
| 80 | **Examples**: | 80 | **Examples**: | ||
| 81 | ```bash | 81 | ```bash | ||
| 82 | # Install only CLI dependencies | 82 | # Install only CLI dependencies | ||
| 83 | - | dumb-browser init deps --only=cli | 83 | + | dumber init deps --only=cli |
| 84 | 84 | ||||
| 85 | # Install everything except Wails | 85 | # Install everything except Wails | ||
| 86 | - | dumb-browser init deps --skip=wails | 86 | + | dumber init deps --skip=wails |
| 87 | 87 | ||||
| 88 | # Check current dependency status | 88 | # Check current dependency status | ||
| 89 | - | dumb-browser init deps --check | 89 | + | dumber init deps --check |
| 90 | ``` | 90 | ``` | ||
| 91 | 91 | ||||
| 92 | - | ### `dumb-browser init structure` | 92 | + | ### `dumber init structure` |
| 93 | **Purpose**: Create project directory structure only | 93 | **Purpose**: Create project directory structure only | ||
| 94 | 94 | ||||
| 95 | **Syntax**: | 95 | **Syntax**: | ||
| 96 | ```bash | 96 | ```bash | ||
| 97 | - | dumb-browser init structure [OPTIONS] | 97 | + | dumber init structure [OPTIONS] |
| 98 | ``` | 98 | ``` | ||
| 99 | 99 | ||||
| 100 | **Options**: | 100 | **Options**: | ||
| @@ -104,21 +104,21 @@ dumb-browser init structure [OPTIONS] | |||||
| 104 | **Examples**: | 104 | **Examples**: | ||
| 105 | ```bash | 105 | ```bash | ||
| 106 | # Create full project structure | 106 | # Create full project structure | ||
| 107 | - | dumb-browser init structure | 107 | + | dumber init structure |
| 108 | 108 | ||||
| 109 | # Preview directory layout | 109 | # Preview directory layout | ||
| 110 | - | dumb-browser init structure --preview | 110 | + | dumber init structure --preview |
| 111 | 111 | ||||
| 112 | # Minimal structure (no example files) | 112 | # Minimal structure (no example files) | ||
| 113 | - | dumb-browser init structure --template=minimal | 113 | + | dumber init structure --template=minimal |
| 114 | ``` | 114 | ``` | ||
| 115 | 115 | ||||
| 116 | - | ### `dumb-browser init config` | 116 | + | ### `dumber init config` |
| 117 | **Purpose**: Generate configuration files only | 117 | **Purpose**: Generate configuration files only | ||
| 118 | 118 | ||||
| 119 | **Syntax**: | 119 | **Syntax**: | ||
| 120 | ```bash | 120 | ```bash | ||
| 121 | - | dumb-browser init config [OPTIONS] | 121 | + | dumber init config [OPTIONS] |
| 122 | ``` | 122 | ``` | ||
| 123 | 123 | ||||
| 124 | **Options**: | 124 | **Options**: | ||
| @@ -128,23 +128,23 @@ dumb-browser init config [OPTIONS] | |||||
| 128 | **Examples**: | 128 | **Examples**: | ||
| 129 | ```bash | 129 | ```bash | ||
| 130 | # Generate all config files | 130 | # Generate all config files | ||
| 131 | - | dumb-browser init config | 131 | + | dumber init config |
| 132 | 132 | ||||
| 133 | # Only SQLC configuration | 133 | # Only SQLC configuration | ||
| 134 | - | dumb-browser init config --type=sqlc | 134 | + | dumber init config --type=sqlc |
| 135 | 135 | ||||
| 136 | # Replace existing configs | 136 | # Replace existing configs | ||
| 137 | - | dumb-browser init config --overwrite | 137 | + | dumber init config --overwrite |
| 138 | ``` | 138 | ``` | ||
| 139 | 139 | ||||
| 140 | ## Status and Information Commands | 140 | ## Status and Information Commands | ||
| 141 | 141 | ||||
| 142 | - | ### `dumb-browser init status` | 142 | + | ### `dumber init status` |
| 143 | **Purpose**: Show initialization status | 143 | **Purpose**: Show initialization status | ||
| 144 | 144 | ||||
| 145 | **Syntax**: | 145 | **Syntax**: | ||
| 146 | ```bash | 146 | ```bash | ||
| 147 | - | dumb-browser init status [OPTIONS] | 147 | + | dumber init status [OPTIONS] |
| 148 | ``` | 148 | ``` | ||
| 149 | 149 | ||||
| 150 | **Options**: | 150 | **Options**: | ||
| @@ -169,12 +169,12 @@ Structure: | |||||
| 169 | Build Status: ✓ Ready (go build succeeds) | 169 | Build Status: ✓ Ready (go build succeeds) | ||
| 170 | ``` | 170 | ``` | ||
| 171 | 171 | ||||
| 172 | - | ### `dumb-browser init verify` | 172 | + | ### `dumber init verify` |
| 173 | **Purpose**: Verify complete installation | 173 | **Purpose**: Verify complete installation | ||
| 174 | 174 | ||||
| 175 | **Syntax**: | 175 | **Syntax**: | ||
| 176 | ```bash | 176 | ```bash | ||
| 177 | - | dumb-browser init verify [OPTIONS] | 177 | + | dumber init verify [OPTIONS] |
| 178 | ``` | 178 | ``` | ||
| 179 | 179 | ||||
| 180 | **Options**: | 180 | **Options**: | ||
| @@ -192,7 +192,7 @@ dumb-browser init verify [OPTIONS] | |||||
| 192 | 192 | ||||
| 193 | **Dependency Installation Failures**: | 193 | **Dependency Installation Failures**: | ||
| 194 | ```bash | 194 | ```bash | ||
| 195 | - | $ dumb-browser init | 195 | + | $ dumber init |
| 196 | ✗ Installing database dependencies: sqlite3 | 196 | ✗ Installing database dependencies: sqlite3 | ||
| 197 | Error: CGO_ENABLED=1 required for sqlite3 | 197 | Error: CGO_ENABLED=1 required for sqlite3 | ||
| 198 | Suggestion: Run 'export CGO_ENABLED=1' and try again | 198 | Suggestion: Run 'export CGO_ENABLED=1' and try again | ||
| @@ -201,7 +201,7 @@ $ dumb-browser init | |||||
| 201 | 201 | ||||
| 202 | **System Dependency Missing**: | 202 | **System Dependency Missing**: | ||
| 203 | ```bash | 203 | ```bash | ||
| 204 | - | $ dumb-browser init | 204 | + | $ dumber init |
| 205 | ✗ Installing Wails v3-alpha | 205 | ✗ Installing Wails v3-alpha | ||
| 206 | Error: libwebkit2gtk-4.0-dev not found | 206 | Error: libwebkit2gtk-4.0-dev not found | ||
| 207 | Suggestion: Install with 'sudo apt install libwebkit2gtk-4.0-dev' | 207 | Suggestion: Install with 'sudo apt install libwebkit2gtk-4.0-dev' | ||
| @@ -210,9 +210,9 @@ $ dumb-browser init | |||||
| 210 | 210 | ||||
| 211 | **Project Already Exists**: | 211 | **Project Already Exists**: | ||
| 212 | ```bash | 212 | ```bash | ||
| 213 | - | $ dumb-browser init | 213 | + | $ dumber init |
| 214 | ✗ Project already initialized | 214 | ✗ Project already initialized | ||
| 215 | - | Use --force to reinitialize or run 'dumb-browser init status' | 215 | + | Use --force to reinitialize or run 'dumber init status' |
| 216 | Exit Code: 4 | 216 | Exit Code: 4 | ||
| 217 | ``` | 217 | ``` | ||
| 218 | 218 | ||||
| @@ -222,24 +222,24 @@ $ dumb-browser init | |||||
| 222 | Commands designed for automated environments: | 222 | Commands designed for automated environments: | ||
| 223 | ```bash | 223 | ```bash | ||
| 224 | # Non-interactive initialization | 224 | # Non-interactive initialization | ||
| 225 | - | dumb-browser init --verbose > init.log 2>&1 | 225 | + | dumber init --verbose > init.log 2>&1 |
| 226 | 226 | ||||
| 227 | # Verification in CI | 227 | # Verification in CI | ||
| 228 | - | dumb-browser init verify --report=ci-report.json | 228 | + | dumber init verify --report=ci-report.json |
| 229 | ``` | 229 | ``` | ||
| 230 | 230 | ||||
| 231 | ### Development Workflow Integration | 231 | ### Development Workflow Integration | ||
| 232 | ```bash | 232 | ```bash | ||
| 233 | # Quick setup for new contributors | 233 | # Quick setup for new contributors | ||
| 234 | git clone <repo> && cd <repo> | 234 | git clone <repo> && cd <repo> | ||
| 235 | - | dumb-browser init | 235 | + | dumber init |
| 236 | go test ./... | 236 | go test ./... | ||
| 237 | ``` | 237 | ``` | ||
| 238 | 238 | ||||
| 239 | ### IDE Integration | 239 | ### IDE Integration | ||
| 240 | Status command provides machine-readable output: | 240 | Status command provides machine-readable output: | ||
| 241 | ```bash | 241 | ```bash | ||
| 242 | - | dumb-browser init status --format=json | jq '.dependencies.wails.status' | 242 | + | dumber init status --format=json | jq '.dependencies.wails.status' |
| 243 | ``` | 243 | ``` | ||
| 244 | 244 | ||||
| 245 | This CLI contract supports the initialization phase while providing flexibility for different development scenarios and debugging needs.⏎̸ | 245 | This CLI contract supports the initialization phase while providing flexibility for different development scenarios and debugging needs.⏎̸ | ||
M specs/001-init-project/contracts/go-module.yaml +5 -5
| @@ -1,7 +1,7 @@ | |||||
| 1 | # Go Module Contract: Project Dependencies | 1 | # Go Module Contract: Project Dependencies | ||
| 2 | 2 | ||||
| 3 | # Expected go.mod file structure after initialization | 3 | # Expected go.mod file structure after initialization | ||
| 4 | - | module: dumb-browser | 4 | + | module: dumber |
| 5 | go_version: "1.25" | 5 | go_version: "1.25" | ||
| 6 | 6 | ||||
| 7 | # Runtime dependencies (will be installed via go get @latest) | 7 | # Runtime dependencies (will be installed via go get @latest) | ||
| @@ -49,7 +49,7 @@ tools: | |||||
| 49 | 49 | ||||
| 50 | # Expected go.mod content template | 50 | # Expected go.mod content template | ||
| 51 | go_mod_template: | | 51 | go_mod_template: | | ||
| 52 | - | module dumb-browser | 52 | + | module dumber |
| 53 | 53 | ||||
| 54 | go 1.21 | 54 | go 1.21 | ||
| 55 | 55 | ||||
| @@ -102,7 +102,7 @@ build: | |||||
| 102 | verification: | 102 | verification: | ||
| 103 | module_valid: "go mod verify" | 103 | module_valid: "go mod verify" | ||
| 104 | dependencies_resolved: "go mod download" | 104 | dependencies_resolved: "go mod download" | ||
| 105 | - | build_succeeds: "go build ./cmd/dumb-browser" | 105 | + | build_succeeds: "go build ./cmd/dumber" |
| 106 | tests_pass: "go test ./..." | 106 | tests_pass: "go test ./..." | ||
| 107 | tools_available: | 107 | tools_available: | ||
| 108 | - "sqlc version" | 108 | - "sqlc version" | ||
| @@ -114,13 +114,13 @@ success_criteria: | |||||
| 114 | command: "test -f go.mod" | 114 | command: "test -f go.mod" | ||
| 115 | 115 | ||||
| 116 | - name: "module name correct" | 116 | - name: "module name correct" | ||
| 117 | - | command: "grep -q 'module dumb-browser' go.mod" | 117 | + | command: "grep -q 'module dumber' go.mod" |
| 118 | 118 | ||||
| 119 | - name: "all dependencies resolved" | 119 | - name: "all dependencies resolved" | ||
| 120 | command: "go mod download && echo 'OK'" | 120 | command: "go mod download && echo 'OK'" | ||
| 121 | 121 | ||||
| 122 | - name: "build succeeds" | 122 | - name: "build succeeds" | ||
| 123 | - | command: "go build -o /tmp/test-build ./cmd/dumb-browser" | 123 | + | command: "go build -o /tmp/test-build ./cmd/dumber" |
| 124 | cleanup: "rm -f /tmp/test-build" | 124 | cleanup: "rm -f /tmp/test-build" | ||
| 125 | 125 | ||||
| 126 | - name: "sqlc available" | 126 | - name: "sqlc available" | ||
M specs/001-init-project/data-model.md +5 -5
| @@ -1,7 +1,7 @@ | |||||
| 1 | # Data Model: Project Initialization | 1 | # Data Model: Project Initialization | ||
| 2 | 2 | ||||
| 3 | **Date**: 2025-01-09 | 3 | **Date**: 2025-01-09 | ||
| 4 | - | **Context**: Data structures for dumb-browser project initialization | 4 | + | **Context**: Data structures for dumber project initialization |
| 5 | 5 | ||||
| 6 | ## Core Entities | 6 | ## Core Entities | ||
| 7 | 7 | ||||
| @@ -11,7 +11,7 @@ | |||||
| 11 | **Structure**: | 11 | **Structure**: | ||
| 12 | ```go | 12 | ```go | ||
| 13 | type ModuleConfig struct { | 13 | type ModuleConfig struct { | ||
| 14 | - | Name string `validate:"required"` // "dumb-browser" | 14 | + | Name string `validate:"required"` // "dumber" |
| 15 | Version string `validate:"required,semver"` // "v0.1.0" | 15 | Version string `validate:"required,semver"` // "v0.1.0" | ||
| 16 | GoVersion string `validate:"required"` // Go version requirement | 16 | GoVersion string `validate:"required"` // Go version requirement | ||
| 17 | Dependencies []Dependency `validate:"dive"` | 17 | Dependencies []Dependency `validate:"dive"` | ||
| @@ -155,9 +155,9 @@ NotStarted → InProgress → Success | |||||
| 155 | ## Integration Points | 155 | ## Integration Points | ||
| 156 | 156 | ||||
| 157 | ### With CLI Commands | 157 | ### With CLI Commands | ||
| 158 | - | - `dumb-browser init` → Creates **ModuleConfig** and **ProjectStructure** | 158 | + | - `dumber init` → Creates **ModuleConfig** and **ProjectStructure** |
| 159 | - | - `dumb-browser deps install` → Updates **InstallationStatus** | 159 | + | - `dumber deps install` → Updates **InstallationStatus** |
| 160 | - | - `dumb-browser deps verify` → Validates all **Dependencies** | 160 | + | - `dumber deps verify` → Validates all **Dependencies** |
| 161 | 161 | ||||
| 162 | ### With Configuration Files | 162 | ### With Configuration Files | ||
| 163 | - **go.mod**: Generated from **ModuleConfig** | 163 | - **go.mod**: Generated from **ModuleConfig** | ||
M specs/001-init-project/plan.md +3 -3
| @@ -1,7 +1,7 @@ | |||||
| 1 | # Implementation Plan: Project Initialization with Dependencies | 1 | # Implementation Plan: Project Initialization with Dependencies | ||
| 2 | 2 | ||||
| 3 | **Branch**: `001-init-project` | **Date**: 2025-01-09 | **Spec**: [spec.md](./spec.md) | 3 | **Branch**: `001-init-project` | **Date**: 2025-01-09 | **Spec**: [spec.md](./spec.md) | ||
| 4 | - | **Input**: Feature specification from `/home/brice/dev/projects/dumb-browser/specs/001-init-project/spec.md` | 4 | + | **Input**: Feature specification from `/home/brice/dev/projects/dumber/specs/001-init-project/spec.md` |
| 5 | 5 | ||||
| 6 | ## Execution Flow (/plan command scope) | 6 | ## Execution Flow (/plan command scope) | ||
| 7 | ``` | 7 | ``` | ||
| @@ -29,7 +29,7 @@ | |||||
| 29 | - Phase 3-4: Implementation execution (manual or via tools) | 29 | - Phase 3-4: Implementation execution (manual or via tools) | ||
| 30 | 30 | ||||
| 31 | ## Summary | 31 | ## Summary | ||
| 32 | - | Initialize a Go project with all required dependencies for the dumb-browser application, following the constitution's technical stack: Go + Wails v3-alpha + WebKit2GTK + SQLite with CLI (Cobra + Viper), database (SQLC), and validation (go-playground/validator) components. Use `go get @latest` for all dependencies. | 32 | + | Initialize a Go project with all required dependencies for the dumber application, following the constitution's technical stack: Go + Wails v3-alpha + WebKit2GTK + SQLite with CLI (Cobra + Viper), database (SQLC), and validation (go-playground/validator) components. Use `go get @latest` for all dependencies. |
| 33 | 33 | ||||
| 34 | ## Technical Context | 34 | ## Technical Context | ||
| 35 | **User Details**: We need to add all deps using go get @latest | 35 | **User Details**: We need to add all deps using go get @latest | ||
| @@ -47,7 +47,7 @@ Initialize a Go project with all required dependencies for the dumb-browser appl | |||||
| 47 | *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* | 47 | *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* | ||
| 48 | 48 | ||||
| 49 | **Simplicity**: | 49 | **Simplicity**: | ||
| 50 | - | - Projects: 1 (single desktop app - dumb-browser) | 50 | + | - Projects: 1 (single desktop app - dumber) |
| 51 | - Using framework directly? YES (Wails, Cobra, Viper directly) | 51 | - Using framework directly? YES (Wails, Cobra, Viper directly) | ||
| 52 | - Single data model? YES (SQLite schema matches Go structs via SQLC) | 52 | - Single data model? YES (SQLite schema matches Go structs via SQLC) | ||
| 53 | - Avoiding patterns? YES (no unnecessary abstractions for initialization) | 53 | - Avoiding patterns? YES (no unnecessary abstractions for initialization) | ||
M specs/001-init-project/quickstart.md +13 -13
| @@ -26,15 +26,15 @@ pacman -Q webkit2gtk gtk3 | |||||
| 26 | Navigate to project root and initialize: | 26 | Navigate to project root and initialize: | ||
| 27 | 27 | ||||
| 28 | ```bash | 28 | ```bash | ||
| 29 | - | cd /home/brice/dev/projects/dumb-browser | 29 | + | cd /home/brice/dev/projects/dumber |
| 30 | 30 | ||||
| 31 | # Initialize with verbose output to see progress | 31 | # Initialize with verbose output to see progress | ||
| 32 | - | dumb-browser init --verbose | 32 | + | dumber init --verbose |
| 33 | ``` | 33 | ``` | ||
| 34 | 34 | ||||
| 35 | **Expected Output**: | 35 | **Expected Output**: | ||
| 36 | ``` | 36 | ``` | ||
| 37 | - | ✓ Initializing Go module: dumb-browser | 37 | + | ✓ Initializing Go module: dumber |
| 38 | ✓ Installing CLI dependencies: cobra, viper | 38 | ✓ Installing CLI dependencies: cobra, viper | ||
| 39 | ✓ Installing database dependencies: ncruces/go-sqlite3, sqlc | 39 | ✓ Installing database dependencies: ncruces/go-sqlite3, sqlc | ||
| 40 | ✓ Installing validation: go-playground/validator | 40 | ✓ Installing validation: go-playground/validator | ||
| @@ -48,8 +48,8 @@ Project initialized successfully! | |||||
| 48 | 48 | ||||
| 49 | Next steps: | 49 | Next steps: | ||
| 50 | 1. Review generated configs/ | 50 | 1. Review generated configs/ | ||
| 51 | - | 2. Run: go build ./cmd/dumb-browser | 51 | + | 2. Run: go build ./cmd/dumber |
| 52 | - | 3. Start development: dumb-browser --help | 52 | + | 3. Start development: dumber --help |
| 53 | ``` | 53 | ``` | ||
| 54 | 54 | ||||
| 55 | ## Step 2: Verify Installation | 55 | ## Step 2: Verify Installation | ||
| @@ -58,7 +58,7 @@ Check that all components are correctly installed: | |||||
| 58 | 58 | ||||
| 59 | ```bash | 59 | ```bash | ||
| 60 | # Verify project status | 60 | # Verify project status | ||
| 61 | - | dumb-browser init status | 61 | + | dumber init status |
| 62 | 62 | ||||
| 63 | # Expected output: | 63 | # Expected output: | ||
| 64 | # Project Status: ✓ Initialized | 64 | # Project Status: ✓ Initialized | ||
| @@ -72,7 +72,7 @@ Detailed verification: | |||||
| 72 | ```bash | 72 | ```bash | ||
| 73 | # Check go.mod exists and is valid | 73 | # Check go.mod exists and is valid | ||
| 74 | test -f go.mod && echo "✓ go.mod exists" | 74 | test -f go.mod && echo "✓ go.mod exists" | ||
| 75 | - | grep -q "module dumb-browser" go.mod && echo "✓ Module name correct" | 75 | + | grep -q "module dumber" go.mod && echo "✓ Module name correct" |
| 76 | 76 | ||||
| 77 | # Verify dependencies are downloadable | 77 | # Verify dependencies are downloadable | ||
| 78 | go mod download && echo "✓ Dependencies resolved" | 78 | go mod download && echo "✓ Dependencies resolved" | ||
| @@ -110,10 +110,10 @@ Test the full application with Wails: | |||||
| 110 | export CGO_ENABLED=1 | 110 | export CGO_ENABLED=1 | ||
| 111 | 111 | ||||
| 112 | # Test full application build | 112 | # Test full application build | ||
| 113 | - | go build ./cmd/dumb-browser && echo "✓ Full build successful" | 113 | + | go build ./cmd/dumber && echo "✓ Full build successful" |
| 114 | 114 | ||||
| 115 | # Clean up test binary | 115 | # Clean up test binary | ||
| 116 | - | rm -f dumb-browser | 116 | + | rm -f dumber |
| 117 | ``` | 117 | ``` | ||
| 118 | 118 | ||||
| 119 | ## Step 5: Test Project Structure | 119 | ## Step 5: Test Project Structure | ||
| @@ -145,7 +145,7 @@ test -f configs/sqlc.yaml && echo "✓ SQLC config exists" | |||||
| 145 | sqlc compile -f configs/sqlc.yaml && echo "✓ SQLC config valid" | 145 | sqlc compile -f configs/sqlc.yaml && echo "✓ SQLC config valid" | ||
| 146 | 146 | ||||
| 147 | # Check basic project files exist (if generated) | 147 | # Check basic project files exist (if generated) | ||
| 148 | - | test -f cmd/dumb-browser/main.go && echo "✓ Main entry point exists" | 148 | + | test -f cmd/dumber/main.go && echo "✓ Main entry point exists" |
| 149 | ``` | 149 | ``` | ||
| 150 | 150 | ||||
| 151 | ## Step 7: Test Module Dependencies | 151 | ## Step 7: Test Module Dependencies | ||
| @@ -186,7 +186,7 @@ go test -race ./... && echo "✓ No race conditions detected" | |||||
| 186 | **Solution** (Arch Linux): | 186 | **Solution** (Arch Linux): | ||
| 187 | ```bash | 187 | ```bash | ||
| 188 | sudo pacman -S webkit2gtk gtk3 | 188 | sudo pacman -S webkit2gtk gtk3 | ||
| 189 | - | dumb-browser init deps --only=wails | 189 | + | dumber init deps --only=wails |
| 190 | ``` | 190 | ``` | ||
| 191 | 191 | ||||
| 192 | ### Issue: CGO disabled for Wails | 192 | ### Issue: CGO disabled for Wails | ||
| @@ -194,7 +194,7 @@ dumb-browser init deps --only=wails | |||||
| 194 | **Solution**: | 194 | **Solution**: | ||
| 195 | ```bash | 195 | ```bash | ||
| 196 | export CGO_ENABLED=1 | 196 | export CGO_ENABLED=1 | ||
| 197 | - | go build ./cmd/dumb-browser | 197 | + | go build ./cmd/dumber |
| 198 | ``` | 198 | ``` | ||
| 199 | 199 | ||||
| 200 | ### Issue: Wrong SQLite driver | 200 | ### Issue: Wrong SQLite driver | ||
| @@ -211,7 +211,7 @@ grep sqlite3 go.mod | |||||
| 211 | ```bash | 211 | ```bash | ||
| 212 | go env -w GOPROXY=proxy.golang.org,direct | 212 | go env -w GOPROXY=proxy.golang.org,direct | ||
| 213 | go env -w GOSUMDB=sum.golang.org | 213 | go env -w GOSUMDB=sum.golang.org | ||
| 214 | - | dumb-browser init deps | 214 | + | dumber init deps |
| 215 | ``` | 215 | ``` | ||
| 216 | 216 | ||||
| 217 | ## Success Criteria | 217 | ## Success Criteria | ||
M specs/001-init-project/research.md +5 -5
| @@ -1,20 +1,20 @@ | |||||
| 1 | # Research: Project Initialization Dependencies | 1 | # Research: Project Initialization Dependencies | ||
| 2 | 2 | ||||
| 3 | **Date**: 2025-01-09 | 3 | **Date**: 2025-01-09 | ||
| 4 | - | **Context**: Resolve technical unknowns for dumb-browser project initialization | 4 | + | **Context**: Resolve technical unknowns for dumber project initialization |
| 5 | 5 | ||||
| 6 | ## Research Questions Resolved | 6 | ## Research Questions Resolved | ||
| 7 | 7 | ||||
| 8 | ### 1. Go Module Initialization | 8 | ### 1. Go Module Initialization | ||
| 9 | - | **Decision**: Use `go mod init dumb-browser` | 9 | + | **Decision**: Use `go mod init dumber` |
| 10 | **Rationale**: | 10 | **Rationale**: | ||
| 11 | - Simple module name matching the binary name | 11 | - Simple module name matching the binary name | ||
| 12 | - Local development focus (no public import path needed) | 12 | - Local development focus (no public import path needed) | ||
| 13 | - Follows Go module naming conventions | 13 | - Follows Go module naming conventions | ||
| 14 | 14 | ||||
| 15 | **Alternatives considered**: | 15 | **Alternatives considered**: | ||
| 16 | - | - `github.com/user/dumb-browser` - rejected (unnecessary complexity for local tool) | 16 | + | - `github.com/user/dumber` - rejected (unnecessary complexity for local tool) |
| 17 | - | - `dumb-browser/v1` - rejected (premature versioning) | 17 | + | - `dumber/v1` - rejected (premature versioning) |
| 18 | 18 | ||||
| 19 | ### 2. Wails v3-Alpha Compatibility | 19 | ### 2. Wails v3-Alpha Compatibility | ||
| 20 | **Decision**: Use Wails v3.0.0-alpha.28 (latest available) | 20 | **Decision**: Use Wails v3.0.0-alpha.28 (latest available) | ||
| @@ -117,7 +117,7 @@ Based on research, the installation sequence should be: | |||||
| 117 | 117 | ||||
| 118 | 1. **Initialize Go module** | 118 | 1. **Initialize Go module** | ||
| 119 | ```bash | 119 | ```bash | ||
| 120 | - | go mod init dumb-browser | 120 | + | go mod init dumber |
| 121 | ``` | 121 | ``` | ||
| 122 | 122 | ||||
| 123 | 2. **Install CLI dependencies** | 123 | 2. **Install CLI dependencies** | ||
M specs/001-init-project/spec.md +1 -1
| @@ -39,7 +39,7 @@ | |||||
| 39 | ## User Scenarios & Testing *(mandatory)* | 39 | ## User Scenarios & Testing *(mandatory)* | ||
| 40 | 40 | ||||
| 41 | ### Primary User Story | 41 | ### Primary User Story | ||
| 42 | - | As a developer starting work on the dumb-browser project, I need a properly initialized Go project with all required dependencies installed so that I can begin implementing features according to the project constitution without manual setup overhead. | 42 | + | As a developer starting work on the dumber project, I need a properly initialized Go project with all required dependencies installed so that I can begin implementing features according to the project constitution without manual setup overhead. |
| 43 | 43 | ||||
| 44 | ### Acceptance Scenarios | 44 | ### Acceptance Scenarios | ||
| 45 | 1. **Given** an empty project directory, **When** project initialization is complete, **Then** the project has a valid Go module structure | 45 | 1. **Given** an empty project directory, **When** project initialization is complete, **Then** the project has a valid Go module structure | ||
M specs/001-init-project/tasks.md +4 -4
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Tasks: Project Initialization with Dependencies | 1 | # Tasks: Project Initialization with Dependencies | ||
| 2 | 2 | ||||
| 3 | - | **Input**: Design documents from `/home/brice/dev/projects/dumb-browser/specs/001-init-project/` | 3 | + | **Input**: Design documents from `/home/brice/dev/projects/dumber/specs/001-init-project/` |
| 4 | **Prerequisites**: plan.md, research.md, data-model.md, contracts/ | 4 | **Prerequisites**: plan.md, research.md, data-model.md, contracts/ | ||
| 5 | 5 | ||||
| 6 | ## Execution Flow (main) | 6 | ## Execution Flow (main) | ||
| @@ -15,8 +15,8 @@ Single project structure: `cmd/`, `internal/`, `tests/` at repository root per c | |||||
| 15 | 15 | ||||
| 16 | ## Phase 3.1: Setup and Structure | 16 | ## Phase 3.1: Setup and Structure | ||
| 17 | 17 | ||||
| 18 | - | - [ ] T001 Initialize Go module with `go mod init dumb-browser` in project root | 18 | + | - [ ] T001 Initialize Go module with `go mod init dumber` in project root |
| 19 | - | - [ ] T002 Create project directory structure: `cmd/dumb-browser/`, `internal/{cli,db,parser,browser}/`, `migrations/`, `configs/`, `tests/{contract,integration,unit}/` | 19 | + | - [ ] T002 Create project directory structure: `cmd/dumber/`, `internal/{cli,db,parser,browser}/`, `migrations/`, `configs/`, `tests/{contract,integration,unit}/` |
| 20 | - [ ] T003 [P] Configure Git repository with .gitignore for Go projects | 20 | - [ ] T003 [P] Configure Git repository with .gitignore for Go projects | ||
| 21 | 21 | ||||
| 22 | ## Phase 3.2: Dependency Installation | 22 | ## Phase 3.2: Dependency Installation | ||
| @@ -44,7 +44,7 @@ Single project structure: `cmd/`, `internal/`, `tests/` at repository root per c | |||||
| 44 | - [ ] T016 [P] ModuleConfig model in `internal/models/module.go` | 44 | - [ ] T016 [P] ModuleConfig model in `internal/models/module.go` | ||
| 45 | - [ ] T017 [P] ProjectStructure model in `internal/models/structure.go` | 45 | - [ ] T017 [P] ProjectStructure model in `internal/models/structure.go` | ||
| 46 | - [ ] T018 [P] InstallationStatus model in `internal/models/status.go` | 46 | - [ ] T018 [P] InstallationStatus model in `internal/models/status.go` | ||
| 47 | - | - [ ] T019 CLI root command setup in `cmd/dumb-browser/main.go` | 47 | + | - [ ] T019 CLI root command setup in `cmd/dumber/main.go` |
| 48 | - [ ] T020 CLI init command implementation in `internal/cli/init.go` | 48 | - [ ] T020 CLI init command implementation in `internal/cli/init.go` | ||
| 49 | - [ ] T021 Dependency installer service in `internal/services/installer.go` | 49 | - [ ] T021 Dependency installer service in `internal/services/installer.go` | ||
| 50 | - [ ] T022 Project structure creator in `internal/services/structure.go` | 50 | - [ ] T022 Project structure creator in `internal/services/structure.go` | ||