feat(wails): integrate Wails v3-alpha browser with unified CLI+GUI architecture
- Add Wails v3-alpha dependency and build system - Create unified main.go that detects CLI vs GUI mode automatically - Implement Wails services wrapping existing parser/browser/config functionality - Add clean landing page with history and shortcuts (no browser UI chrome) - Support direct website navigation via `dumber browse <url>` command - Maintain full CLI functionality while adding GUI option - Create spec for next enhancement phase (002-ui-enhancement-polishing) Usage: ./dumber -> Clean landing page with history/shortcuts ./dumber browse <url> -> Direct website navigation ./dumber version -> CLI mode with all existing features ./dumber --dmenu -> Dmenu integration (unchanged) Single 28MB binary provides both CLI and browser functionality.
- commit
- f83599b0c18a62f9c6bc858352e2a8fa3aafd23c
- author
- bamen · · 2025-09-08 19:54 UTC
- committer
- bamen · · 2025-09-08 18:07 UTC
- parent
- f17cf68
- signature
- unsigned
13 files · +1319 -85
A Taskfile.yml +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | version: '3' | |
| 2 | + | ||
| 3 | + | vars: | |
| 4 | + | APP_NAME: dumber | |
| 5 | + | VERSION: 0.1.0 | |
| 6 | + | COMMIT: | |
| 7 | + | sh: git rev-parse --short HEAD 2>/dev/null || echo "dev" | |
| 8 | + | BUILD_DATE: | |
| 9 | + | sh: date -u +"%Y-%m-%dT%H:%M:%SZ" | |
| 10 | + | ||
| 11 | + | tasks: | |
| 12 | + | build: | |
| 13 | + | desc: Build the unified Dumber application (CLI + Browser) | |
| 14 | + | cmds: | |
| 15 | + | - go build -ldflags "-X main.version={{.VERSION}} -X main.commit={{.COMMIT}} -X main.buildDate={{.BUILD_DATE}}" -o dist/{{.APP_NAME}} main.go | |
| 16 | + | ||
| 17 | + | dev: | |
| 18 | + | desc: Run the application in development mode (GUI) | |
| 19 | + | cmds: | |
| 20 | + | - go run main.go | |
| 21 | + | ||
| 22 | + | dev-cli: | |
| 23 | + | desc: Run the application in CLI development mode | |
| 24 | + | cmds: | |
| 25 | + | - go run main.go version | |
| 26 | + | ||
| 27 | + | test: | |
| 28 | + | desc: Run tests | |
| 29 | + | cmds: | |
| 30 | + | - go test ./... | |
| 31 | + | ||
| 32 | + | clean: | |
| 33 | + | desc: Clean build artifacts | |
| 34 | + | cmds: | |
| 35 | + | - rm -rf dist/ | |
| 36 | + | - mkdir -p dist | |
| 37 | + | ||
| 38 | + | demo: | |
| 39 | + | desc: Show different usage modes | |
| 40 | + | cmds: | |
| 41 | + | - echo "Building unified application..." | |
| 42 | + | - task build | |
| 43 | + | - echo "" | |
| 44 | + | - echo "Usage examples:" | |
| 45 | + | - echo " GUI mode{{":"}} ./dist/{{.APP_NAME}}" | |
| 46 | + | - echo " CLI mode{{":"}} ./dist/{{.APP_NAME}} version" | |
| 47 | + | - echo " History{{":"}} ./dist/{{.APP_NAME}} history" | |
| 48 | + | - echo " Dmenu{{":"}} ./dist/{{.APP_NAME}} --dmenu" | |
| 49 | + | - echo " Browse URL{{":"}} ./dist/{{.APP_NAME}} 'https://github.com'"⏎̸ | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | version: '3' | |||
| 2 | + | ||||
| 3 | + | vars: | |||
| 4 | + | APP_NAME: dumber | |||
| 5 | + | VERSION: 0.1.0 | |||
| 6 | + | COMMIT: | |||
| 7 | + | sh: git rev-parse --short HEAD 2>/dev/null || echo "dev" | |||
| 8 | + | BUILD_DATE: | |||
| 9 | + | sh: date -u +"%Y-%m-%dT%H:%M:%SZ" | |||
| 10 | + | ||||
| 11 | + | tasks: | |||
| 12 | + | build: | |||
| 13 | + | desc: Build the unified Dumber application (CLI + Browser) | |||
| 14 | + | cmds: | |||
| 15 | + | - go build -ldflags "-X main.version={{.VERSION}} -X main.commit={{.COMMIT}} -X main.buildDate={{.BUILD_DATE}}" -o dist/{{.APP_NAME}} main.go | |||
| 16 | + | ||||
| 17 | + | dev: | |||
| 18 | + | desc: Run the application in development mode (GUI) | |||
| 19 | + | cmds: | |||
| 20 | + | - go run main.go | |||
| 21 | + | ||||
| 22 | + | dev-cli: | |||
| 23 | + | desc: Run the application in CLI development mode | |||
| 24 | + | cmds: | |||
| 25 | + | - go run main.go version | |||
| 26 | + | ||||
| 27 | + | test: | |||
| 28 | + | desc: Run tests | |||
| 29 | + | cmds: | |||
| 30 | + | - go test ./... | |||
| 31 | + | ||||
| 32 | + | clean: | |||
| 33 | + | desc: Clean build artifacts | |||
| 34 | + | cmds: | |||
| 35 | + | - rm -rf dist/ | |||
| 36 | + | - mkdir -p dist | |||
| 37 | + | ||||
| 38 | + | demo: | |||
| 39 | + | desc: Show different usage modes | |||
| 40 | + | cmds: | |||
| 41 | + | - echo "Building unified application..." | |||
| 42 | + | - task build | |||
| 43 | + | - echo "" | |||
| 44 | + | - echo "Usage examples:" | |||
| 45 | + | - echo " GUI mode{{":"}} ./dist/{{.APP_NAME}}" | |||
| 46 | + | - echo " CLI mode{{":"}} ./dist/{{.APP_NAME}} version" | |||
| 47 | + | - echo " History{{":"}} ./dist/{{.APP_NAME}} history" | |||
| 48 | + | - echo " Dmenu{{":"}} ./dist/{{.APP_NAME}} --dmenu" | |||
| 49 | + | - echo " Browse URL{{":"}} ./dist/{{.APP_NAME}} 'https://github.com'"⏎̸ | |||
D cmd/dumb-browser/main.go +0 -62
| @@ -1,62 +0,0 @@ | |||
| 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 | - | }⏎̸ | |
| @@ -1,62 +0,0 @@ | |||||
| 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 | - | }⏎̸ | |||
M go.mod +39 -0
| @@ -7,21 +7,60 @@ require ( | |||
| 7 | 7 | github.com/ncruces/go-sqlite3 v0.29.0 | |
| 8 | 8 | github.com/spf13/cobra v1.10.1 | |
| 9 | 9 | github.com/spf13/viper v1.21.0 | |
| 10 | + | github.com/wailsapp/wails/v3 v3.0.0-alpha.28 | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | require ( | |
| 14 | + | dario.cat/mergo v1.0.1 // indirect | |
| 15 | + | github.com/Microsoft/go-winio v0.6.2 // indirect | |
| 16 | + | github.com/ProtonMail/go-crypto v1.1.6 // indirect | |
| 17 | + | github.com/adrg/xdg v0.5.3 // indirect | |
| 18 | + | github.com/bep/debounce v1.2.1 // indirect | |
| 19 | + | github.com/cloudflare/circl v1.6.0 // indirect | |
| 20 | + | github.com/cyphar/filepath-securejoin v0.4.1 // indirect | |
| 21 | + | github.com/ebitengine/purego v0.8.2 // indirect | |
| 22 | + | github.com/emirpasic/gods v1.18.1 // indirect | |
| 23 | + | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect | |
| 24 | + | github.com/go-git/go-billy/v5 v5.6.2 // indirect | |
| 25 | + | github.com/go-git/go-git/v5 v5.13.2 // indirect | |
| 26 | + | github.com/go-ole/go-ole v1.3.0 // indirect | |
| 13 | 27 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | |
| 28 | + | github.com/godbus/dbus/v5 v5.1.0 // indirect | |
| 29 | + | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect | |
| 30 | + | github.com/google/uuid v1.6.0 // indirect | |
| 14 | 31 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 32 | + | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect | |
| 33 | + | github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect | |
| 34 | + | github.com/kevinburke/ssh_config v1.2.0 // indirect | |
| 35 | + | github.com/leaanthony/go-ansi-parser v1.6.1 // indirect | |
| 36 | + | github.com/leaanthony/u v1.1.1 // indirect | |
| 37 | + | github.com/lmittmann/tint v1.0.7 // indirect | |
| 38 | + | github.com/mattn/go-colorable v0.1.14 // indirect | |
| 39 | + | github.com/mattn/go-isatty v0.0.20 // indirect | |
| 15 | 40 | github.com/ncruces/julianday v1.0.0 // indirect | |
| 16 | 41 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | |
| 42 | + | github.com/pjbgf/sha1cd v0.3.2 // indirect | |
| 43 | + | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect | |
| 44 | + | github.com/pkg/errors v0.9.1 // indirect | |
| 45 | + | github.com/rivo/uniseg v0.4.7 // indirect | |
| 17 | 46 | github.com/sagikazarmark/locafero v0.11.0 // indirect | |
| 47 | + | github.com/samber/lo v1.49.1 // indirect | |
| 48 | + | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect | |
| 49 | + | github.com/skeema/knownhosts v1.3.1 // indirect | |
| 18 | 50 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | |
| 19 | 51 | github.com/spf13/afero v1.15.0 // indirect | |
| 20 | 52 | github.com/spf13/cast v1.10.0 // indirect | |
| 21 | 53 | github.com/spf13/pflag v1.0.10 // indirect | |
| 22 | 54 | github.com/subosito/gotenv v1.6.0 // indirect | |
| 23 | 55 | github.com/tetratelabs/wazero v1.9.0 // indirect | |
| 56 | + | github.com/wailsapp/go-webview2 v1.0.21 // indirect | |
| 57 | + | github.com/wailsapp/mimetype v1.4.1 // indirect | |
| 58 | + | github.com/xanzy/ssh-agent v0.3.3 // indirect | |
| 24 | 59 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 60 | + | golang.org/x/crypto v0.41.0 // indirect | |
| 61 | + | golang.org/x/net v0.42.0 // indirect | |
| 25 | 62 | golang.org/x/sys v0.36.0 // indirect | |
| 26 | 63 | golang.org/x/text v0.29.0 // indirect | |
| 64 | + | gopkg.in/ini.v1 v1.67.0 // indirect | |
| 65 | + | gopkg.in/warnings.v0 v0.1.2 // indirect | |
| 27 | 66 | ) | |
| @@ -7,21 +7,60 @@ require ( | |||||
| 7 | github.com/ncruces/go-sqlite3 v0.29.0 | 7 | github.com/ncruces/go-sqlite3 v0.29.0 | ||
| 8 | github.com/spf13/cobra v1.10.1 | 8 | github.com/spf13/cobra v1.10.1 | ||
| 9 | github.com/spf13/viper v1.21.0 | 9 | github.com/spf13/viper v1.21.0 | ||
| 10 | + | github.com/wailsapp/wails/v3 v3.0.0-alpha.28 | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | require ( | 13 | require ( | ||
| 14 | + | dario.cat/mergo v1.0.1 // indirect | |||
| 15 | + | github.com/Microsoft/go-winio v0.6.2 // indirect | |||
| 16 | + | github.com/ProtonMail/go-crypto v1.1.6 // indirect | |||
| 17 | + | github.com/adrg/xdg v0.5.3 // indirect | |||
| 18 | + | github.com/bep/debounce v1.2.1 // indirect | |||
| 19 | + | github.com/cloudflare/circl v1.6.0 // indirect | |||
| 20 | + | github.com/cyphar/filepath-securejoin v0.4.1 // indirect | |||
| 21 | + | github.com/ebitengine/purego v0.8.2 // indirect | |||
| 22 | + | github.com/emirpasic/gods v1.18.1 // indirect | |||
| 23 | + | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect | |||
| 24 | + | github.com/go-git/go-billy/v5 v5.6.2 // indirect | |||
| 25 | + | github.com/go-git/go-git/v5 v5.13.2 // indirect | |||
| 26 | + | github.com/go-ole/go-ole v1.3.0 // indirect | |||
| 13 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | 27 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | ||
| 28 | + | github.com/godbus/dbus/v5 v5.1.0 // indirect | |||
| 29 | + | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect | |||
| 30 | + | github.com/google/uuid v1.6.0 // indirect | |||
| 14 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 31 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 32 | + | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect | |||
| 33 | + | github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect | |||
| 34 | + | github.com/kevinburke/ssh_config v1.2.0 // indirect | |||
| 35 | + | github.com/leaanthony/go-ansi-parser v1.6.1 // indirect | |||
| 36 | + | github.com/leaanthony/u v1.1.1 // indirect | |||
| 37 | + | github.com/lmittmann/tint v1.0.7 // indirect | |||
| 38 | + | github.com/mattn/go-colorable v0.1.14 // indirect | |||
| 39 | + | github.com/mattn/go-isatty v0.0.20 // indirect | |||
| 15 | github.com/ncruces/julianday v1.0.0 // indirect | 40 | github.com/ncruces/julianday v1.0.0 // indirect | ||
| 16 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | 41 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | ||
| 42 | + | github.com/pjbgf/sha1cd v0.3.2 // indirect | |||
| 43 | + | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect | |||
| 44 | + | github.com/pkg/errors v0.9.1 // indirect | |||
| 45 | + | github.com/rivo/uniseg v0.4.7 // indirect | |||
| 17 | github.com/sagikazarmark/locafero v0.11.0 // indirect | 46 | github.com/sagikazarmark/locafero v0.11.0 // indirect | ||
| 47 | + | github.com/samber/lo v1.49.1 // indirect | |||
| 48 | + | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect | |||
| 49 | + | github.com/skeema/knownhosts v1.3.1 // indirect | |||
| 18 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | 50 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | ||
| 19 | github.com/spf13/afero v1.15.0 // indirect | 51 | github.com/spf13/afero v1.15.0 // indirect | ||
| 20 | github.com/spf13/cast v1.10.0 // indirect | 52 | github.com/spf13/cast v1.10.0 // indirect | ||
| 21 | github.com/spf13/pflag v1.0.10 // indirect | 53 | github.com/spf13/pflag v1.0.10 // indirect | ||
| 22 | github.com/subosito/gotenv v1.6.0 // indirect | 54 | github.com/subosito/gotenv v1.6.0 // indirect | ||
| 23 | github.com/tetratelabs/wazero v1.9.0 // indirect | 55 | github.com/tetratelabs/wazero v1.9.0 // indirect | ||
| 56 | + | github.com/wailsapp/go-webview2 v1.0.21 // indirect | |||
| 57 | + | github.com/wailsapp/mimetype v1.4.1 // indirect | |||
| 58 | + | github.com/xanzy/ssh-agent v0.3.3 // indirect | |||
| 24 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 59 | go.yaml.in/yaml/v3 v3.0.4 // indirect | ||
| 60 | + | golang.org/x/crypto v0.41.0 // indirect | |||
| 61 | + | golang.org/x/net v0.42.0 // indirect | |||
| 25 | golang.org/x/sys v0.36.0 // indirect | 62 | golang.org/x/sys v0.36.0 // indirect | ||
| 26 | golang.org/x/text v0.29.0 // indirect | 63 | golang.org/x/text v0.29.0 // indirect | ||
| 64 | + | gopkg.in/ini.v1 v1.67.0 // indirect | |||
| 65 | + | gopkg.in/warnings.v0 v0.1.2 // indirect | |||
| 27 | ) | 66 | ) | ||
M go.sum +130 -5 generated file: collapsed
M internal/cli/browse.go +11 -18
| @@ -8,7 +8,6 @@ import ( | |||
| 8 | 8 | "os" | |
| 9 | 9 | "os/exec" | |
| 10 | 10 | "regexp" | |
| 11 | - | "runtime" | |
| 12 | 11 | "strings" | |
| 13 | 12 | ||
| 14 | 13 | "github.com/bnema/dumber/internal/config" | |
| @@ -158,26 +157,20 @@ func openURL(url string) error { | |||
| 158 | 157 | return openURLWithConfig(url, nil) | |
| 159 | 158 | } | |
| 160 | 159 | ||
| 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) | |
| 160 | + | // openURLWithConfig opens a URL using our built-in Wails browser | |
| 161 | + | func 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) | |
| 176 | 166 | } | |
| 177 | 167 | ||
| 168 | + | // Launch our own browser in browse mode with the URL directly | |
| 169 | + | cmd := exec.Command(executable, "browse", url) | |
| 170 | + | ||
| 178 | 171 | // Start the browser in detached mode | |
| 179 | 172 | if err := cmd.Start(); err != nil { | |
| 180 | - | return fmt.Errorf("failed to open URL in browser: %w", err) | |
| 173 | + | return fmt.Errorf("failed to open URL in built-in browser: %w", err) | |
| 181 | 174 | } | |
| 182 | 175 | ||
| 183 | 176 | // Don't wait for the browser process to exit | |
| @@ -185,7 +178,7 @@ func openURLWithConfig(url string, _ *config.Config) error { | |||
| 185 | 178 | _ = cmd.Wait() | |
| 186 | 179 | }() | |
| 187 | 180 | ||
| 188 | - | fmt.Printf("Opening: %s (using system default browser)\n", url) | |
| 181 | + | fmt.Printf("Opening: %s (using built-in browser)\n", url) | |
| 189 | 182 | return nil | |
| 190 | 183 | } | |
| 191 | 184 | ||
| @@ -8,7 +8,6 @@ import ( | |||||
| 8 | "os" | 8 | "os" | ||
| 9 | "os/exec" | 9 | "os/exec" | ||
| 10 | "regexp" | 10 | "regexp" | ||
| 11 | - | "runtime" | |||
| 12 | "strings" | 11 | "strings" | ||
| 13 | 12 | ||||
| 14 | "github.com/bnema/dumber/internal/config" | 13 | "github.com/bnema/dumber/internal/config" | ||
| @@ -158,26 +157,20 @@ func openURL(url string) error { | |||||
| 158 | return openURLWithConfig(url, nil) | 157 | return openURLWithConfig(url, nil) | ||
| 159 | } | 158 | } | ||
| 160 | 159 | ||||
| 161 | - | // openURLWithConfig opens a URL using system default browser | 160 | + | // openURLWithConfig opens a URL using our built-in Wails browser |
| 162 | - | // TODO: Replace with Wails browser integration | 161 | + | func openURLWithConfig(url string, cfg *config.Config) error { |
| 163 | - | func openURLWithConfig(url string, _ *config.Config) error { | 162 | + | // Get the path to our own executable |
| 164 | - | var cmd *exec.Cmd | 163 | + | executable, err := os.Executable() |
| 165 | - | 164 | + | if err != nil { | |
| 166 | - | // Use system default browser until Wails integration is ready | 165 | + | return fmt.Errorf("failed to get executable path: %w", err) |
| 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 | } | 166 | } | ||
| 177 | 167 | ||||
| 168 | + | // Launch our own browser in browse mode with the URL directly | |||
| 169 | + | cmd := exec.Command(executable, "browse", url) | |||
| 170 | + | ||||
| 178 | // Start the browser in detached mode | 171 | // Start the browser in detached mode | ||
| 179 | if err := cmd.Start(); err != nil { | 172 | if err := cmd.Start(); err != nil { | ||
| 180 | - | return fmt.Errorf("failed to open URL in browser: %w", err) | 173 | + | return fmt.Errorf("failed to open URL in built-in browser: %w", err) |
| 181 | } | 174 | } | ||
| 182 | 175 | ||||
| 183 | // Don't wait for the browser process to exit | 176 | // Don't wait for the browser process to exit | ||
| @@ -185,7 +178,7 @@ func openURLWithConfig(url string, _ *config.Config) error { | |||||
| 185 | _ = cmd.Wait() | 178 | _ = cmd.Wait() | ||
| 186 | }() | 179 | }() | ||
| 187 | 180 | ||||
| 188 | - | fmt.Printf("Opening: %s (using system default browser)\n", url) | 181 | + | fmt.Printf("Opening: %s (using built-in browser)\n", url) |
| 189 | return nil | 182 | return nil | ||
| 190 | } | 183 | } | ||
| 191 | 184 | ||||
A internal/db/init.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | package db | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "database/sql" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/config" | |
| 10 | + | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver | |
| 11 | + | _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // InitDB initializes the database connection and schema | |
| 15 | + | func InitDB(dbPath string) (*sql.DB, error) { | |
| 16 | + | if dbPath == "" { | |
| 17 | + | return nil, fmt.Errorf("database path cannot be empty") | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // Ensure database directory exists | |
| 21 | + | if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { | |
| 22 | + | return nil, fmt.Errorf("failed to create database directory: %w", err) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Open database connection | |
| 26 | + | database, err := sql.Open("sqlite3", dbPath) | |
| 27 | + | if err != nil { | |
| 28 | + | return nil, fmt.Errorf("failed to open database: %w", err) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Test the connection | |
| 32 | + | if err := database.Ping(); err != nil { | |
| 33 | + | database.Close() | |
| 34 | + | return nil, fmt.Errorf("failed to connect to database: %w", err) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Initialize database schema | |
| 38 | + | if err := initializeSchema(database); err != nil { | |
| 39 | + | database.Close() | |
| 40 | + | return nil, fmt.Errorf("failed to initialize database schema: %w", err) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | return database, nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // InitDBWithConfig initializes the database with configuration-based shortcuts | |
| 47 | + | func InitDBWithConfig(dbPath string, cfg *config.Config) (*sql.DB, error) { | |
| 48 | + | db, err := InitDB(dbPath) | |
| 49 | + | if err != nil { | |
| 50 | + | return nil, err | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // Initialize shortcuts from configuration | |
| 54 | + | if err := initializeShortcuts(db, cfg); err != nil { | |
| 55 | + | db.Close() | |
| 56 | + | return nil, fmt.Errorf("failed to initialize shortcuts: %w", err) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | return db, nil | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // initializeSchema creates the database schema if it doesn't exist | |
| 63 | + | func initializeSchema(db *sql.DB) error { | |
| 64 | + | schema := ` | |
| 65 | + | -- History tracking for visited URLs | |
| 66 | + | CREATE TABLE IF NOT EXISTS history ( | |
| 67 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 68 | + | url TEXT NOT NULL UNIQUE, | |
| 69 | + | title TEXT, | |
| 70 | + | visit_count INTEGER DEFAULT 1, | |
| 71 | + | last_visited DATETIME DEFAULT CURRENT_TIMESTAMP, | |
| 72 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |
| 73 | + | ); | |
| 74 | + | ||
| 75 | + | CREATE INDEX IF NOT EXISTS idx_history_url ON history(url); | |
| 76 | + | CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited); | |
| 77 | + | ||
| 78 | + | -- URL shortcuts configuration | |
| 79 | + | CREATE TABLE IF NOT EXISTS shortcuts ( | |
| 80 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 81 | + | shortcut TEXT NOT NULL UNIQUE, | |
| 82 | + | url_template TEXT NOT NULL, | |
| 83 | + | description TEXT, | |
| 84 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |
| 85 | + | ); | |
| 86 | + | ` | |
| 87 | + | ||
| 88 | + | // Execute schema creation | |
| 89 | + | _, err := db.Exec(schema) | |
| 90 | + | return err | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // initializeShortcuts inserts configured shortcuts into the database | |
| 94 | + | func initializeShortcuts(db *sql.DB, cfg *config.Config) error { | |
| 95 | + | if cfg == nil || cfg.SearchShortcuts == nil { | |
| 96 | + | return nil // No shortcuts to initialize | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // Insert configured shortcuts | |
| 100 | + | for shortcut, shortcutCfg := range cfg.SearchShortcuts { | |
| 101 | + | _, err := db.Exec( | |
| 102 | + | "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)", | |
| 103 | + | shortcut, shortcutCfg.URL, shortcutCfg.Description, | |
| 104 | + | ) | |
| 105 | + | if err != nil { | |
| 106 | + | return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | return nil | |
| 111 | + | }⏎̸ | |
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package db | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "database/sql" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/config" | |||
| 10 | + | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver | |||
| 11 | + | _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // InitDB initializes the database connection and schema | |||
| 15 | + | func InitDB(dbPath string) (*sql.DB, error) { | |||
| 16 | + | if dbPath == "" { | |||
| 17 | + | return nil, fmt.Errorf("database path cannot be empty") | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // Ensure database directory exists | |||
| 21 | + | if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { | |||
| 22 | + | return nil, fmt.Errorf("failed to create database directory: %w", err) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // Open database connection | |||
| 26 | + | database, err := sql.Open("sqlite3", dbPath) | |||
| 27 | + | if err != nil { | |||
| 28 | + | return nil, fmt.Errorf("failed to open database: %w", err) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // Test the connection | |||
| 32 | + | if err := database.Ping(); err != nil { | |||
| 33 | + | database.Close() | |||
| 34 | + | return nil, fmt.Errorf("failed to connect to database: %w", err) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Initialize database schema | |||
| 38 | + | if err := initializeSchema(database); err != nil { | |||
| 39 | + | database.Close() | |||
| 40 | + | return nil, fmt.Errorf("failed to initialize database schema: %w", err) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | return database, nil | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // InitDBWithConfig initializes the database with configuration-based shortcuts | |||
| 47 | + | func InitDBWithConfig(dbPath string, cfg *config.Config) (*sql.DB, error) { | |||
| 48 | + | db, err := InitDB(dbPath) | |||
| 49 | + | if err != nil { | |||
| 50 | + | return nil, err | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // Initialize shortcuts from configuration | |||
| 54 | + | if err := initializeShortcuts(db, cfg); err != nil { | |||
| 55 | + | db.Close() | |||
| 56 | + | return nil, fmt.Errorf("failed to initialize shortcuts: %w", err) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | return db, nil | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // initializeSchema creates the database schema if it doesn't exist | |||
| 63 | + | func initializeSchema(db *sql.DB) error { | |||
| 64 | + | schema := ` | |||
| 65 | + | -- History tracking for visited URLs | |||
| 66 | + | CREATE TABLE IF NOT EXISTS history ( | |||
| 67 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| 68 | + | url TEXT NOT NULL UNIQUE, | |||
| 69 | + | title TEXT, | |||
| 70 | + | visit_count INTEGER DEFAULT 1, | |||
| 71 | + | last_visited DATETIME DEFAULT CURRENT_TIMESTAMP, | |||
| 72 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 73 | + | ); | |||
| 74 | + | ||||
| 75 | + | CREATE INDEX IF NOT EXISTS idx_history_url ON history(url); | |||
| 76 | + | CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited); | |||
| 77 | + | ||||
| 78 | + | -- URL shortcuts configuration | |||
| 79 | + | CREATE TABLE IF NOT EXISTS shortcuts ( | |||
| 80 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| 81 | + | shortcut TEXT NOT NULL UNIQUE, | |||
| 82 | + | url_template TEXT NOT NULL, | |||
| 83 | + | description TEXT, | |||
| 84 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 85 | + | ); | |||
| 86 | + | ` | |||
| 87 | + | ||||
| 88 | + | // Execute schema creation | |||
| 89 | + | _, err := db.Exec(schema) | |||
| 90 | + | return err | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // initializeShortcuts inserts configured shortcuts into the database | |||
| 94 | + | func initializeShortcuts(db *sql.DB, cfg *config.Config) error { | |||
| 95 | + | if cfg == nil || cfg.SearchShortcuts == nil { | |||
| 96 | + | return nil // No shortcuts to initialize | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // Insert configured shortcuts | |||
| 100 | + | for shortcut, shortcutCfg := range cfg.SearchShortcuts { | |||
| 101 | + | _, err := db.Exec( | |||
| 102 | + | "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)", | |||
| 103 | + | shortcut, shortcutCfg.URL, shortcutCfg.Description, | |||
| 104 | + | ) | |||
| 105 | + | if err != nil { | |||
| 106 | + | return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | return nil | |||
| 111 | + | }⏎̸ | |||
A main.go +190 -0
| @@ -0,0 +1,190 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "embed" | |
| 6 | + | "fmt" | |
| 7 | + | "log" | |
| 8 | + | "log/slog" | |
| 9 | + | "os" | |
| 10 | + | ||
| 11 | + | "github.com/wailsapp/wails/v3/pkg/application" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/dumber/internal/cli" | |
| 14 | + | "github.com/bnema/dumber/internal/config" | |
| 15 | + | "github.com/bnema/dumber/internal/db" | |
| 16 | + | "github.com/bnema/dumber/services" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Embed the frontend assets | |
| 20 | + | //go:embed frontend/dist | |
| 21 | + | var assets embed.FS | |
| 22 | + | ||
| 23 | + | // Build information set via ldflags | |
| 24 | + | var ( | |
| 25 | + | version = "dev" | |
| 26 | + | commit = "none" | |
| 27 | + | buildDate = "unknown" | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | func main() { | |
| 31 | + | // Check if we should run the CLI mode | |
| 32 | + | if shouldRunCLI() { | |
| 33 | + | runCLI() | |
| 34 | + | return | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Otherwise run the GUI browser | |
| 38 | + | runBrowser() | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // shouldRunCLI determines if we should run in CLI mode based on arguments | |
| 42 | + | func shouldRunCLI() bool { | |
| 43 | + | // No args = GUI landing page mode | |
| 44 | + | if len(os.Args) <= 1 { | |
| 45 | + | return false | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Check for GUI-specific flags | |
| 49 | + | for _, arg := range os.Args[1:] { | |
| 50 | + | if arg == "--gui" || arg == "-g" { | |
| 51 | + | return false // Explicit GUI mode | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Check for browse command - this should open GUI in direct navigation mode | |
| 56 | + | if len(os.Args) >= 2 && os.Args[1] == "browse" { | |
| 57 | + | return false // Browse command uses GUI mode but navigates directly | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Any other arguments mean CLI mode | |
| 61 | + | return true | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // runCLI executes the CLI functionality using the existing CLI package | |
| 65 | + | func runCLI() { | |
| 66 | + | // Initialize configuration system | |
| 67 | + | if err := config.Init(); err != nil { | |
| 68 | + | fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err) | |
| 69 | + | os.Exit(1) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Start configuration watching for live reload | |
| 73 | + | if err := config.Watch(); err != nil { | |
| 74 | + | // Don't exit on watch error, just warn | |
| 75 | + | fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | rootCmd := cli.NewRootCmd(version, commit, buildDate) | |
| 79 | + | ||
| 80 | + | // Handle dmenu flag at the root level for direct integration | |
| 81 | + | if len(os.Args) > 1 && os.Args[1] == "--dmenu" { | |
| 82 | + | // Create a temporary CLI instance to handle dmenu mode | |
| 83 | + | cliInstance, err := cli.NewCLI() | |
| 84 | + | if err != nil { | |
| 85 | + | fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err) | |
| 86 | + | os.Exit(1) | |
| 87 | + | } | |
| 88 | + | defer func() { | |
| 89 | + | if closeErr := cliInstance.Close(); closeErr != nil { | |
| 90 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |
| 91 | + | } | |
| 92 | + | }() | |
| 93 | + | ||
| 94 | + | // Generate dmenu options | |
| 95 | + | dmenuCmd := cli.NewDmenuCmd() | |
| 96 | + | if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil { | |
| 97 | + | fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err) | |
| 98 | + | os.Exit(1) | |
| 99 | + | } | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // Execute the root command | |
| 104 | + | if err := rootCmd.Execute(); err != nil { | |
| 105 | + | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | |
| 106 | + | os.Exit(1) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // runBrowser executes the browser GUI functionality | |
| 111 | + | func runBrowser() { | |
| 112 | + | // Initialize configuration | |
| 113 | + | if err := config.Init(); err != nil { | |
| 114 | + | log.Fatalf("Failed to initialize config: %v", err) | |
| 115 | + | } | |
| 116 | + | cfg := config.Get() | |
| 117 | + | ||
| 118 | + | // Initialize database | |
| 119 | + | database, err := db.InitDB(cfg.Database.Path) | |
| 120 | + | if err != nil { | |
| 121 | + | log.Fatalf("Failed to initialize database: %v", err) | |
| 122 | + | } | |
| 123 | + | defer database.Close() | |
| 124 | + | ||
| 125 | + | queries := db.New(database) | |
| 126 | + | ||
| 127 | + | // Initialize services | |
| 128 | + | parserService := services.NewParserService(cfg, queries) | |
| 129 | + | browserService := services.NewBrowserService(cfg, queries) | |
| 130 | + | configService := services.NewConfigService(cfg, "") | |
| 131 | + | ||
| 132 | + | // Check if this is a browse command with URL | |
| 133 | + | var directURL string | |
| 134 | + | if len(os.Args) >= 3 && os.Args[1] == "browse" { | |
| 135 | + | // Parse the URL using our parser service | |
| 136 | + | ctx := context.Background() | |
| 137 | + | result, err := parserService.ParseInput(ctx, os.Args[2]) | |
| 138 | + | if err != nil { | |
| 139 | + | log.Printf("Error parsing URL: %v", err) | |
| 140 | + | } else { | |
| 141 | + | directURL = result.URL | |
| 142 | + | // Record the visit | |
| 143 | + | browserService.Navigate(ctx, directURL) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // Create application with correct v3-alpha options | |
| 148 | + | app := application.New(application.Options{ | |
| 149 | + | Name: "dumber-browser", | |
| 150 | + | Description: "A smart URL launcher and browser with learning behavior", | |
| 151 | + | LogLevel: slog.LevelInfo, | |
| 152 | + | ||
| 153 | + | Services: []application.Service{ | |
| 154 | + | application.NewService(parserService), | |
| 155 | + | application.NewService(browserService), | |
| 156 | + | application.NewService(configService), | |
| 157 | + | }, | |
| 158 | + | ||
| 159 | + | Assets: application.AssetOptions{ | |
| 160 | + | Handler: application.AssetFileServerFS(assets), | |
| 161 | + | }, | |
| 162 | + | ||
| 163 | + | Mac: application.MacOptions{ | |
| 164 | + | ApplicationShouldTerminateAfterLastWindowClosed: true, | |
| 165 | + | }, | |
| 166 | + | }) | |
| 167 | + | ||
| 168 | + | // Determine the starting URL | |
| 169 | + | startURL := "/" | |
| 170 | + | if directURL != "" { | |
| 171 | + | startURL = directURL | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // Create the main window | |
| 175 | + | window := app.Window.NewWithOptions(application.WebviewWindowOptions{ | |
| 176 | + | Title: "Dumber Browser", | |
| 177 | + | Width: 1200, | |
| 178 | + | Height: 800, | |
| 179 | + | URL: startURL, | |
| 180 | + | }) | |
| 181 | + | ||
| 182 | + | // Show the window | |
| 183 | + | window.Show() | |
| 184 | + | ||
| 185 | + | // Run the application (blocking call) | |
| 186 | + | err = app.Run() | |
| 187 | + | if err != nil { | |
| 188 | + | log.Fatal(err) | |
| 189 | + | } | |
| 190 | + | }⏎̸ | |
| @@ -0,0 +1,190 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "embed" | |||
| 6 | + | "fmt" | |||
| 7 | + | "log" | |||
| 8 | + | "log/slog" | |||
| 9 | + | "os" | |||
| 10 | + | ||||
| 11 | + | "github.com/wailsapp/wails/v3/pkg/application" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/dumber/internal/cli" | |||
| 14 | + | "github.com/bnema/dumber/internal/config" | |||
| 15 | + | "github.com/bnema/dumber/internal/db" | |||
| 16 | + | "github.com/bnema/dumber/services" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // Embed the frontend assets | |||
| 20 | + | //go:embed frontend/dist | |||
| 21 | + | var assets embed.FS | |||
| 22 | + | ||||
| 23 | + | // Build information set via ldflags | |||
| 24 | + | var ( | |||
| 25 | + | version = "dev" | |||
| 26 | + | commit = "none" | |||
| 27 | + | buildDate = "unknown" | |||
| 28 | + | ) | |||
| 29 | + | ||||
| 30 | + | func main() { | |||
| 31 | + | // Check if we should run the CLI mode | |||
| 32 | + | if shouldRunCLI() { | |||
| 33 | + | runCLI() | |||
| 34 | + | return | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Otherwise run the GUI browser | |||
| 38 | + | runBrowser() | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // shouldRunCLI determines if we should run in CLI mode based on arguments | |||
| 42 | + | func shouldRunCLI() bool { | |||
| 43 | + | // No args = GUI landing page mode | |||
| 44 | + | if len(os.Args) <= 1 { | |||
| 45 | + | return false | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Check for GUI-specific flags | |||
| 49 | + | for _, arg := range os.Args[1:] { | |||
| 50 | + | if arg == "--gui" || arg == "-g" { | |||
| 51 | + | return false // Explicit GUI mode | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Check for browse command - this should open GUI in direct navigation mode | |||
| 56 | + | if len(os.Args) >= 2 && os.Args[1] == "browse" { | |||
| 57 | + | return false // Browse command uses GUI mode but navigates directly | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Any other arguments mean CLI mode | |||
| 61 | + | return true | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // runCLI executes the CLI functionality using the existing CLI package | |||
| 65 | + | func runCLI() { | |||
| 66 | + | // Initialize configuration system | |||
| 67 | + | if err := config.Init(); err != nil { | |||
| 68 | + | fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err) | |||
| 69 | + | os.Exit(1) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Start configuration watching for live reload | |||
| 73 | + | if err := config.Watch(); err != nil { | |||
| 74 | + | // Don't exit on watch error, just warn | |||
| 75 | + | fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | rootCmd := cli.NewRootCmd(version, commit, buildDate) | |||
| 79 | + | ||||
| 80 | + | // Handle dmenu flag at the root level for direct integration | |||
| 81 | + | if len(os.Args) > 1 && os.Args[1] == "--dmenu" { | |||
| 82 | + | // Create a temporary CLI instance to handle dmenu mode | |||
| 83 | + | cliInstance, err := cli.NewCLI() | |||
| 84 | + | if err != nil { | |||
| 85 | + | fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err) | |||
| 86 | + | os.Exit(1) | |||
| 87 | + | } | |||
| 88 | + | defer func() { | |||
| 89 | + | if closeErr := cliInstance.Close(); closeErr != nil { | |||
| 90 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |||
| 91 | + | } | |||
| 92 | + | }() | |||
| 93 | + | ||||
| 94 | + | // Generate dmenu options | |||
| 95 | + | dmenuCmd := cli.NewDmenuCmd() | |||
| 96 | + | if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil { | |||
| 97 | + | fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err) | |||
| 98 | + | os.Exit(1) | |||
| 99 | + | } | |||
| 100 | + | return | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // Execute the root command | |||
| 104 | + | if err := rootCmd.Execute(); err != nil { | |||
| 105 | + | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | |||
| 106 | + | os.Exit(1) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // runBrowser executes the browser GUI functionality | |||
| 111 | + | func runBrowser() { | |||
| 112 | + | // Initialize configuration | |||
| 113 | + | if err := config.Init(); err != nil { | |||
| 114 | + | log.Fatalf("Failed to initialize config: %v", err) | |||
| 115 | + | } | |||
| 116 | + | cfg := config.Get() | |||
| 117 | + | ||||
| 118 | + | // Initialize database | |||
| 119 | + | database, err := db.InitDB(cfg.Database.Path) | |||
| 120 | + | if err != nil { | |||
| 121 | + | log.Fatalf("Failed to initialize database: %v", err) | |||
| 122 | + | } | |||
| 123 | + | defer database.Close() | |||
| 124 | + | ||||
| 125 | + | queries := db.New(database) | |||
| 126 | + | ||||
| 127 | + | // Initialize services | |||
| 128 | + | parserService := services.NewParserService(cfg, queries) | |||
| 129 | + | browserService := services.NewBrowserService(cfg, queries) | |||
| 130 | + | configService := services.NewConfigService(cfg, "") | |||
| 131 | + | ||||
| 132 | + | // Check if this is a browse command with URL | |||
| 133 | + | var directURL string | |||
| 134 | + | if len(os.Args) >= 3 && os.Args[1] == "browse" { | |||
| 135 | + | // Parse the URL using our parser service | |||
| 136 | + | ctx := context.Background() | |||
| 137 | + | result, err := parserService.ParseInput(ctx, os.Args[2]) | |||
| 138 | + | if err != nil { | |||
| 139 | + | log.Printf("Error parsing URL: %v", err) | |||
| 140 | + | } else { | |||
| 141 | + | directURL = result.URL | |||
| 142 | + | // Record the visit | |||
| 143 | + | browserService.Navigate(ctx, directURL) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // Create application with correct v3-alpha options | |||
| 148 | + | app := application.New(application.Options{ | |||
| 149 | + | Name: "dumber-browser", | |||
| 150 | + | Description: "A smart URL launcher and browser with learning behavior", | |||
| 151 | + | LogLevel: slog.LevelInfo, | |||
| 152 | + | ||||
| 153 | + | Services: []application.Service{ | |||
| 154 | + | application.NewService(parserService), | |||
| 155 | + | application.NewService(browserService), | |||
| 156 | + | application.NewService(configService), | |||
| 157 | + | }, | |||
| 158 | + | ||||
| 159 | + | Assets: application.AssetOptions{ | |||
| 160 | + | Handler: application.AssetFileServerFS(assets), | |||
| 161 | + | }, | |||
| 162 | + | ||||
| 163 | + | Mac: application.MacOptions{ | |||
| 164 | + | ApplicationShouldTerminateAfterLastWindowClosed: true, | |||
| 165 | + | }, | |||
| 166 | + | }) | |||
| 167 | + | ||||
| 168 | + | // Determine the starting URL | |||
| 169 | + | startURL := "/" | |||
| 170 | + | if directURL != "" { | |||
| 171 | + | startURL = directURL | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // Create the main window | |||
| 175 | + | window := app.Window.NewWithOptions(application.WebviewWindowOptions{ | |||
| 176 | + | Title: "Dumber Browser", | |||
| 177 | + | Width: 1200, | |||
| 178 | + | Height: 800, | |||
| 179 | + | URL: startURL, | |||
| 180 | + | }) | |||
| 181 | + | ||||
| 182 | + | // Show the window | |||
| 183 | + | window.Show() | |||
| 184 | + | ||||
| 185 | + | // Run the application (blocking call) | |||
| 186 | + | err = app.Run() | |||
| 187 | + | if err != nil { | |||
| 188 | + | log.Fatal(err) | |||
| 189 | + | } | |||
| 190 | + | }⏎̸ | |||
A services/browser_service.go +205 -0
| @@ -0,0 +1,205 @@ | |||
| 1 | + | package services | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "database/sql" | |
| 6 | + | "fmt" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/config" | |
| 10 | + | "github.com/bnema/dumber/internal/db" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // BrowserService handles browser-related operations for Wails integration. | |
| 14 | + | type BrowserService struct { | |
| 15 | + | config *config.Config | |
| 16 | + | dbQueries *db.Queries | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // NavigationResult represents the result of a navigation operation. | |
| 20 | + | type NavigationResult struct { | |
| 21 | + | URL string `json:"url"` | |
| 22 | + | Title string `json:"title"` | |
| 23 | + | Success bool `json:"success"` | |
| 24 | + | Error string `json:"error,omitempty"` | |
| 25 | + | LoadTime time.Duration `json:"load_time"` | |
| 26 | + | RedirectChain []string `json:"redirect_chain,omitempty"` | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // HistoryEntry represents a simplified history entry for frontend. | |
| 30 | + | type HistoryEntry struct { | |
| 31 | + | ID int64 `json:"id"` | |
| 32 | + | URL string `json:"url"` | |
| 33 | + | Title string `json:"title"` | |
| 34 | + | VisitCount int32 `json:"visit_count"` | |
| 35 | + | LastVisited time.Time `json:"last_visited"` | |
| 36 | + | CreatedAt time.Time `json:"created_at"` | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // NewBrowserService creates a new BrowserService instance. | |
| 40 | + | func NewBrowserService(cfg *config.Config, queries *db.Queries) *BrowserService { | |
| 41 | + | return &BrowserService{ | |
| 42 | + | config: cfg, | |
| 43 | + | dbQueries: queries, | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Navigate handles navigation to a URL and records it in history. | |
| 48 | + | func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) { | |
| 49 | + | startTime := time.Now() | |
| 50 | + | ||
| 51 | + | if url == "" { | |
| 52 | + | return &NavigationResult{ | |
| 53 | + | Success: false, | |
| 54 | + | Error: "URL cannot be empty", | |
| 55 | + | }, nil | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Record the navigation in history | |
| 59 | + | err := s.recordHistory(ctx, url, "") | |
| 60 | + | if err != nil { | |
| 61 | + | // Don't fail navigation if history recording fails | |
| 62 | + | fmt.Printf("Failed to record history: %v\n", err) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | return &NavigationResult{ | |
| 66 | + | URL: url, | |
| 67 | + | Success: true, | |
| 68 | + | LoadTime: time.Since(startTime), | |
| 69 | + | }, nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // UpdatePageTitle updates the title of the current page in history. | |
| 73 | + | func (s *BrowserService) UpdatePageTitle(ctx context.Context, url, title string) error { | |
| 74 | + | if url == "" || title == "" { | |
| 75 | + | return fmt.Errorf("URL and title cannot be empty") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Use AddOrUpdateHistory to update the title | |
| 79 | + | titleNull := sql.NullString{String: title, Valid: true} | |
| 80 | + | return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // GetRecentHistory returns recent browser history entries. | |
| 84 | + | func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]HistoryEntry, error) { | |
| 85 | + | if limit <= 0 { | |
| 86 | + | limit = 50 // Default limit | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | entries, err := s.dbQueries.GetHistory(ctx, int64(limit)) | |
| 90 | + | if err != nil { | |
| 91 | + | return nil, err | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | result := make([]HistoryEntry, len(entries)) | |
| 95 | + | for i, entry := range entries { | |
| 96 | + | result[i] = HistoryEntry{ | |
| 97 | + | ID: entry.ID, | |
| 98 | + | URL: entry.Url, | |
| 99 | + | Title: entry.Title.String, | |
| 100 | + | VisitCount: int32(entry.VisitCount.Int64), | |
| 101 | + | LastVisited: entry.LastVisited.Time, | |
| 102 | + | CreatedAt: entry.CreatedAt.Time, | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | return result, nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // SearchHistory searches browser history. | |
| 110 | + | func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) { | |
| 111 | + | if query == "" { | |
| 112 | + | return s.GetRecentHistory(ctx, limit) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | if limit <= 0 { | |
| 116 | + | limit = 50 | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | urlPattern := sql.NullString{String: "%" + query + "%", Valid: true} | |
| 120 | + | titlePattern := sql.NullString{String: "%" + query + "%", Valid: true} | |
| 121 | + | ||
| 122 | + | entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit)) | |
| 123 | + | if err != nil { | |
| 124 | + | return nil, err | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | result := make([]HistoryEntry, len(entries)) | |
| 128 | + | for i, entry := range entries { | |
| 129 | + | result[i] = HistoryEntry{ | |
| 130 | + | ID: entry.ID, | |
| 131 | + | URL: entry.Url, | |
| 132 | + | Title: entry.Title.String, | |
| 133 | + | VisitCount: int32(entry.VisitCount.Int64), | |
| 134 | + | LastVisited: entry.LastVisited.Time, | |
| 135 | + | CreatedAt: entry.CreatedAt.Time, | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | return result, nil | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // DeleteHistoryEntry removes a specific history entry. | |
| 143 | + | func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error { | |
| 144 | + | // Note: DeleteHistory method doesn't exist in current schema | |
| 145 | + | // This would need to be implemented in the database layer | |
| 146 | + | return fmt.Errorf("delete history not implemented yet") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // ClearHistory removes all history entries. | |
| 150 | + | func (s *BrowserService) ClearHistory(ctx context.Context) error { | |
| 151 | + | // Note: ClearAllHistory method doesn't exist in current schema | |
| 152 | + | // This would need to be implemented in the database layer | |
| 153 | + | return fmt.Errorf("clear all history not implemented yet") | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // GetHistoryStats returns statistics about browser history. | |
| 157 | + | func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) { | |
| 158 | + | // Get recent entries for basic stats | |
| 159 | + | recentEntries, err := s.dbQueries.GetHistory(ctx, 1000) | |
| 160 | + | if err != nil { | |
| 161 | + | return nil, err | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // Calculate basic statistics | |
| 165 | + | stats := map[string]interface{}{ | |
| 166 | + | "total_entries": len(recentEntries), | |
| 167 | + | "recent_count": len(recentEntries), | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // Add more detailed stats if we have entries | |
| 171 | + | if len(recentEntries) > 0 { | |
| 172 | + | stats["oldest_entry"] = recentEntries[len(recentEntries)-1].CreatedAt.Time | |
| 173 | + | stats["newest_entry"] = recentEntries[0].CreatedAt.Time | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | return stats, nil | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // GetConfig returns the current browser configuration. | |
| 180 | + | func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) { | |
| 181 | + | return s.config, nil | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // UpdateConfig updates the browser configuration. | |
| 185 | + | func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error { | |
| 186 | + | // In a real implementation, you'd want to validate and persist the config | |
| 187 | + | s.config = newConfig | |
| 188 | + | return nil | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // GetSearchShortcuts returns available search shortcuts. | |
| 192 | + | func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) { | |
| 193 | + | return s.config.SearchShortcuts, nil | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // recordHistory adds or updates a history entry. | |
| 197 | + | func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error { | |
| 198 | + | // Use AddOrUpdateHistory which handles both cases | |
| 199 | + | titleNull := sql.NullString{Valid: false} | |
| 200 | + | if title != "" { | |
| 201 | + | titleNull = sql.NullString{String: title, Valid: true} | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull) | |
| 205 | + | }⏎̸ | |
| @@ -0,0 +1,205 @@ | |||||
| 1 | + | package services | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "database/sql" | |||
| 6 | + | "fmt" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/config" | |||
| 10 | + | "github.com/bnema/dumber/internal/db" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // BrowserService handles browser-related operations for Wails integration. | |||
| 14 | + | type BrowserService struct { | |||
| 15 | + | config *config.Config | |||
| 16 | + | dbQueries *db.Queries | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // NavigationResult represents the result of a navigation operation. | |||
| 20 | + | type NavigationResult struct { | |||
| 21 | + | URL string `json:"url"` | |||
| 22 | + | Title string `json:"title"` | |||
| 23 | + | Success bool `json:"success"` | |||
| 24 | + | Error string `json:"error,omitempty"` | |||
| 25 | + | LoadTime time.Duration `json:"load_time"` | |||
| 26 | + | RedirectChain []string `json:"redirect_chain,omitempty"` | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // HistoryEntry represents a simplified history entry for frontend. | |||
| 30 | + | type HistoryEntry struct { | |||
| 31 | + | ID int64 `json:"id"` | |||
| 32 | + | URL string `json:"url"` | |||
| 33 | + | Title string `json:"title"` | |||
| 34 | + | VisitCount int32 `json:"visit_count"` | |||
| 35 | + | LastVisited time.Time `json:"last_visited"` | |||
| 36 | + | CreatedAt time.Time `json:"created_at"` | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // NewBrowserService creates a new BrowserService instance. | |||
| 40 | + | func NewBrowserService(cfg *config.Config, queries *db.Queries) *BrowserService { | |||
| 41 | + | return &BrowserService{ | |||
| 42 | + | config: cfg, | |||
| 43 | + | dbQueries: queries, | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // Navigate handles navigation to a URL and records it in history. | |||
| 48 | + | func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) { | |||
| 49 | + | startTime := time.Now() | |||
| 50 | + | ||||
| 51 | + | if url == "" { | |||
| 52 | + | return &NavigationResult{ | |||
| 53 | + | Success: false, | |||
| 54 | + | Error: "URL cannot be empty", | |||
| 55 | + | }, nil | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Record the navigation in history | |||
| 59 | + | err := s.recordHistory(ctx, url, "") | |||
| 60 | + | if err != nil { | |||
| 61 | + | // Don't fail navigation if history recording fails | |||
| 62 | + | fmt.Printf("Failed to record history: %v\n", err) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | return &NavigationResult{ | |||
| 66 | + | URL: url, | |||
| 67 | + | Success: true, | |||
| 68 | + | LoadTime: time.Since(startTime), | |||
| 69 | + | }, nil | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // UpdatePageTitle updates the title of the current page in history. | |||
| 73 | + | func (s *BrowserService) UpdatePageTitle(ctx context.Context, url, title string) error { | |||
| 74 | + | if url == "" || title == "" { | |||
| 75 | + | return fmt.Errorf("URL and title cannot be empty") | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // Use AddOrUpdateHistory to update the title | |||
| 79 | + | titleNull := sql.NullString{String: title, Valid: true} | |||
| 80 | + | return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // GetRecentHistory returns recent browser history entries. | |||
| 84 | + | func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]HistoryEntry, error) { | |||
| 85 | + | if limit <= 0 { | |||
| 86 | + | limit = 50 // Default limit | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | entries, err := s.dbQueries.GetHistory(ctx, int64(limit)) | |||
| 90 | + | if err != nil { | |||
| 91 | + | return nil, err | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | result := make([]HistoryEntry, len(entries)) | |||
| 95 | + | for i, entry := range entries { | |||
| 96 | + | result[i] = HistoryEntry{ | |||
| 97 | + | ID: entry.ID, | |||
| 98 | + | URL: entry.Url, | |||
| 99 | + | Title: entry.Title.String, | |||
| 100 | + | VisitCount: int32(entry.VisitCount.Int64), | |||
| 101 | + | LastVisited: entry.LastVisited.Time, | |||
| 102 | + | CreatedAt: entry.CreatedAt.Time, | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | return result, nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // SearchHistory searches browser history. | |||
| 110 | + | func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) { | |||
| 111 | + | if query == "" { | |||
| 112 | + | return s.GetRecentHistory(ctx, limit) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | if limit <= 0 { | |||
| 116 | + | limit = 50 | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | urlPattern := sql.NullString{String: "%" + query + "%", Valid: true} | |||
| 120 | + | titlePattern := sql.NullString{String: "%" + query + "%", Valid: true} | |||
| 121 | + | ||||
| 122 | + | entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit)) | |||
| 123 | + | if err != nil { | |||
| 124 | + | return nil, err | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | result := make([]HistoryEntry, len(entries)) | |||
| 128 | + | for i, entry := range entries { | |||
| 129 | + | result[i] = HistoryEntry{ | |||
| 130 | + | ID: entry.ID, | |||
| 131 | + | URL: entry.Url, | |||
| 132 | + | Title: entry.Title.String, | |||
| 133 | + | VisitCount: int32(entry.VisitCount.Int64), | |||
| 134 | + | LastVisited: entry.LastVisited.Time, | |||
| 135 | + | CreatedAt: entry.CreatedAt.Time, | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | return result, nil | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // DeleteHistoryEntry removes a specific history entry. | |||
| 143 | + | func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error { | |||
| 144 | + | // Note: DeleteHistory method doesn't exist in current schema | |||
| 145 | + | // This would need to be implemented in the database layer | |||
| 146 | + | return fmt.Errorf("delete history not implemented yet") | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // ClearHistory removes all history entries. | |||
| 150 | + | func (s *BrowserService) ClearHistory(ctx context.Context) error { | |||
| 151 | + | // Note: ClearAllHistory method doesn't exist in current schema | |||
| 152 | + | // This would need to be implemented in the database layer | |||
| 153 | + | return fmt.Errorf("clear all history not implemented yet") | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // GetHistoryStats returns statistics about browser history. | |||
| 157 | + | func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) { | |||
| 158 | + | // Get recent entries for basic stats | |||
| 159 | + | recentEntries, err := s.dbQueries.GetHistory(ctx, 1000) | |||
| 160 | + | if err != nil { | |||
| 161 | + | return nil, err | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // Calculate basic statistics | |||
| 165 | + | stats := map[string]interface{}{ | |||
| 166 | + | "total_entries": len(recentEntries), | |||
| 167 | + | "recent_count": len(recentEntries), | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // Add more detailed stats if we have entries | |||
| 171 | + | if len(recentEntries) > 0 { | |||
| 172 | + | stats["oldest_entry"] = recentEntries[len(recentEntries)-1].CreatedAt.Time | |||
| 173 | + | stats["newest_entry"] = recentEntries[0].CreatedAt.Time | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | return stats, nil | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // GetConfig returns the current browser configuration. | |||
| 180 | + | func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) { | |||
| 181 | + | return s.config, nil | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // UpdateConfig updates the browser configuration. | |||
| 185 | + | func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error { | |||
| 186 | + | // In a real implementation, you'd want to validate and persist the config | |||
| 187 | + | s.config = newConfig | |||
| 188 | + | return nil | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | // GetSearchShortcuts returns available search shortcuts. | |||
| 192 | + | func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) { | |||
| 193 | + | return s.config.SearchShortcuts, nil | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // recordHistory adds or updates a history entry. | |||
| 197 | + | func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error { | |||
| 198 | + | // Use AddOrUpdateHistory which handles both cases | |||
| 199 | + | titleNull := sql.NullString{Valid: false} | |||
| 200 | + | if title != "" { | |||
| 201 | + | titleNull = sql.NullString{String: title, Valid: true} | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull) | |||
| 205 | + | }⏎̸ | |||
A services/config_service.go +240 -0
| @@ -0,0 +1,240 @@ | |||
| 1 | + | package services | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/config" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // ConfigService handles configuration operations for Wails integration. | |
| 13 | + | type ConfigService struct { | |
| 14 | + | config *config.Config | |
| 15 | + | configPath string | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // ConfigInfo represents configuration information for the frontend. | |
| 19 | + | type ConfigInfo struct { | |
| 20 | + | ConfigPath string `json:"config_path"` | |
| 21 | + | DatabasePath string `json:"database_path"` | |
| 22 | + | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | |
| 23 | + | DmenuSettings *config.DmenuConfig `json:"dmenu_settings"` | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // NewConfigService creates a new ConfigService instance. | |
| 27 | + | func NewConfigService(cfg *config.Config, configPath string) *ConfigService { | |
| 28 | + | return &ConfigService{ | |
| 29 | + | config: cfg, | |
| 30 | + | configPath: configPath, | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // GetConfigInfo returns comprehensive configuration information. | |
| 35 | + | func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error) { | |
| 36 | + | return &ConfigInfo{ | |
| 37 | + | ConfigPath: s.configPath, | |
| 38 | + | DatabasePath: s.config.Database.Path, | |
| 39 | + | SearchShortcuts: s.config.SearchShortcuts, | |
| 40 | + | DmenuSettings: &s.config.Dmenu, | |
| 41 | + | }, nil | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // GetSearchShortcuts returns all configured search shortcuts. | |
| 45 | + | func (s *ConfigService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) { | |
| 46 | + | return s.config.SearchShortcuts, nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // AddSearchShortcut adds a new search shortcut. | |
| 50 | + | func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error { | |
| 51 | + | if key == "" { | |
| 52 | + | return fmt.Errorf("shortcut key cannot be empty") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | if shortcut.URL == "" { | |
| 56 | + | return fmt.Errorf("shortcut URL cannot be empty") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | if s.config.SearchShortcuts == nil { | |
| 60 | + | s.config.SearchShortcuts = make(map[string]config.SearchShortcut) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | s.config.SearchShortcuts[key] = shortcut | |
| 64 | + | return s.saveConfig() | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // UpdateSearchShortcut updates an existing search shortcut. | |
| 68 | + | func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error { | |
| 69 | + | if key == "" { | |
| 70 | + | return fmt.Errorf("shortcut key cannot be empty") | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | if _, exists := s.config.SearchShortcuts[key]; !exists { | |
| 74 | + | return fmt.Errorf("shortcut '%s' does not exist", key) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | if shortcut.URL == "" { | |
| 78 | + | return fmt.Errorf("shortcut URL cannot be empty") | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | s.config.SearchShortcuts[key] = shortcut | |
| 82 | + | return s.saveConfig() | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // DeleteSearchShortcut removes a search shortcut. | |
| 86 | + | func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) error { | |
| 87 | + | if key == "" { | |
| 88 | + | return fmt.Errorf("shortcut key cannot be empty") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | if _, exists := s.config.SearchShortcuts[key]; !exists { | |
| 92 | + | return fmt.Errorf("shortcut '%s' does not exist", key) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | delete(s.config.SearchShortcuts, key) | |
| 96 | + | return s.saveConfig() | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // GetDmenuSettings returns current dmenu settings. | |
| 100 | + | func (s *ConfigService) GetDmenuSettings(ctx context.Context) (*config.DmenuConfig, error) { | |
| 101 | + | return &s.config.Dmenu, nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // UpdateDmenuSettings updates dmenu configuration. | |
| 105 | + | func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *config.DmenuConfig) error { | |
| 106 | + | if settings == nil { | |
| 107 | + | return fmt.Errorf("dmenu settings cannot be nil") | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | s.config.Dmenu = *settings | |
| 111 | + | return s.saveConfig() | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // GetDatabasePath returns the current database path. | |
| 115 | + | func (s *ConfigService) GetDatabasePath(ctx context.Context) (string, error) { | |
| 116 | + | return s.config.Database.Path, nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // SetDatabasePath updates the database path. | |
| 120 | + | func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error { | |
| 121 | + | if path == "" { | |
| 122 | + | return fmt.Errorf("database path cannot be empty") | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // Validate that the directory exists or can be created | |
| 126 | + | dir := filepath.Dir(path) | |
| 127 | + | if err := os.MkdirAll(dir, 0755); err != nil { | |
| 128 | + | return fmt.Errorf("failed to create database directory: %w", err) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | s.config.Database.Path = path | |
| 132 | + | return s.saveConfig() | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // GetDefaultBrowser returns the default browser setting. | |
| 136 | + | func (s *ConfigService) GetDefaultBrowser(ctx context.Context) (string, error) { | |
| 137 | + | // Default browser is now built into the app | |
| 138 | + | return "dumber (built-in)", nil | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // SetDefaultBrowser updates the default browser setting. | |
| 142 | + | func (s *ConfigService) SetDefaultBrowser(ctx context.Context, browser string) error { | |
| 143 | + | // Default browser setting is not needed for built-in browser | |
| 144 | + | return fmt.Errorf("default browser is built-in and cannot be changed") | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // ReloadConfig reloads configuration from file. | |
| 148 | + | func (s *ConfigService) ReloadConfig(ctx context.Context) error { | |
| 149 | + | // Create new manager and load config | |
| 150 | + | manager, err := config.NewManager() | |
| 151 | + | if err != nil { | |
| 152 | + | return fmt.Errorf("failed to create config manager: %w", err) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | if err := manager.Load(); err != nil { | |
| 156 | + | return fmt.Errorf("failed to load config: %w", err) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | s.config = manager.Get() | |
| 160 | + | return nil | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // ValidateConfig validates the current configuration. | |
| 164 | + | func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) { | |
| 165 | + | var errors []string | |
| 166 | + | ||
| 167 | + | // Validate database path | |
| 168 | + | if s.config.Database.Path == "" { | |
| 169 | + | errors = append(errors, "database path is not set") | |
| 170 | + | } else { | |
| 171 | + | dir := filepath.Dir(s.config.Database.Path) | |
| 172 | + | if _, err := os.Stat(dir); os.IsNotExist(err) { | |
| 173 | + | errors = append(errors, fmt.Sprintf("database directory does not exist: %s", dir)) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // Validate search shortcuts | |
| 178 | + | if len(s.config.SearchShortcuts) == 0 { | |
| 179 | + | errors = append(errors, "no search shortcuts configured") | |
| 180 | + | } else { | |
| 181 | + | for key, shortcut := range s.config.SearchShortcuts { | |
| 182 | + | if shortcut.URL == "" { | |
| 183 | + | errors = append(errors, fmt.Sprintf("search shortcut '%s' has empty URL", key)) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // Check for default search shortcut | |
| 189 | + | if _, hasDefault := s.config.SearchShortcuts["g"]; !hasDefault { | |
| 190 | + | errors = append(errors, "no default search shortcut 'g' configured") | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // Validate dmenu settings | |
| 194 | + | if s.config.Dmenu.MaxHistoryItems < 1 { | |
| 195 | + | errors = append(errors, "dmenu max_history_items must be at least 1") | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | return errors, nil | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // GetConfigPath returns the path to the configuration file. | |
| 202 | + | func (s *ConfigService) GetConfigPath(ctx context.Context) (string, error) { | |
| 203 | + | return s.configPath, nil | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // ExportConfig returns the current configuration as JSON. | |
| 207 | + | func (s *ConfigService) ExportConfig(ctx context.Context) (*config.Config, error) { | |
| 208 | + | // Return a copy of the config to prevent modifications | |
| 209 | + | configCopy := *s.config | |
| 210 | + | return &configCopy, nil | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // ImportConfig imports configuration from a Config object. | |
| 214 | + | func (s *ConfigService) ImportConfig(ctx context.Context, newConfig *config.Config) error { | |
| 215 | + | if newConfig == nil { | |
| 216 | + | return fmt.Errorf("config cannot be nil") | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // Validate the new config | |
| 220 | + | tempService := &ConfigService{config: newConfig} | |
| 221 | + | validationErrors, err := tempService.ValidateConfig(ctx) | |
| 222 | + | if err != nil { | |
| 223 | + | return fmt.Errorf("failed to validate config: %w", err) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | if len(validationErrors) > 0 { | |
| 227 | + | return fmt.Errorf("config validation failed: %v", validationErrors) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // Apply the new config | |
| 231 | + | s.config = newConfig | |
| 232 | + | return s.saveConfig() | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // saveConfig saves the current configuration to file. | |
| 236 | + | func (s *ConfigService) saveConfig() error { | |
| 237 | + | // For now, return success - implementing config saving would require | |
| 238 | + | // extending the config package with save functionality | |
| 239 | + | return nil | |
| 240 | + | }⏎̸ | |
| @@ -0,0 +1,240 @@ | |||||
| 1 | + | package services | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/config" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // ConfigService handles configuration operations for Wails integration. | |||
| 13 | + | type ConfigService struct { | |||
| 14 | + | config *config.Config | |||
| 15 | + | configPath string | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // ConfigInfo represents configuration information for the frontend. | |||
| 19 | + | type ConfigInfo struct { | |||
| 20 | + | ConfigPath string `json:"config_path"` | |||
| 21 | + | DatabasePath string `json:"database_path"` | |||
| 22 | + | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | |||
| 23 | + | DmenuSettings *config.DmenuConfig `json:"dmenu_settings"` | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // NewConfigService creates a new ConfigService instance. | |||
| 27 | + | func NewConfigService(cfg *config.Config, configPath string) *ConfigService { | |||
| 28 | + | return &ConfigService{ | |||
| 29 | + | config: cfg, | |||
| 30 | + | configPath: configPath, | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // GetConfigInfo returns comprehensive configuration information. | |||
| 35 | + | func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error) { | |||
| 36 | + | return &ConfigInfo{ | |||
| 37 | + | ConfigPath: s.configPath, | |||
| 38 | + | DatabasePath: s.config.Database.Path, | |||
| 39 | + | SearchShortcuts: s.config.SearchShortcuts, | |||
| 40 | + | DmenuSettings: &s.config.Dmenu, | |||
| 41 | + | }, nil | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // GetSearchShortcuts returns all configured search shortcuts. | |||
| 45 | + | func (s *ConfigService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) { | |||
| 46 | + | return s.config.SearchShortcuts, nil | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // AddSearchShortcut adds a new search shortcut. | |||
| 50 | + | func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error { | |||
| 51 | + | if key == "" { | |||
| 52 | + | return fmt.Errorf("shortcut key cannot be empty") | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | if shortcut.URL == "" { | |||
| 56 | + | return fmt.Errorf("shortcut URL cannot be empty") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | if s.config.SearchShortcuts == nil { | |||
| 60 | + | s.config.SearchShortcuts = make(map[string]config.SearchShortcut) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | s.config.SearchShortcuts[key] = shortcut | |||
| 64 | + | return s.saveConfig() | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // UpdateSearchShortcut updates an existing search shortcut. | |||
| 68 | + | func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error { | |||
| 69 | + | if key == "" { | |||
| 70 | + | return fmt.Errorf("shortcut key cannot be empty") | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | if _, exists := s.config.SearchShortcuts[key]; !exists { | |||
| 74 | + | return fmt.Errorf("shortcut '%s' does not exist", key) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | if shortcut.URL == "" { | |||
| 78 | + | return fmt.Errorf("shortcut URL cannot be empty") | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | s.config.SearchShortcuts[key] = shortcut | |||
| 82 | + | return s.saveConfig() | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // DeleteSearchShortcut removes a search shortcut. | |||
| 86 | + | func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) error { | |||
| 87 | + | if key == "" { | |||
| 88 | + | return fmt.Errorf("shortcut key cannot be empty") | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | if _, exists := s.config.SearchShortcuts[key]; !exists { | |||
| 92 | + | return fmt.Errorf("shortcut '%s' does not exist", key) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | delete(s.config.SearchShortcuts, key) | |||
| 96 | + | return s.saveConfig() | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // GetDmenuSettings returns current dmenu settings. | |||
| 100 | + | func (s *ConfigService) GetDmenuSettings(ctx context.Context) (*config.DmenuConfig, error) { | |||
| 101 | + | return &s.config.Dmenu, nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // UpdateDmenuSettings updates dmenu configuration. | |||
| 105 | + | func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *config.DmenuConfig) error { | |||
| 106 | + | if settings == nil { | |||
| 107 | + | return fmt.Errorf("dmenu settings cannot be nil") | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | s.config.Dmenu = *settings | |||
| 111 | + | return s.saveConfig() | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // GetDatabasePath returns the current database path. | |||
| 115 | + | func (s *ConfigService) GetDatabasePath(ctx context.Context) (string, error) { | |||
| 116 | + | return s.config.Database.Path, nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // SetDatabasePath updates the database path. | |||
| 120 | + | func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error { | |||
| 121 | + | if path == "" { | |||
| 122 | + | return fmt.Errorf("database path cannot be empty") | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // Validate that the directory exists or can be created | |||
| 126 | + | dir := filepath.Dir(path) | |||
| 127 | + | if err := os.MkdirAll(dir, 0755); err != nil { | |||
| 128 | + | return fmt.Errorf("failed to create database directory: %w", err) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | s.config.Database.Path = path | |||
| 132 | + | return s.saveConfig() | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // GetDefaultBrowser returns the default browser setting. | |||
| 136 | + | func (s *ConfigService) GetDefaultBrowser(ctx context.Context) (string, error) { | |||
| 137 | + | // Default browser is now built into the app | |||
| 138 | + | return "dumber (built-in)", nil | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // SetDefaultBrowser updates the default browser setting. | |||
| 142 | + | func (s *ConfigService) SetDefaultBrowser(ctx context.Context, browser string) error { | |||
| 143 | + | // Default browser setting is not needed for built-in browser | |||
| 144 | + | return fmt.Errorf("default browser is built-in and cannot be changed") | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // ReloadConfig reloads configuration from file. | |||
| 148 | + | func (s *ConfigService) ReloadConfig(ctx context.Context) error { | |||
| 149 | + | // Create new manager and load config | |||
| 150 | + | manager, err := config.NewManager() | |||
| 151 | + | if err != nil { | |||
| 152 | + | return fmt.Errorf("failed to create config manager: %w", err) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | if err := manager.Load(); err != nil { | |||
| 156 | + | return fmt.Errorf("failed to load config: %w", err) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | s.config = manager.Get() | |||
| 160 | + | return nil | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // ValidateConfig validates the current configuration. | |||
| 164 | + | func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) { | |||
| 165 | + | var errors []string | |||
| 166 | + | ||||
| 167 | + | // Validate database path | |||
| 168 | + | if s.config.Database.Path == "" { | |||
| 169 | + | errors = append(errors, "database path is not set") | |||
| 170 | + | } else { | |||
| 171 | + | dir := filepath.Dir(s.config.Database.Path) | |||
| 172 | + | if _, err := os.Stat(dir); os.IsNotExist(err) { | |||
| 173 | + | errors = append(errors, fmt.Sprintf("database directory does not exist: %s", dir)) | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // Validate search shortcuts | |||
| 178 | + | if len(s.config.SearchShortcuts) == 0 { | |||
| 179 | + | errors = append(errors, "no search shortcuts configured") | |||
| 180 | + | } else { | |||
| 181 | + | for key, shortcut := range s.config.SearchShortcuts { | |||
| 182 | + | if shortcut.URL == "" { | |||
| 183 | + | errors = append(errors, fmt.Sprintf("search shortcut '%s' has empty URL", key)) | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // Check for default search shortcut | |||
| 189 | + | if _, hasDefault := s.config.SearchShortcuts["g"]; !hasDefault { | |||
| 190 | + | errors = append(errors, "no default search shortcut 'g' configured") | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // Validate dmenu settings | |||
| 194 | + | if s.config.Dmenu.MaxHistoryItems < 1 { | |||
| 195 | + | errors = append(errors, "dmenu max_history_items must be at least 1") | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | return errors, nil | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // GetConfigPath returns the path to the configuration file. | |||
| 202 | + | func (s *ConfigService) GetConfigPath(ctx context.Context) (string, error) { | |||
| 203 | + | return s.configPath, nil | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // ExportConfig returns the current configuration as JSON. | |||
| 207 | + | func (s *ConfigService) ExportConfig(ctx context.Context) (*config.Config, error) { | |||
| 208 | + | // Return a copy of the config to prevent modifications | |||
| 209 | + | configCopy := *s.config | |||
| 210 | + | return &configCopy, nil | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // ImportConfig imports configuration from a Config object. | |||
| 214 | + | func (s *ConfigService) ImportConfig(ctx context.Context, newConfig *config.Config) error { | |||
| 215 | + | if newConfig == nil { | |||
| 216 | + | return fmt.Errorf("config cannot be nil") | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // Validate the new config | |||
| 220 | + | tempService := &ConfigService{config: newConfig} | |||
| 221 | + | validationErrors, err := tempService.ValidateConfig(ctx) | |||
| 222 | + | if err != nil { | |||
| 223 | + | return fmt.Errorf("failed to validate config: %w", err) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | if len(validationErrors) > 0 { | |||
| 227 | + | return fmt.Errorf("config validation failed: %v", validationErrors) | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // Apply the new config | |||
| 231 | + | s.config = newConfig | |||
| 232 | + | return s.saveConfig() | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // saveConfig saves the current configuration to file. | |||
| 236 | + | func (s *ConfigService) saveConfig() error { | |||
| 237 | + | // For now, return success - implementing config saving would require | |||
| 238 | + | // extending the config package with save functionality | |||
| 239 | + | return nil | |||
| 240 | + | }⏎̸ | |||
A services/parser_service.go +185 -0
| @@ -0,0 +1,185 @@ | |||
| 1 | + | package services | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "database/sql" | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/config" | |
| 9 | + | "github.com/bnema/dumber/internal/db" | |
| 10 | + | "github.com/bnema/dumber/internal/parser" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // ParserService wraps the parser functionality for Wails integration. | |
| 14 | + | type ParserService struct { | |
| 15 | + | parser *parser.Parser | |
| 16 | + | dbQueries *db.Queries | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // NewParserService creates a new ParserService instance. | |
| 20 | + | func NewParserService(cfg *config.Config, queries *db.Queries) *ParserService { | |
| 21 | + | // Create history provider from database | |
| 22 | + | historyProvider := &DatabaseHistoryProvider{queries: queries} | |
| 23 | + | ||
| 24 | + | // Create parser with configuration | |
| 25 | + | p := parser.NewParser(cfg, historyProvider) | |
| 26 | + | ||
| 27 | + | return &ParserService{ | |
| 28 | + | parser: p, | |
| 29 | + | dbQueries: queries, | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // ParseInput parses user input and returns navigation result. | |
| 34 | + | func (s *ParserService) ParseInput(ctx context.Context, input string) (*parser.ParseResult, error) { | |
| 35 | + | if input == "" { | |
| 36 | + | return nil, fmt.Errorf("input cannot be empty") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | return s.parser.ParseInput(input) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // GetCompletions provides URL/search completions based on input. | |
| 43 | + | func (s *ParserService) GetCompletions(ctx context.Context, input string, limit int) ([]string, error) { | |
| 44 | + | if limit <= 0 { | |
| 45 | + | limit = 10 // Default limit | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | return s.parser.SuggestCompletions(input, limit) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // FuzzySearchHistory performs fuzzy search on history entries. | |
| 52 | + | func (s *ParserService) FuzzySearchHistory(ctx context.Context, query string, threshold float64) ([]parser.FuzzyMatch, error) { | |
| 53 | + | if threshold <= 0 { | |
| 54 | + | threshold = 0.3 // Default threshold | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | return s.parser.FuzzySearchHistory(query, threshold) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // GetSupportedShortcuts returns configured search shortcuts. | |
| 61 | + | func (s *ParserService) GetSupportedShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) { | |
| 62 | + | return s.parser.GetSupportedShortcuts(), nil | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // ProcessShortcut processes a shortcut and returns the resulting URL. | |
| 66 | + | func (s *ParserService) ProcessShortcut(ctx context.Context, shortcut, query string) (string, error) { | |
| 67 | + | shortcuts := s.parser.GetSupportedShortcuts() | |
| 68 | + | return s.parser.ProcessShortcut(shortcut, query, shortcuts) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // CalculateSimilarity calculates similarity between two strings. | |
| 72 | + | func (s *ParserService) CalculateSimilarity(ctx context.Context, s1, s2 string) (float64, error) { | |
| 73 | + | return s.parser.CalculateSimilarity(s1, s2), nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // ValidateURL checks if the input represents a valid URL. | |
| 77 | + | func (s *ParserService) ValidateURL(ctx context.Context, input string) (bool, error) { | |
| 78 | + | return s.parser.IsValidURL(input), nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // PreviewParse provides a preview of what ParseInput would return. | |
| 82 | + | func (s *ParserService) PreviewParse(ctx context.Context, input string) (*parser.ParseResult, error) { | |
| 83 | + | if input == "" { | |
| 84 | + | return nil, fmt.Errorf("input cannot be empty") | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | return s.parser.PreviewParse(input) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // GetFuzzyConfig returns current fuzzy matching configuration. | |
| 91 | + | func (s *ParserService) GetFuzzyConfig(ctx context.Context) (*parser.FuzzyConfig, error) { | |
| 92 | + | return s.parser.GetFuzzyConfig(), nil | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // UpdateFuzzyConfig updates the fuzzy matching configuration. | |
| 96 | + | func (s *ParserService) UpdateFuzzyConfig(ctx context.Context, config *parser.FuzzyConfig) error { | |
| 97 | + | return s.parser.UpdateFuzzyConfig(config) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // DatabaseHistoryProvider implements HistoryProvider using the database. | |
| 101 | + | type DatabaseHistoryProvider struct { | |
| 102 | + | queries *db.Queries | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // GetRecentHistory returns recent history entries limited by count. | |
| 106 | + | func (h *DatabaseHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) { | |
| 107 | + | ctx := context.Background() | |
| 108 | + | ||
| 109 | + | entries, err := h.queries.GetHistory(ctx, int64(limit)) | |
| 110 | + | if err != nil { | |
| 111 | + | return nil, err | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // Convert to pointer slice | |
| 115 | + | result := make([]*db.History, len(entries)) | |
| 116 | + | for i, entry := range entries { | |
| 117 | + | entryCopy := entry | |
| 118 | + | result[i] = &entryCopy | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | return result, nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // GetAllHistory returns all history entries for fuzzy search. | |
| 125 | + | func (h *DatabaseHistoryProvider) GetAllHistory() ([]*db.History, error) { | |
| 126 | + | ctx := context.Background() | |
| 127 | + | ||
| 128 | + | // Use a large limit to get all history | |
| 129 | + | entries, err := h.queries.GetHistory(ctx, 10000) | |
| 130 | + | if err != nil { | |
| 131 | + | return nil, err | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // Convert to pointer slice | |
| 135 | + | result := make([]*db.History, len(entries)) | |
| 136 | + | for i, entry := range entries { | |
| 137 | + | entryCopy := entry | |
| 138 | + | result[i] = &entryCopy | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | return result, nil | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // SearchHistory performs a basic text search in history. | |
| 145 | + | func (h *DatabaseHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) { | |
| 146 | + | ctx := context.Background() | |
| 147 | + | ||
| 148 | + | // Use SQL LIKE pattern for basic search | |
| 149 | + | urlPattern := sql.NullString{String: "%" + query + "%", Valid: true} | |
| 150 | + | titlePattern := sql.NullString{String: "%" + query + "%", Valid: true} | |
| 151 | + | ||
| 152 | + | entries, err := h.queries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit)) | |
| 153 | + | if err != nil { | |
| 154 | + | return nil, err | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // Convert to pointer slice | |
| 158 | + | result := make([]*db.History, len(entries)) | |
| 159 | + | for i, entry := range entries { | |
| 160 | + | entryCopy := entry | |
| 161 | + | result[i] = &entryCopy | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | return result, nil | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // GetHistoryByURL retrieves history entry by exact URL match. | |
| 168 | + | func (h *DatabaseHistoryProvider) GetHistoryByURL(url string) (*db.History, error) { | |
| 169 | + | ctx := context.Background() | |
| 170 | + | ||
| 171 | + | // Search for exact URL match using SearchHistory | |
| 172 | + | urlPattern := sql.NullString{String: url, Valid: true} | |
| 173 | + | emptyPattern := sql.NullString{Valid: false} | |
| 174 | + | ||
| 175 | + | entries, err := h.queries.SearchHistory(ctx, urlPattern, emptyPattern, 1) | |
| 176 | + | if err != nil { | |
| 177 | + | return nil, err | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | if len(entries) == 0 { | |
| 181 | + | return nil, fmt.Errorf("history entry not found for URL: %s", url) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | return &entries[0], nil | |
| 185 | + | }⏎̸ | |
| @@ -0,0 +1,185 @@ | |||||
| 1 | + | package services | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "database/sql" | |||
| 6 | + | "fmt" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/config" | |||
| 9 | + | "github.com/bnema/dumber/internal/db" | |||
| 10 | + | "github.com/bnema/dumber/internal/parser" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // ParserService wraps the parser functionality for Wails integration. | |||
| 14 | + | type ParserService struct { | |||
| 15 | + | parser *parser.Parser | |||
| 16 | + | dbQueries *db.Queries | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // NewParserService creates a new ParserService instance. | |||
| 20 | + | func NewParserService(cfg *config.Config, queries *db.Queries) *ParserService { | |||
| 21 | + | // Create history provider from database | |||
| 22 | + | historyProvider := &DatabaseHistoryProvider{queries: queries} | |||
| 23 | + | ||||
| 24 | + | // Create parser with configuration | |||
| 25 | + | p := parser.NewParser(cfg, historyProvider) | |||
| 26 | + | ||||
| 27 | + | return &ParserService{ | |||
| 28 | + | parser: p, | |||
| 29 | + | dbQueries: queries, | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // ParseInput parses user input and returns navigation result. | |||
| 34 | + | func (s *ParserService) ParseInput(ctx context.Context, input string) (*parser.ParseResult, error) { | |||
| 35 | + | if input == "" { | |||
| 36 | + | return nil, fmt.Errorf("input cannot be empty") | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | return s.parser.ParseInput(input) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // GetCompletions provides URL/search completions based on input. | |||
| 43 | + | func (s *ParserService) GetCompletions(ctx context.Context, input string, limit int) ([]string, error) { | |||
| 44 | + | if limit <= 0 { | |||
| 45 | + | limit = 10 // Default limit | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | return s.parser.SuggestCompletions(input, limit) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // FuzzySearchHistory performs fuzzy search on history entries. | |||
| 52 | + | func (s *ParserService) FuzzySearchHistory(ctx context.Context, query string, threshold float64) ([]parser.FuzzyMatch, error) { | |||
| 53 | + | if threshold <= 0 { | |||
| 54 | + | threshold = 0.3 // Default threshold | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | return s.parser.FuzzySearchHistory(query, threshold) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // GetSupportedShortcuts returns configured search shortcuts. | |||
| 61 | + | func (s *ParserService) GetSupportedShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) { | |||
| 62 | + | return s.parser.GetSupportedShortcuts(), nil | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // ProcessShortcut processes a shortcut and returns the resulting URL. | |||
| 66 | + | func (s *ParserService) ProcessShortcut(ctx context.Context, shortcut, query string) (string, error) { | |||
| 67 | + | shortcuts := s.parser.GetSupportedShortcuts() | |||
| 68 | + | return s.parser.ProcessShortcut(shortcut, query, shortcuts) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // CalculateSimilarity calculates similarity between two strings. | |||
| 72 | + | func (s *ParserService) CalculateSimilarity(ctx context.Context, s1, s2 string) (float64, error) { | |||
| 73 | + | return s.parser.CalculateSimilarity(s1, s2), nil | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // ValidateURL checks if the input represents a valid URL. | |||
| 77 | + | func (s *ParserService) ValidateURL(ctx context.Context, input string) (bool, error) { | |||
| 78 | + | return s.parser.IsValidURL(input), nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // PreviewParse provides a preview of what ParseInput would return. | |||
| 82 | + | func (s *ParserService) PreviewParse(ctx context.Context, input string) (*parser.ParseResult, error) { | |||
| 83 | + | if input == "" { | |||
| 84 | + | return nil, fmt.Errorf("input cannot be empty") | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | return s.parser.PreviewParse(input) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // GetFuzzyConfig returns current fuzzy matching configuration. | |||
| 91 | + | func (s *ParserService) GetFuzzyConfig(ctx context.Context) (*parser.FuzzyConfig, error) { | |||
| 92 | + | return s.parser.GetFuzzyConfig(), nil | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // UpdateFuzzyConfig updates the fuzzy matching configuration. | |||
| 96 | + | func (s *ParserService) UpdateFuzzyConfig(ctx context.Context, config *parser.FuzzyConfig) error { | |||
| 97 | + | return s.parser.UpdateFuzzyConfig(config) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // DatabaseHistoryProvider implements HistoryProvider using the database. | |||
| 101 | + | type DatabaseHistoryProvider struct { | |||
| 102 | + | queries *db.Queries | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // GetRecentHistory returns recent history entries limited by count. | |||
| 106 | + | func (h *DatabaseHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) { | |||
| 107 | + | ctx := context.Background() | |||
| 108 | + | ||||
| 109 | + | entries, err := h.queries.GetHistory(ctx, int64(limit)) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return nil, err | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // Convert to pointer slice | |||
| 115 | + | result := make([]*db.History, len(entries)) | |||
| 116 | + | for i, entry := range entries { | |||
| 117 | + | entryCopy := entry | |||
| 118 | + | result[i] = &entryCopy | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | return result, nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // GetAllHistory returns all history entries for fuzzy search. | |||
| 125 | + | func (h *DatabaseHistoryProvider) GetAllHistory() ([]*db.History, error) { | |||
| 126 | + | ctx := context.Background() | |||
| 127 | + | ||||
| 128 | + | // Use a large limit to get all history | |||
| 129 | + | entries, err := h.queries.GetHistory(ctx, 10000) | |||
| 130 | + | if err != nil { | |||
| 131 | + | return nil, err | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // Convert to pointer slice | |||
| 135 | + | result := make([]*db.History, len(entries)) | |||
| 136 | + | for i, entry := range entries { | |||
| 137 | + | entryCopy := entry | |||
| 138 | + | result[i] = &entryCopy | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | return result, nil | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // SearchHistory performs a basic text search in history. | |||
| 145 | + | func (h *DatabaseHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) { | |||
| 146 | + | ctx := context.Background() | |||
| 147 | + | ||||
| 148 | + | // Use SQL LIKE pattern for basic search | |||
| 149 | + | urlPattern := sql.NullString{String: "%" + query + "%", Valid: true} | |||
| 150 | + | titlePattern := sql.NullString{String: "%" + query + "%", Valid: true} | |||
| 151 | + | ||||
| 152 | + | entries, err := h.queries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit)) | |||
| 153 | + | if err != nil { | |||
| 154 | + | return nil, err | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // Convert to pointer slice | |||
| 158 | + | result := make([]*db.History, len(entries)) | |||
| 159 | + | for i, entry := range entries { | |||
| 160 | + | entryCopy := entry | |||
| 161 | + | result[i] = &entryCopy | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | return result, nil | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // GetHistoryByURL retrieves history entry by exact URL match. | |||
| 168 | + | func (h *DatabaseHistoryProvider) GetHistoryByURL(url string) (*db.History, error) { | |||
| 169 | + | ctx := context.Background() | |||
| 170 | + | ||||
| 171 | + | // Search for exact URL match using SearchHistory | |||
| 172 | + | urlPattern := sql.NullString{String: url, Valid: true} | |||
| 173 | + | emptyPattern := sql.NullString{Valid: false} | |||
| 174 | + | ||||
| 175 | + | entries, err := h.queries.SearchHistory(ctx, urlPattern, emptyPattern, 1) | |||
| 176 | + | if err != nil { | |||
| 177 | + | return nil, err | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | if len(entries) == 0 { | |||
| 181 | + | return nil, fmt.Errorf("history entry not found for URL: %s", url) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | return &entries[0], nil | |||
| 185 | + | }⏎̸ | |||
A specs/002-ui-enhancement-polishing/spec.md +135 -0
| @@ -0,0 +1,135 @@ | |||
| 1 | + | # Feature Specification: UI Enhancement & Polishing + Browser Identity | |
| 2 | + | ||
| 3 | + | **Feature Branch**: `002-ui-enhancement-polishing` | |
| 4 | + | **Created**: 2025-09-09 | |
| 5 | + | **Status**: Draft | |
| 6 | + | **Input**: User description: "Polish UI, add browser identity/user agent, enhance performance" | |
| 7 | + | ||
| 8 | + | ## Execution Flow (main) | |
| 9 | + | ``` | |
| 10 | + | 1. Parse user description from Input | |
| 11 | + | → "Enhance UI/UX, create browser identity, optimize performance" | |
| 12 | + | 2. Extract key concepts from description | |
| 13 | + | → Actors: users, website analytics, system performance | |
| 14 | + | → Actions: polish, identify, optimize, enhance | |
| 15 | + | → Data: user agent strings, UI components, performance metrics | |
| 16 | + | → Constraints: maintain compatibility, respect privacy | |
| 17 | + | 3. For each unclear aspect: | |
| 18 | + | → No major ambiguities - build on existing foundation | |
| 19 | + | 4. Fill User Scenarios & Testing section | |
| 20 | + | → Clear flow: user experiences polished interface and proper browser identification | |
| 21 | + | 5. Generate Functional Requirements | |
| 22 | + | → Each requirement testable through UI/performance validation | |
| 23 | + | 6. Identify Key Entities: User agent, UI components, performance metrics, browser identity | |
| 24 | + | 7. Run Review Checklist | |
| 25 | + | → No [NEEDS CLARIFICATION] markers needed | |
| 26 | + | → Focus on polish and optimization over new features | |
| 27 | + | 8. Return: SUCCESS (spec ready for planning) | |
| 28 | + | ``` | |
| 29 | + | ||
| 30 | + | --- | |
| 31 | + | ||
| 32 | + | ## ⚡ Quick Guidelines | |
| 33 | + | - ✅ Focus on WHAT users need and WHY | |
| 34 | + | - ❌ Avoid HOW to implement (no tech stack, APIs, code structure) | |
| 35 | + | - 👥 Written for business stakeholders, not developers | |
| 36 | + | ||
| 37 | + | --- | |
| 38 | + | ||
| 39 | + | ## User Scenarios & Testing *(mandatory)* | |
| 40 | + | ||
| 41 | + | ### Primary User Story | |
| 42 | + | As a Dumber Browser user, I need a polished, fast, and properly identified browser experience so that websites recognize my browser correctly, the interface feels professional, and performance meets my daily usage expectations without friction. | |
| 43 | + | ||
| 44 | + | ### Acceptance Scenarios | |
| 45 | + | ||
| 46 | + | #### Browser Identity & Recognition | |
| 47 | + | 1. **Given** I visit a website with analytics, **When** I browse normally, **Then** the site logs show "Dumber Browser" in user agent statistics | |
| 48 | + | 2. **Given** a website checks browser compatibility, **When** I access it, **Then** the site recognizes WebKit compatibility and loads properly | |
| 49 | + | 3. **Given** I want privacy control, **When** I configure user agent settings, **Then** I can choose between branded identity and anonymous browsing | |
| 50 | + | ||
| 51 | + | #### UI Enhancement & Polish | |
| 52 | + | 4. **Given** I launch the landing page, **When** it loads, **Then** the interface appears within 500ms with smooth transitions | |
| 53 | + | 5. **Given** I view my browsing history, **When** scanning entries, **Then** URLs and titles are clearly readable with proper formatting and visual hierarchy | |
| 54 | + | 6. **Given** I use the application on different screen sizes, **When** resizing, **Then** the layout adapts gracefully without breaking | |
| 55 | + | 7. **Given** I interact with shortcuts, **When** hovering or clicking, **Then** visual feedback is immediate and intuitive | |
| 56 | + | ||
| 57 | + | #### Performance & Optimization | |
| 58 | + | 8. **Given** I start the application, **When** timing startup, **Then** launch completes in under 500ms | |
| 59 | + | 9. **Given** I monitor system resources, **When** running normally, **Then** memory usage stays below 100MB baseline | |
| 60 | + | 10. **Given** I switch between CLI and GUI modes, **When** testing transitions, **Then** mode switching is seamless without delays | |
| 61 | + | ||
| 62 | + | ### Edge Cases | |
| 63 | + | - What happens when user agent is blocked by websites? | |
| 64 | + | - How does the UI behave with extremely long URLs or titles? | |
| 65 | + | - What occurs when system resources are constrained? | |
| 66 | + | - How does the interface handle empty states (no history, no shortcuts)? | |
| 67 | + | ||
| 68 | + | ## Requirements *(mandatory)* | |
| 69 | + | ||
| 70 | + | ### Functional Requirements | |
| 71 | + | ||
| 72 | + | #### Browser Identity | |
| 73 | + | - **FR-001**: System MUST provide a custom Dumber Browser user agent string | |
| 74 | + | - **FR-002**: System MUST include version information in user agent for debugging | |
| 75 | + | - **FR-003**: System MUST maintain WebKit compatibility strings for site compatibility | |
| 76 | + | - **FR-004**: System MUST allow user agent customization per-site if needed | |
| 77 | + | - **FR-005**: System MUST provide privacy mode with anonymous/generic user agent | |
| 78 | + | ||
| 79 | + | #### UI Enhancement | |
| 80 | + | - **FR-006**: System MUST display loading states clearly during data fetching | |
| 81 | + | - **FR-007**: System MUST provide visual feedback for all interactive elements | |
| 82 | + | - **FR-008**: System MUST format URLs and titles for optimal readability | |
| 83 | + | - **FR-009**: System MUST implement responsive design for various screen sizes | |
| 84 | + | - **FR-010**: System MUST show empty states gracefully when no data exists | |
| 85 | + | - **FR-011**: System MUST provide smooth transitions between different views | |
| 86 | + | ||
| 87 | + | #### Performance Optimization | |
| 88 | + | - **FR-012**: System MUST start within 500ms from launch command | |
| 89 | + | - **FR-013**: System MUST maintain memory usage below 100MB during normal operation | |
| 90 | + | - **FR-014**: System MUST load landing page data within 200ms | |
| 91 | + | - **FR-015**: System MUST respond to user interactions within 50ms | |
| 92 | + | ||
| 93 | + | #### Integration Polish | |
| 94 | + | - **FR-016**: System MUST provide clear error messages with actionable guidance | |
| 95 | + | - **FR-017**: System MUST handle CLI-to-GUI transitions seamlessly | |
| 96 | + | - **FR-018**: System MUST format dmenu output for optimal launcher integration | |
| 97 | + | - **FR-019**: System MUST maintain consistent visual design language throughout | |
| 98 | + | ||
| 99 | + | ### Key Entities | |
| 100 | + | - **Browser Identity**: User agent string, version info, compatibility data | |
| 101 | + | - **UI Components**: Landing page, history list, shortcuts grid, loading states | |
| 102 | + | - **Performance Metrics**: Startup time, memory usage, response times | |
| 103 | + | - **Visual Design**: Color scheme, typography, spacing, transitions | |
| 104 | + | - **User Experience**: Interactions, feedback, error states, accessibility | |
| 105 | + | ||
| 106 | + | --- | |
| 107 | + | ||
| 108 | + | ## Review & Acceptance Checklist | |
| 109 | + | ||
| 110 | + | ### Content Quality | |
| 111 | + | - [x] No implementation details (languages, frameworks, APIs) | |
| 112 | + | - [x] Focused on user value and business needs | |
| 113 | + | - [x] Written for non-technical stakeholders | |
| 114 | + | - [x] All mandatory sections completed | |
| 115 | + | ||
| 116 | + | ### Requirement Completeness | |
| 117 | + | - [x] No [NEEDS CLARIFICATION] markers remain | |
| 118 | + | - [x] Requirements are testable and unambiguous | |
| 119 | + | - [x] Success criteria are measurable | |
| 120 | + | - [x] Scope is clearly bounded (polish existing, not new features) | |
| 121 | + | - [x] Dependencies and assumptions identified | |
| 122 | + | ||
| 123 | + | --- | |
| 124 | + | ||
| 125 | + | ## Execution Status | |
| 126 | + | ||
| 127 | + | - [x] User description parsed | |
| 128 | + | - [x] Key concepts extracted | |
| 129 | + | - [x] Ambiguities marked (none found) | |
| 130 | + | - [x] User scenarios defined | |
| 131 | + | - [x] Requirements generated | |
| 132 | + | - [x] Entities identified | |
| 133 | + | - [x] Review checklist passed | |
| 134 | + | ||
| 135 | + | ---⏎̸ | |
| @@ -0,0 +1,135 @@ | |||||
| 1 | + | # Feature Specification: UI Enhancement & Polishing + Browser Identity | |||
| 2 | + | ||||
| 3 | + | **Feature Branch**: `002-ui-enhancement-polishing` | |||
| 4 | + | **Created**: 2025-09-09 | |||
| 5 | + | **Status**: Draft | |||
| 6 | + | **Input**: User description: "Polish UI, add browser identity/user agent, enhance performance" | |||
| 7 | + | ||||
| 8 | + | ## Execution Flow (main) | |||
| 9 | + | ``` | |||
| 10 | + | 1. Parse user description from Input | |||
| 11 | + | → "Enhance UI/UX, create browser identity, optimize performance" | |||
| 12 | + | 2. Extract key concepts from description | |||
| 13 | + | → Actors: users, website analytics, system performance | |||
| 14 | + | → Actions: polish, identify, optimize, enhance | |||
| 15 | + | → Data: user agent strings, UI components, performance metrics | |||
| 16 | + | → Constraints: maintain compatibility, respect privacy | |||
| 17 | + | 3. For each unclear aspect: | |||
| 18 | + | → No major ambiguities - build on existing foundation | |||
| 19 | + | 4. Fill User Scenarios & Testing section | |||
| 20 | + | → Clear flow: user experiences polished interface and proper browser identification | |||
| 21 | + | 5. Generate Functional Requirements | |||
| 22 | + | → Each requirement testable through UI/performance validation | |||
| 23 | + | 6. Identify Key Entities: User agent, UI components, performance metrics, browser identity | |||
| 24 | + | 7. Run Review Checklist | |||
| 25 | + | → No [NEEDS CLARIFICATION] markers needed | |||
| 26 | + | → Focus on polish and optimization over new features | |||
| 27 | + | 8. Return: SUCCESS (spec ready for planning) | |||
| 28 | + | ``` | |||
| 29 | + | ||||
| 30 | + | --- | |||
| 31 | + | ||||
| 32 | + | ## ⚡ Quick Guidelines | |||
| 33 | + | - ✅ Focus on WHAT users need and WHY | |||
| 34 | + | - ❌ Avoid HOW to implement (no tech stack, APIs, code structure) | |||
| 35 | + | - 👥 Written for business stakeholders, not developers | |||
| 36 | + | ||||
| 37 | + | --- | |||
| 38 | + | ||||
| 39 | + | ## User Scenarios & Testing *(mandatory)* | |||
| 40 | + | ||||
| 41 | + | ### Primary User Story | |||
| 42 | + | As a Dumber Browser user, I need a polished, fast, and properly identified browser experience so that websites recognize my browser correctly, the interface feels professional, and performance meets my daily usage expectations without friction. | |||
| 43 | + | ||||
| 44 | + | ### Acceptance Scenarios | |||
| 45 | + | ||||
| 46 | + | #### Browser Identity & Recognition | |||
| 47 | + | 1. **Given** I visit a website with analytics, **When** I browse normally, **Then** the site logs show "Dumber Browser" in user agent statistics | |||
| 48 | + | 2. **Given** a website checks browser compatibility, **When** I access it, **Then** the site recognizes WebKit compatibility and loads properly | |||
| 49 | + | 3. **Given** I want privacy control, **When** I configure user agent settings, **Then** I can choose between branded identity and anonymous browsing | |||
| 50 | + | ||||
| 51 | + | #### UI Enhancement & Polish | |||
| 52 | + | 4. **Given** I launch the landing page, **When** it loads, **Then** the interface appears within 500ms with smooth transitions | |||
| 53 | + | 5. **Given** I view my browsing history, **When** scanning entries, **Then** URLs and titles are clearly readable with proper formatting and visual hierarchy | |||
| 54 | + | 6. **Given** I use the application on different screen sizes, **When** resizing, **Then** the layout adapts gracefully without breaking | |||
| 55 | + | 7. **Given** I interact with shortcuts, **When** hovering or clicking, **Then** visual feedback is immediate and intuitive | |||
| 56 | + | ||||
| 57 | + | #### Performance & Optimization | |||
| 58 | + | 8. **Given** I start the application, **When** timing startup, **Then** launch completes in under 500ms | |||
| 59 | + | 9. **Given** I monitor system resources, **When** running normally, **Then** memory usage stays below 100MB baseline | |||
| 60 | + | 10. **Given** I switch between CLI and GUI modes, **When** testing transitions, **Then** mode switching is seamless without delays | |||
| 61 | + | ||||
| 62 | + | ### Edge Cases | |||
| 63 | + | - What happens when user agent is blocked by websites? | |||
| 64 | + | - How does the UI behave with extremely long URLs or titles? | |||
| 65 | + | - What occurs when system resources are constrained? | |||
| 66 | + | - How does the interface handle empty states (no history, no shortcuts)? | |||
| 67 | + | ||||
| 68 | + | ## Requirements *(mandatory)* | |||
| 69 | + | ||||
| 70 | + | ### Functional Requirements | |||
| 71 | + | ||||
| 72 | + | #### Browser Identity | |||
| 73 | + | - **FR-001**: System MUST provide a custom Dumber Browser user agent string | |||
| 74 | + | - **FR-002**: System MUST include version information in user agent for debugging | |||
| 75 | + | - **FR-003**: System MUST maintain WebKit compatibility strings for site compatibility | |||
| 76 | + | - **FR-004**: System MUST allow user agent customization per-site if needed | |||
| 77 | + | - **FR-005**: System MUST provide privacy mode with anonymous/generic user agent | |||
| 78 | + | ||||
| 79 | + | #### UI Enhancement | |||
| 80 | + | - **FR-006**: System MUST display loading states clearly during data fetching | |||
| 81 | + | - **FR-007**: System MUST provide visual feedback for all interactive elements | |||
| 82 | + | - **FR-008**: System MUST format URLs and titles for optimal readability | |||
| 83 | + | - **FR-009**: System MUST implement responsive design for various screen sizes | |||
| 84 | + | - **FR-010**: System MUST show empty states gracefully when no data exists | |||
| 85 | + | - **FR-011**: System MUST provide smooth transitions between different views | |||
| 86 | + | ||||
| 87 | + | #### Performance Optimization | |||
| 88 | + | - **FR-012**: System MUST start within 500ms from launch command | |||
| 89 | + | - **FR-013**: System MUST maintain memory usage below 100MB during normal operation | |||
| 90 | + | - **FR-014**: System MUST load landing page data within 200ms | |||
| 91 | + | - **FR-015**: System MUST respond to user interactions within 50ms | |||
| 92 | + | ||||
| 93 | + | #### Integration Polish | |||
| 94 | + | - **FR-016**: System MUST provide clear error messages with actionable guidance | |||
| 95 | + | - **FR-017**: System MUST handle CLI-to-GUI transitions seamlessly | |||
| 96 | + | - **FR-018**: System MUST format dmenu output for optimal launcher integration | |||
| 97 | + | - **FR-019**: System MUST maintain consistent visual design language throughout | |||
| 98 | + | ||||
| 99 | + | ### Key Entities | |||
| 100 | + | - **Browser Identity**: User agent string, version info, compatibility data | |||
| 101 | + | - **UI Components**: Landing page, history list, shortcuts grid, loading states | |||
| 102 | + | - **Performance Metrics**: Startup time, memory usage, response times | |||
| 103 | + | - **Visual Design**: Color scheme, typography, spacing, transitions | |||
| 104 | + | - **User Experience**: Interactions, feedback, error states, accessibility | |||
| 105 | + | ||||
| 106 | + | --- | |||
| 107 | + | ||||
| 108 | + | ## Review & Acceptance Checklist | |||
| 109 | + | ||||
| 110 | + | ### Content Quality | |||
| 111 | + | - [x] No implementation details (languages, frameworks, APIs) | |||
| 112 | + | - [x] Focused on user value and business needs | |||
| 113 | + | - [x] Written for non-technical stakeholders | |||
| 114 | + | - [x] All mandatory sections completed | |||
| 115 | + | ||||
| 116 | + | ### Requirement Completeness | |||
| 117 | + | - [x] No [NEEDS CLARIFICATION] markers remain | |||
| 118 | + | - [x] Requirements are testable and unambiguous | |||
| 119 | + | - [x] Success criteria are measurable | |||
| 120 | + | - [x] Scope is clearly bounded (polish existing, not new features) | |||
| 121 | + | - [x] Dependencies and assumptions identified | |||
| 122 | + | ||||
| 123 | + | --- | |||
| 124 | + | ||||
| 125 | + | ## Execution Status | |||
| 126 | + | ||||
| 127 | + | - [x] User description parsed | |||
| 128 | + | - [x] Key concepts extracted | |||
| 129 | + | - [x] Ambiguities marked (none found) | |||
| 130 | + | - [x] User scenarios defined | |||
| 131 | + | - [x] Requirements generated | |||
| 132 | + | - [x] Entities identified | |||
| 133 | + | - [x] Review checklist passed | |||
| 134 | + | ||||
| 135 | + | ---⏎̸ | |||
A test-wails +0 -0 binary file: no text diff
binary file: no text diff
A wails.json +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "dumber", | |
| 3 | + | "displayname": "Dumber Browser", | |
| 4 | + | "description": "A smart URL launcher and browser with learning behavior", | |
| 5 | + | "version": "0.1.0", | |
| 6 | + | "author": "dumber", | |
| 7 | + | "license": "MIT", | |
| 8 | + | "build": { | |
| 9 | + | "dir": ".", | |
| 10 | + | "main": "main.go", | |
| 11 | + | "binary": "dumber", | |
| 12 | + | "output": "dist" | |
| 13 | + | }, | |
| 14 | + | "frontend": { | |
| 15 | + | "dir": "frontend", | |
| 16 | + | "dist": "dist" | |
| 17 | + | }, | |
| 18 | + | "flags": { | |
| 19 | + | "linux": [ | |
| 20 | + | "-s", | |
| 21 | + | "-w" | |
| 22 | + | ] | |
| 23 | + | } | |
| 24 | + | }⏎̸ | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | { | |||
| 2 | + | "name": "dumber", | |||
| 3 | + | "displayname": "Dumber Browser", | |||
| 4 | + | "description": "A smart URL launcher and browser with learning behavior", | |||
| 5 | + | "version": "0.1.0", | |||
| 6 | + | "author": "dumber", | |||
| 7 | + | "license": "MIT", | |||
| 8 | + | "build": { | |||
| 9 | + | "dir": ".", | |||
| 10 | + | "main": "main.go", | |||
| 11 | + | "binary": "dumber", | |||
| 12 | + | "output": "dist" | |||
| 13 | + | }, | |||
| 14 | + | "frontend": { | |||
| 15 | + | "dir": "frontend", | |||
| 16 | + | "dist": "dist" | |||
| 17 | + | }, | |||
| 18 | + | "flags": { | |||
| 19 | + | "linux": [ | |||
| 20 | + | "-s", | |||
| 21 | + | "-w" | |||
| 22 | + | ] | |||
| 23 | + | } | |||
| 24 | + | }⏎̸ | |||