1package cli
2
3import (
4 "context"
5 "database/sql"
6 "fmt"
7 "net/url"
8 "os"
9 "os/exec"
10 "regexp"
11 "strings"
12
13 "github.com/bnema/dumber/internal/config"
14
15 "github.com/spf13/cobra"
16)
17
18// NewBrowseCmd creates the browse command
19func NewBrowseCmd() *cobra.Command {
20 cmd := &cobra.Command{
21 Use: "browse <url|query>",
22 Short: "Browse a URL or search query",
23 Long: `Browse a URL directly or use search shortcuts like:
24 g:golang -> Google search for "golang"
25 gh:cobra -> GitHub search for "cobra"
26 yt:tutorials -> YouTube search for "tutorials"
27 w:go language -> Wikipedia search for "go language"
28
29Direct URLs are also supported:
30 https://example.com
31 example.com (automatically adds https://)`,
32 Args: cobra.ExactArgs(1),
33 RunE: func(_ *cobra.Command, args []string) error {
34 cli, err := NewCLI()
35 if err != nil {
36 return fmt.Errorf("failed to initialize CLI: %w", err)
37 }
38 defer func() {
39 if closeErr := cli.Close(); closeErr != nil {
40 fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
41 }
42 }()
43
44 return browse(cli, args[0])
45 },
46 }
47
48 return cmd
49}
50
51// browse handles the core browsing logic
52func browse(cli *CLI, input string) error {
53 ctx := context.Background()
54
55 // Parse the input to determine URL or shortcut
56 finalURL, err := parseInput(ctx, cli, input)
57 if err != nil {
58 return fmt.Errorf("failed to parse input: %w", err)
59 }
60
61 // Record in history
62 if err := recordVisit(ctx, cli, finalURL, ""); err != nil {
63 // Don't fail the browse operation if history recording fails
64 fmt.Fprintf(os.Stderr, "Warning: failed to record history: %v\n", err)
65 }
66
67 // Open URL using configuration
68 return openURLWithConfig(finalURL, cli.Config)
69}
70
71// parseInput processes user input and returns the final URL to browse
72func parseInput(ctx context.Context, cli *CLI, input string) (string, error) {
73 // Check if it's a shortcut (format: "prefix:query")
74 if strings.Contains(input, ":") && !strings.Contains(input, "://") {
75 parts := strings.SplitN(input, ":", 2) //nolint:mnd // split on first colon only
76 if len(parts) == 2 { //nolint:mnd // expect prefix and query parts
77 shortcut := strings.TrimSpace(parts[0])
78 query := strings.TrimSpace(parts[1])
79
80 if query == "" {
81 return "", fmt.Errorf("empty query for shortcut '%s'", shortcut)
82 }
83
84 // First check configuration-based shortcuts
85 if shortcutCfg, exists := cli.Config.SearchShortcuts[shortcut]; exists {
86 return fmt.Sprintf(shortcutCfg.URL, url.QueryEscape(query)), nil
87 }
88
89 // Fallback to database shortcuts for backward compatibility
90 shortcuts, err := cli.Queries.GetShortcuts(ctx)
91 if err != nil {
92 return "", fmt.Errorf("failed to get shortcuts: %w", err)
93 }
94
95 for _, s := range shortcuts {
96 if s.Shortcut == shortcut {
97 return fmt.Sprintf(s.UrlTemplate, url.QueryEscape(query)), nil
98 }
99 }
100
101 return "", fmt.Errorf("unknown shortcut '%s'", shortcut)
102 }
103 }
104
105 // Check if it's already a valid URL
106 if isValidURL(input) {
107 return input, nil
108 }
109
110 // Try to make it a URL by adding https://
111 if !strings.Contains(input, "://") {
112 candidate := "https://" + input
113 if isValidURL(candidate) {
114 return candidate, nil
115 }
116 }
117
118 // If all else fails, search Google for it
119 return fmt.Sprintf("https://www.google.com/search?q=%s", url.QueryEscape(input)), nil
120}
121
122// isValidURL checks if a string is a valid URL
123func isValidURL(str string) bool {
124 u, err := url.Parse(str)
125 if err != nil {
126 return false
127 }
128
129 // Must have a scheme and host
130 if u.Scheme == "" || u.Host == "" {
131 return false
132 }
133
134 // Must be http or https
135 if u.Scheme != "http" && u.Scheme != "https" {
136 return false
137 }
138
139 // Basic domain validation
140 domainRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?` +
141 `(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
142 return domainRegex.MatchString(u.Host)
143}
144
145// recordVisit adds or updates a URL visit in the history
146func recordVisit(ctx context.Context, cli *CLI, url, title string) error {
147 var sqlTitle sql.NullString
148 if title != "" {
149 sqlTitle = sql.NullString{String: title, Valid: true}
150 }
151
152 return cli.Queries.AddOrUpdateHistory(ctx, url, sqlTitle)
153}
154
155// openURL opens a URL using the configured browser
156func openURL(url string) error {
157 return openURLWithConfig(url, nil)
158}
159
160// openURLWithConfig opens a URL using our built-in browser
161func openURLWithConfig(url string, cfg *config.Config) error {
162 // Get the path to our own executable
163 executable, err := os.Executable()
164 if err != nil {
165 return fmt.Errorf("failed to get executable path: %w", err)
166 }
167
168 // Launch our own browser in browse mode with the URL directly
169 cmd := exec.Command(executable, "browse", url)
170
171 // Start the browser in detached mode
172 if err := cmd.Start(); err != nil {
173 return fmt.Errorf("failed to open URL in built-in browser: %w", err)
174 }
175
176 // Don't wait for the browser process to exit
177 go func() {
178 _ = cmd.Wait()
179 }()
180
181 fmt.Printf("Opening: %s (using built-in browser)\n", url)
182 return nil
183}
184
185// commandExists checks if a command is available in the system PATH
186func commandExists(cmd string) bool {
187 _, err := exec.LookPath(cmd)
188 return err == nil
189}