Merge branch '001-init-project'
53 files · +8649 -35
A .gitignore +59 -0
| @@ -0,0 +1,59 @@ | |||||
| 1 | + | # Build outputs | |||
| 2 | + | dist/ | |||
| 3 | + | *.exe | |||
| 4 | + | *.exe~ | |||
| 5 | + | *.dll | |||
| 6 | + | *.so | |||
| 7 | + | *.dylib | |||
| 8 | + | ||||
| 9 | + | # Binaries for programs and plugins | |||
| 10 | + | dumber | |||
| 11 | + | dumber.exe | |||
| 12 | + | ||||
| 13 | + | # Test binary, built with `go test -c` | |||
| 14 | + | *.test | |||
| 15 | + | ||||
| 16 | + | # Output of the go coverage tool | |||
| 17 | + | *.out | |||
| 18 | + | ||||
| 19 | + | # Dependency directories (remove the comment below to include it) | |||
| 20 | + | # vendor/ | |||
| 21 | + | ||||
| 22 | + | # Go workspace file | |||
| 23 | + | go.work | |||
| 24 | + | ||||
| 25 | + | # IDE files | |||
| 26 | + | .vscode/ | |||
| 27 | + | .idea/ | |||
| 28 | + | *.swp | |||
| 29 | + | *.swo | |||
| 30 | + | *~ | |||
| 31 | + | ||||
| 32 | + | # OS generated files | |||
| 33 | + | .DS_Store | |||
| 34 | + | .DS_Store? | |||
| 35 | + | ._* | |||
| 36 | + | .Spotlight-V100 | |||
| 37 | + | .Trashes | |||
| 38 | + | ehthumbs.db | |||
| 39 | + | Thumbs.db | |||
| 40 | + | ||||
| 41 | + | # Temporary files | |||
| 42 | + | *.tmp | |||
| 43 | + | *.temp | |||
| 44 | + | *.log | |||
| 45 | + | ||||
| 46 | + | # Database files | |||
| 47 | + | *.db | |||
| 48 | + | *.sqlite | |||
| 49 | + | *.sqlite3 | |||
| 50 | + | ||||
| 51 | + | # Configuration files that might contain secrets | |||
| 52 | + | config.local.* | |||
| 53 | + | .env | |||
| 54 | + | .env.local | |||
| 55 | + | .env.*.local | |||
| 56 | + | ||||
| 57 | + | # Build artifacts and caches | |||
| 58 | + | .cache/ | |||
| 59 | + | *.prof⏎̸ | |||
A .golangci.yml +126 -0
| @@ -0,0 +1,126 @@ | |||||
| 1 | + | version: "2" | |||
| 2 | + | ||||
| 3 | + | linters: | |||
| 4 | + | default: none | |||
| 5 | + | enable: | |||
| 6 | + | - bodyclose # checks whether HTTP response body is closed successfully | |||
| 7 | + | - copyloopvar # detects places where loop variables are copied | |||
| 8 | + | # - depguard # checks for unwanted dependencies (disabled for CLI apps) | |||
| 9 | + | - dogsled # checks assignments with too many blank identifiers | |||
| 10 | + | - dupl # tool for code clone detection | |||
| 11 | + | - errcheck # checking for unchecked errors | |||
| 12 | + | - errorlint # errorlint is a linter for that can be used to find code that will cause problems with the error wrapping scheme | |||
| 13 | + | - funlen # tool for detection of long functions | |||
| 14 | + | - gocheckcompilerdirectives # validates go compiler directive comments (//go:) | |||
| 15 | + | # - gochecknoinits # checks that no init functions are present in Go code (disabled for CLI apps) | |||
| 16 | + | - goconst # finds repeated strings that could be replaced by a constant | |||
| 17 | + | - gocritic # provides diagnostics that check for bugs, performance and style issues | |||
| 18 | + | - gocyclo # computes and checks the cyclomatic complexity of functions | |||
| 19 | + | - godox # tool for detection of FIXME, TODO and other comment keywords | |||
| 20 | + | - mnd # detects magic numbers | |||
| 21 | + | - goprintffuncname # checks that printf-like functions are named with `f` at the end | |||
| 22 | + | - gosec # inspects source code for security problems | |||
| 23 | + | - govet # examines Go source code and reports suspicious constructs | |||
| 24 | + | - ineffassign # detects when assignments to existing variables are not used | |||
| 25 | + | - lll # reports long lines | |||
| 26 | + | - misspell # finds commonly misspelled English words in comments | |||
| 27 | + | - nakedret # finds naked returns in functions greater than a specified function length | |||
| 28 | + | - nolintlint # reports ill-formed or insufficient nolint directives | |||
| 29 | + | - revive # fast, configurable, extensible, flexible, and beautiful linter for Go | |||
| 30 | + | - staticcheck # advanced Go linter | |||
| 31 | + | - testifylint # checks usage of github.com/stretchr/testify | |||
| 32 | + | - unconvert # removes unnecessary type conversions | |||
| 33 | + | - unparam # reports unused function parameters | |||
| 34 | + | - unused # checks for unused constants, variables, functions and types | |||
| 35 | + | - whitespace # detects leading and trailing whitespace | |||
| 36 | + | ||||
| 37 | + | linters-settings: | |||
| 38 | + | dupl: | |||
| 39 | + | threshold: 100 | |||
| 40 | + | funlen: | |||
| 41 | + | lines: -1 # don't check line count | |||
| 42 | + | statements: 50 | |||
| 43 | + | goconst: | |||
| 44 | + | min-len: 2 | |||
| 45 | + | min-occurrences: 3 | |||
| 46 | + | gocritic: | |||
| 47 | + | enabled-tags: | |||
| 48 | + | - diagnostic | |||
| 49 | + | - experimental | |||
| 50 | + | - opinionated | |||
| 51 | + | - performance | |||
| 52 | + | - style | |||
| 53 | + | disabled-checks: | |||
| 54 | + | - dupImport | |||
| 55 | + | - ifElseChain | |||
| 56 | + | - octalLiteral | |||
| 57 | + | - whyNoLint | |||
| 58 | + | gocyclo: | |||
| 59 | + | min-complexity: 15 | |||
| 60 | + | godox: | |||
| 61 | + | keywords: | |||
| 62 | + | - FIXME | |||
| 63 | + | - TODO | |||
| 64 | + | - BUG | |||
| 65 | + | mnd: | |||
| 66 | + | checks: | |||
| 67 | + | - argument | |||
| 68 | + | - case | |||
| 69 | + | - condition | |||
| 70 | + | - return | |||
| 71 | + | ignored-numbers: | |||
| 72 | + | - '0' | |||
| 73 | + | - '1' | |||
| 74 | + | - '2' | |||
| 75 | + | - '3' | |||
| 76 | + | ignored-functions: | |||
| 77 | + | - strings.SplitN | |||
| 78 | + | govet: | |||
| 79 | + | enable: | |||
| 80 | + | - nilness | |||
| 81 | + | - shadow | |||
| 82 | + | errorlint: | |||
| 83 | + | asserts: false | |||
| 84 | + | lll: | |||
| 85 | + | line-length: 120 | |||
| 86 | + | misspell: | |||
| 87 | + | locale: US | |||
| 88 | + | nolintlint: | |||
| 89 | + | allow-unused: false | |||
| 90 | + | require-explanation: true | |||
| 91 | + | require-specific: true | |||
| 92 | + | revive: | |||
| 93 | + | rules: | |||
| 94 | + | - name: indent-error-flow | |||
| 95 | + | - name: unused-parameter | |||
| 96 | + | - name: unused-receiver | |||
| 97 | + | ||||
| 98 | + | issues: | |||
| 99 | + | exclude-rules: | |||
| 100 | + | # Exclude some linters from running on tests files | |||
| 101 | + | - path: _test\.go | |||
| 102 | + | linters: | |||
| 103 | + | - dupl | |||
| 104 | + | - mnd | |||
| 105 | + | - lll | |||
| 106 | + | - funlen | |||
| 107 | + | ||||
| 108 | + | # Exclude SQLC generated files from linting | |||
| 109 | + | - path: internal/db/.*\.sql\.go | |||
| 110 | + | linters: | |||
| 111 | + | - all | |||
| 112 | + | ||||
| 113 | + | # Exclude SQLC generated models from some linters | |||
| 114 | + | - path: internal/db/models\.go | |||
| 115 | + | linters: | |||
| 116 | + | - unused | |||
| 117 | + | - deadcode | |||
| 118 | + | ||||
| 119 | + | # Allow long lines in generated code | |||
| 120 | + | - path: internal/db/ | |||
| 121 | + | linters: | |||
| 122 | + | - lll | |||
| 123 | + | ||||
| 124 | + | run: | |||
| 125 | + | timeout: 5m | |||
| 126 | + | modules-download-mode: readonly⏎̸ | |||
A Makefile +100 -0
| @@ -0,0 +1,100 @@ | |||||
| 1 | + | # Makefile for dumber | |||
| 2 | + | ||||
| 3 | + | .PHONY: build test lint clean install-tools dev generate help | |||
| 4 | + | ||||
| 5 | + | # Variables | |||
| 6 | + | BINARY_NAME=dumber | |||
| 7 | + | MAIN_PATH=./cmd/dumber | |||
| 8 | + | DIST_DIR=dist | |||
| 9 | + | ||||
| 10 | + | # Version information from git | |||
| 11 | + | VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0-dev") | |||
| 12 | + | COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") | |||
| 13 | + | BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") | |||
| 14 | + | ||||
| 15 | + | # Linker flags | |||
| 16 | + | LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)" | |||
| 17 | + | ||||
| 18 | + | # Default target | |||
| 19 | + | help: ## Show this help message | |||
| 20 | + | @echo "Available targets:" | |||
| 21 | + | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | |||
| 22 | + | ||||
| 23 | + | # Build targets | |||
| 24 | + | build: ## Build the binary | |||
| 25 | + | @echo "Building $(BINARY_NAME) $(VERSION)..." | |||
| 26 | + | @mkdir -p $(DIST_DIR) | |||
| 27 | + | CGO_ENABLED=0 go build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |||
| 28 | + | ||||
| 29 | + | build-wails: ## Build with CGO enabled for Wails (when needed) | |||
| 30 | + | @echo "Building $(BINARY_NAME) $(VERSION) with CGO enabled..." | |||
| 31 | + | @mkdir -p $(DIST_DIR) | |||
| 32 | + | CGO_ENABLED=1 go build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |||
| 33 | + | ||||
| 34 | + | # Development targets | |||
| 35 | + | dev: ## Run in development mode | |||
| 36 | + | @echo "Running in development mode..." | |||
| 37 | + | go run $(MAIN_PATH) | |||
| 38 | + | ||||
| 39 | + | generate: ## Generate code (SQLC) | |||
| 40 | + | @echo "Generating code with SQLC..." | |||
| 41 | + | sqlc generate | |||
| 42 | + | @echo "Code generation complete" | |||
| 43 | + | ||||
| 44 | + | # Testing | |||
| 45 | + | test: ## Run tests | |||
| 46 | + | @echo "Running tests..." | |||
| 47 | + | CGO_ENABLED=0 go test -v ./... | |||
| 48 | + | ||||
| 49 | + | test-race: ## Run tests with race detection | |||
| 50 | + | @echo "Running tests with race detection..." | |||
| 51 | + | CGO_ENABLED=1 go test -race -v ./... | |||
| 52 | + | ||||
| 53 | + | # Linting | |||
| 54 | + | lint: ## Run golangci-lint | |||
| 55 | + | @echo "Running golangci-lint..." | |||
| 56 | + | golangci-lint run | |||
| 57 | + | ||||
| 58 | + | lint-fix: ## Run golangci-lint with --fix | |||
| 59 | + | @echo "Running golangci-lint with --fix..." | |||
| 60 | + | golangci-lint run --fix | |||
| 61 | + | ||||
| 62 | + | # Tools installation | |||
| 63 | + | install-tools: ## Install development tools | |||
| 64 | + | @echo "Installing development tools..." | |||
| 65 | + | go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest | |||
| 66 | + | go install github.com/wailsapp/wails/v3/cmd/wails3@latest | |||
| 67 | + | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest | |||
| 68 | + | @echo "Tools installed successfully" | |||
| 69 | + | ||||
| 70 | + | # Cleanup | |||
| 71 | + | clean: ## Clean build artifacts | |||
| 72 | + | @echo "Cleaning build artifacts..." | |||
| 73 | + | rm -rf $(DIST_DIR) | |||
| 74 | + | rm -f $(BINARY_NAME) # Remove any old binaries in root | |||
| 75 | + | go clean -cache | |||
| 76 | + | go clean -testcache | |||
| 77 | + | ||||
| 78 | + | # Project initialization | |||
| 79 | + | init: install-tools ## Initialize project dependencies and tools | |||
| 80 | + | @echo "Project dependencies and tools installed!" | |||
| 81 | + | @echo "Ready for development. Run 'make help' for available commands." | |||
| 82 | + | ||||
| 83 | + | # Check setup | |||
| 84 | + | check: ## Check that all tools and dependencies are working | |||
| 85 | + | @echo "Checking project setup..." | |||
| 86 | + | @echo "Go version:" | |||
| 87 | + | @go version | |||
| 88 | + | @echo "\nSQLC version:" | |||
| 89 | + | @sqlc version | |||
| 90 | + | @echo "\nWails version:" | |||
| 91 | + | @wails3 version | |||
| 92 | + | @echo "\nGolangci-lint version:" | |||
| 93 | + | @golangci-lint version | |||
| 94 | + | @echo "\nBuilding project..." | |||
| 95 | + | @make build | |||
| 96 | + | @echo "\nTesting built binary..." | |||
| 97 | + | @$(DIST_DIR)/$(BINARY_NAME) version | |||
| 98 | + | @echo "\nRunning tests..." | |||
| 99 | + | @make test | |||
| 100 | + | @echo "\n✅ All checks passed! Project is ready for development."⏎̸ | |||
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'"⏎̸ | |||
A configs/sqlc.yaml +15 -0
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | version: "2" | |||
| 2 | + | sql: | |||
| 3 | + | - engine: "sqlite" | |||
| 4 | + | queries: "./internal/db/queries/" | |||
| 5 | + | schema: "./migrations/" | |||
| 6 | + | gen: | |||
| 7 | + | go: | |||
| 8 | + | package: "db" | |||
| 9 | + | out: "./internal/db" | |||
| 10 | + | sql_package: "database/sql" | |||
| 11 | + | emit_json_tags: true | |||
| 12 | + | emit_empty_slices: true | |||
| 13 | + | emit_exported_queries: true | |||
| 14 | + | emit_interface: true | |||
| 15 | + | query_parameter_limit: 5⏎̸ | |||
A dumb-browser +0 -0 binary file: no text diff
binary file: no text diff
A go.mod +66 -0
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | module github.com/bnema/dumber | |||
| 2 | + | ||||
| 3 | + | go 1.25.1 | |||
| 4 | + | ||||
| 5 | + | require ( | |||
| 6 | + | github.com/fsnotify/fsnotify v1.9.0 | |||
| 7 | + | github.com/ncruces/go-sqlite3 v0.29.0 | |||
| 8 | + | github.com/spf13/cobra v1.10.1 | |||
| 9 | + | github.com/spf13/viper v1.21.0 | |||
| 10 | + | github.com/wailsapp/wails/v3 v3.0.0-alpha.28 | |||
| 11 | + | ) | |||
| 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 | |||
| 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 | |||
| 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 | |||
| 40 | + | github.com/ncruces/julianday v1.0.0 // 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 | |||
| 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 | |||
| 50 | + | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | |||
| 51 | + | github.com/spf13/afero v1.15.0 // indirect | |||
| 52 | + | github.com/spf13/cast v1.10.0 // indirect | |||
| 53 | + | github.com/spf13/pflag v1.0.10 // indirect | |||
| 54 | + | github.com/subosito/gotenv v1.6.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 | |||
| 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 | |||
| 62 | + | golang.org/x/sys v0.36.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 | |||
| 66 | + | ) | |||
A go.sum +185 -0 generated file: collapsed
A internal/cli/browse.go +189 -0
| @@ -0,0 +1,189 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "database/sql" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net/url" | |||
| 8 | + | "os" | |||
| 9 | + | "os/exec" | |||
| 10 | + | "regexp" | |||
| 11 | + | "strings" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/dumber/internal/config" | |||
| 14 | + | ||||
| 15 | + | "github.com/spf13/cobra" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // NewBrowseCmd creates the browse command | |||
| 19 | + | func NewBrowseCmd() *cobra.Command { | |||
| 20 | + | cmd := &cobra.Command{ | |||
| 21 | + | Use: "browse <url|query>", | |||
| 22 | + | Short: "Browse a URL or search query", | |||
| 23 | + | Long: `Browse a URL directly or use search shortcuts like: | |||
| 24 | + | g:golang -> Google search for "golang" | |||
| 25 | + | gh:cobra -> GitHub search for "cobra" | |||
| 26 | + | yt:tutorials -> YouTube search for "tutorials" | |||
| 27 | + | w:go language -> Wikipedia search for "go language" | |||
| 28 | + | ||||
| 29 | + | Direct URLs are also supported: | |||
| 30 | + | https://example.com | |||
| 31 | + | example.com (automatically adds https://)`, | |||
| 32 | + | Args: cobra.ExactArgs(1), | |||
| 33 | + | RunE: func(_ *cobra.Command, args []string) error { | |||
| 34 | + | cli, err := NewCLI() | |||
| 35 | + | if err != nil { | |||
| 36 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 37 | + | } | |||
| 38 | + | defer func() { | |||
| 39 | + | if closeErr := cli.Close(); closeErr != nil { | |||
| 40 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |||
| 41 | + | } | |||
| 42 | + | }() | |||
| 43 | + | ||||
| 44 | + | return browse(cli, args[0]) | |||
| 45 | + | }, | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | return cmd | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // browse handles the core browsing logic | |||
| 52 | + | func browse(cli *CLI, input string) error { | |||
| 53 | + | ctx := context.Background() | |||
| 54 | + | ||||
| 55 | + | // Parse the input to determine URL or shortcut | |||
| 56 | + | finalURL, err := parseInput(ctx, cli, input) | |||
| 57 | + | if err != nil { | |||
| 58 | + | return fmt.Errorf("failed to parse input: %w", err) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Record in history | |||
| 62 | + | if err := recordVisit(ctx, cli, finalURL, ""); err != nil { | |||
| 63 | + | // Don't fail the browse operation if history recording fails | |||
| 64 | + | fmt.Fprintf(os.Stderr, "Warning: failed to record history: %v\n", err) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Open URL using configuration | |||
| 68 | + | return openURLWithConfig(finalURL, cli.Config) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // parseInput processes user input and returns the final URL to browse | |||
| 72 | + | func parseInput(ctx context.Context, cli *CLI, input string) (string, error) { | |||
| 73 | + | // Check if it's a shortcut (format: "prefix:query") | |||
| 74 | + | if strings.Contains(input, ":") && !strings.Contains(input, "://") { | |||
| 75 | + | parts := strings.SplitN(input, ":", 2) //nolint:mnd // split on first colon only | |||
| 76 | + | if len(parts) == 2 { //nolint:mnd // expect prefix and query parts | |||
| 77 | + | shortcut := strings.TrimSpace(parts[0]) | |||
| 78 | + | query := strings.TrimSpace(parts[1]) | |||
| 79 | + | ||||
| 80 | + | if query == "" { | |||
| 81 | + | return "", fmt.Errorf("empty query for shortcut '%s'", shortcut) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // First check configuration-based shortcuts | |||
| 85 | + | if shortcutCfg, exists := cli.Config.SearchShortcuts[shortcut]; exists { | |||
| 86 | + | return fmt.Sprintf(shortcutCfg.URL, url.QueryEscape(query)), nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Fallback to database shortcuts for backward compatibility | |||
| 90 | + | shortcuts, err := cli.Queries.GetShortcuts(ctx) | |||
| 91 | + | if err != nil { | |||
| 92 | + | return "", fmt.Errorf("failed to get shortcuts: %w", err) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | for _, s := range shortcuts { | |||
| 96 | + | if s.Shortcut == shortcut { | |||
| 97 | + | return fmt.Sprintf(s.UrlTemplate, url.QueryEscape(query)), nil | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | return "", fmt.Errorf("unknown shortcut '%s'", shortcut) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Check if it's already a valid URL | |||
| 106 | + | if isValidURL(input) { | |||
| 107 | + | return input, nil | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // Try to make it a URL by adding https:// | |||
| 111 | + | if !strings.Contains(input, "://") { | |||
| 112 | + | candidate := "https://" + input | |||
| 113 | + | if isValidURL(candidate) { | |||
| 114 | + | return candidate, nil | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // If all else fails, search Google for it | |||
| 119 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", url.QueryEscape(input)), nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // isValidURL checks if a string is a valid URL | |||
| 123 | + | func isValidURL(str string) bool { | |||
| 124 | + | u, err := url.Parse(str) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return false | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // Must have a scheme and host | |||
| 130 | + | if u.Scheme == "" || u.Host == "" { | |||
| 131 | + | return false | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // Must be http or https | |||
| 135 | + | if u.Scheme != "http" && u.Scheme != "https" { | |||
| 136 | + | return false | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // Basic domain validation | |||
| 140 | + | domainRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?` + | |||
| 141 | + | `(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) | |||
| 142 | + | return domainRegex.MatchString(u.Host) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // recordVisit adds or updates a URL visit in the history | |||
| 146 | + | func recordVisit(ctx context.Context, cli *CLI, url, title string) error { | |||
| 147 | + | var sqlTitle sql.NullString | |||
| 148 | + | if title != "" { | |||
| 149 | + | sqlTitle = sql.NullString{String: title, Valid: true} | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | return cli.Queries.AddOrUpdateHistory(ctx, url, sqlTitle) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // openURL opens a URL using the configured browser | |||
| 156 | + | func openURL(url string) error { | |||
| 157 | + | return openURLWithConfig(url, nil) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 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) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // Launch our own browser in browse mode with the URL directly | |||
| 169 | + | cmd := exec.Command(executable, "browse", url) | |||
| 170 | + | ||||
| 171 | + | // Start the browser in detached mode | |||
| 172 | + | if err := cmd.Start(); err != nil { | |||
| 173 | + | return fmt.Errorf("failed to open URL in built-in browser: %w", err) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // Don't wait for the browser process to exit | |||
| 177 | + | go func() { | |||
| 178 | + | _ = cmd.Wait() | |||
| 179 | + | }() | |||
| 180 | + | ||||
| 181 | + | fmt.Printf("Opening: %s (using built-in browser)\n", url) | |||
| 182 | + | return nil | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // commandExists checks if a command is available in the system PATH | |||
| 186 | + | func commandExists(cmd string) bool { | |||
| 187 | + | _, err := exec.LookPath(cmd) | |||
| 188 | + | return err == nil | |||
| 189 | + | } | |||
A internal/cli/dmenu.go +218 -0
| @@ -0,0 +1,218 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "sort" | |||
| 9 | + | "strings" | |||
| 10 | + | ||||
| 11 | + | "github.com/spf13/cobra" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewDmenuCmd creates the dmenu command for launcher integration | |||
| 15 | + | func NewDmenuCmd() *cobra.Command { | |||
| 16 | + | cmd := &cobra.Command{ | |||
| 17 | + | Use: "dmenu", | |||
| 18 | + | Short: "Run in dmenu mode for launcher integration", | |||
| 19 | + | Long: `Run in dmenu mode to integrate with rofi, dmenu, or other launchers. | |||
| 20 | + | This mode reads from stdin and outputs selectable options to stdout. | |||
| 21 | + | ||||
| 22 | + | Usage with rofi: | |||
| 23 | + | dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select | |||
| 24 | + | ||||
| 25 | + | Usage with dmenu: | |||
| 26 | + | dumber dmenu | dmenu -p "Browse: " | dumber dmenu --select`, | |||
| 27 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |||
| 28 | + | selectFlag := cmd.Flag("select").Changed | |||
| 29 | + | ||||
| 30 | + | cli, err := NewCLI() | |||
| 31 | + | if err != nil { | |||
| 32 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 33 | + | } | |||
| 34 | + | defer func() { | |||
| 35 | + | if closeErr := cli.Close(); closeErr != nil { | |||
| 36 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |||
| 37 | + | } | |||
| 38 | + | }() | |||
| 39 | + | ||||
| 40 | + | if selectFlag { | |||
| 41 | + | return handleSelection(cli) | |||
| 42 | + | } | |||
| 43 | + | return generateOptions(cli) | |||
| 44 | + | }, | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | cmd.Flags().Bool("select", false, "Process selection from launcher (reads from stdin)") | |||
| 48 | + | return cmd | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // generateOptions outputs all available options for the launcher | |||
| 52 | + | func generateOptions(cli *CLI) error { | |||
| 53 | + | ctx := context.Background() | |||
| 54 | + | ||||
| 55 | + | const maxHistoryEntries = 50 | |||
| 56 | + | // Get history entries | |||
| 57 | + | history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return fmt.Errorf("failed to get history: %w", err) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // Get shortcuts | |||
| 63 | + | shortcuts, err := cli.Queries.GetShortcuts(ctx) | |||
| 64 | + | if err != nil { | |||
| 65 | + | return fmt.Errorf("failed to get shortcuts: %w", err) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // Generate options | |||
| 69 | + | options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1) | |||
| 70 | + | ||||
| 71 | + | // Add direct URL input option | |||
| 72 | + | options = append(options, DmenuOption{ | |||
| 73 | + | Display: "🌐 Enter URL or search query...", | |||
| 74 | + | Value: "", | |||
| 75 | + | Type: "input", | |||
| 76 | + | Description: "Type any URL or search term", | |||
| 77 | + | }) | |||
| 78 | + | ||||
| 79 | + | // Add shortcuts with examples | |||
| 80 | + | for _, shortcut := range shortcuts { | |||
| 81 | + | desc := shortcut.Description.String | |||
| 82 | + | if !shortcut.Description.Valid { | |||
| 83 | + | desc = "Custom shortcut" | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | options = append(options, DmenuOption{ | |||
| 87 | + | Display: fmt.Sprintf("🔍 %s: (%s)", shortcut.Shortcut, desc), | |||
| 88 | + | Value: shortcut.Shortcut + ":", | |||
| 89 | + | Type: "shortcut", | |||
| 90 | + | Description: desc, | |||
| 91 | + | }) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // Add history entries | |||
| 95 | + | for _, entry := range history { | |||
| 96 | + | title := entry.Url | |||
| 97 | + | if entry.Title.Valid && entry.Title.String != "" { | |||
| 98 | + | title = entry.Title.String | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | const maxDisplayLength = 80 | |||
| 102 | + | // Truncate long titles/URLs for display | |||
| 103 | + | display := truncateString(title, maxDisplayLength) | |||
| 104 | + | ||||
| 105 | + | options = append(options, DmenuOption{ | |||
| 106 | + | Display: fmt.Sprintf("🕒 %s", display), | |||
| 107 | + | Value: entry.Url, | |||
| 108 | + | Type: "history", | |||
| 109 | + | Description: entry.Url, | |||
| 110 | + | }) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // Sort options: input first, then shortcuts, then history by visit count/recency | |||
| 114 | + | sort.Slice(options, func(i, j int) bool { | |||
| 115 | + | if options[i].Type != options[j].Type { | |||
| 116 | + | // Order: input, shortcut, history | |||
| 117 | + | typeOrder := map[string]int{"input": 0, "shortcut": 1, "history": 2} //nolint:mnd | |||
| 118 | + | return typeOrder[options[i].Type] < typeOrder[options[j].Type] | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | if options[i].Type == "history" && options[j].Type == "history" { | |||
| 122 | + | // History already sorted by recency from database | |||
| 123 | + | return i < j | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | return options[i].Display < options[j].Display | |||
| 127 | + | }) | |||
| 128 | + | ||||
| 129 | + | // Output options to stdout | |||
| 130 | + | for _, option := range options { | |||
| 131 | + | fmt.Println(option.Display) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | return nil | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // handleSelection processes the user's selection from the launcher | |||
| 138 | + | func handleSelection(cli *CLI) error { | |||
| 139 | + | scanner := bufio.NewScanner(os.Stdin) | |||
| 140 | + | if !scanner.Scan() { | |||
| 141 | + | return fmt.Errorf("no input received") | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | selection := strings.TrimSpace(scanner.Text()) | |||
| 145 | + | if selection == "" { | |||
| 146 | + | return fmt.Errorf("empty selection") | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Parse the selection to get the actual input | |||
| 150 | + | input := parseSelection(selection) | |||
| 151 | + | ||||
| 152 | + | // If it's just the input prompt, read another line for the actual input | |||
| 153 | + | if input == "" || input == "Enter URL or search query..." { | |||
| 154 | + | fmt.Print("Enter URL or search query: ") | |||
| 155 | + | if !scanner.Scan() { | |||
| 156 | + | return fmt.Errorf("no input received") | |||
| 157 | + | } | |||
| 158 | + | input = strings.TrimSpace(scanner.Text()) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | if input == "" { | |||
| 162 | + | return fmt.Errorf("empty input") | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // Browse the selected/entered URL | |||
| 166 | + | return browse(cli, input) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // DmenuOption represents a selectable option in dmenu mode | |||
| 170 | + | type DmenuOption struct { | |||
| 171 | + | Display string | |||
| 172 | + | Value string | |||
| 173 | + | Type string // "input", "shortcut", "history" | |||
| 174 | + | Description string | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // parseSelection extracts the actual URL/query from a dmenu selection | |||
| 178 | + | func parseSelection(selection string) string { | |||
| 179 | + | // Remove emoji prefixes and clean up the selection | |||
| 180 | + | selection = strings.TrimSpace(selection) | |||
| 181 | + | ||||
| 182 | + | // Handle different option types | |||
| 183 | + | if strings.HasPrefix(selection, "🌐 ") { | |||
| 184 | + | // Input option selected | |||
| 185 | + | return "" | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | if strings.HasPrefix(selection, "🔍 ") { | |||
| 189 | + | // Shortcut selected - extract the shortcut prefix | |||
| 190 | + | rest := strings.TrimPrefix(selection, "🔍 ") | |||
| 191 | + | if idx := strings.Index(rest, ":"); idx > 0 { | |||
| 192 | + | return rest[:idx+1] // Return "g:" format | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | if strings.HasPrefix(selection, "🕒 ") { | |||
| 197 | + | // History entry selected - this is the display title, need to extract URL | |||
| 198 | + | // For now, we'll assume the display is the URL or close enough | |||
| 199 | + | return strings.TrimPrefix(selection, "🕒 ") | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // Fallback: return the selection as-is | |||
| 203 | + | return selection | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // truncateString truncates a string to the specified length with ellipsis | |||
| 207 | + | func truncateString(s string, maxLen int) string { | |||
| 208 | + | if len(s) <= maxLen { | |||
| 209 | + | return s | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | const minEllipsisLength = 3 | |||
| 213 | + | if maxLen <= minEllipsisLength { | |||
| 214 | + | return s[:maxLen] | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | return s[:maxLen-minEllipsisLength] + "..." | |||
| 218 | + | } | |||
A internal/cli/history.go +380 -0
| @@ -0,0 +1,380 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "database/sql" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "strconv" | |||
| 9 | + | "strings" | |||
| 10 | + | "text/tabwriter" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/spf13/cobra" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | unknownValue = "Unknown" | |||
| 18 | + | defaultHistoryLimit = 20 | |||
| 19 | + | defaultSearchLimit = 10 | |||
| 20 | + | maxURLDisplay = 50 | |||
| 21 | + | maxTitleDisplay = 40 | |||
| 22 | + | maxURLCompact = 60 | |||
| 23 | + | maxTitleCompact = 30 | |||
| 24 | + | tabSpacing = 2 | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // NewHistoryCmd creates the history command | |||
| 28 | + | func NewHistoryCmd() *cobra.Command { | |||
| 29 | + | cmd := &cobra.Command{ | |||
| 30 | + | Use: "history", | |||
| 31 | + | Short: "Manage browsing history", | |||
| 32 | + | Long: `Manage browsing history with various subcommands: | |||
| 33 | + | list - Show recent browsing history | |||
| 34 | + | search - Search through history | |||
| 35 | + | clear - Clear history (with confirmation)`, | |||
| 36 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 37 | + | // Default to list if no subcommand | |||
| 38 | + | return listHistory(cmd, args) | |||
| 39 | + | }, | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // List subcommand (also default behavior) | |||
| 43 | + | listCmd := &cobra.Command{ | |||
| 44 | + | Use: "list", | |||
| 45 | + | Short: "List recent browsing history", | |||
| 46 | + | Long: `Display recent browsing history with visit counts and timestamps.`, | |||
| 47 | + | RunE: listHistory, | |||
| 48 | + | } | |||
| 49 | + | listCmd.Flags().IntP("limit", "n", defaultHistoryLimit, "Number of history entries to show") | |||
| 50 | + | listCmd.Flags().BoolP("verbose", "v", false, "Show detailed information") | |||
| 51 | + | ||||
| 52 | + | // Search subcommand | |||
| 53 | + | searchCmd := &cobra.Command{ | |||
| 54 | + | Use: "search <query>", | |||
| 55 | + | Short: "Search through browsing history", | |||
| 56 | + | Long: `Search for URLs and titles in browsing history.`, | |||
| 57 | + | Args: cobra.ExactArgs(1), | |||
| 58 | + | RunE: searchHistory, | |||
| 59 | + | } | |||
| 60 | + | searchCmd.Flags().IntP("limit", "n", defaultSearchLimit, "Number of search results to show") | |||
| 61 | + | ||||
| 62 | + | // Clear subcommand | |||
| 63 | + | clearCmd := &cobra.Command{ | |||
| 64 | + | Use: "clear", | |||
| 65 | + | Short: "Clear browsing history", | |||
| 66 | + | Long: `Clear all browsing history. This action cannot be undone.`, | |||
| 67 | + | RunE: clearHistory, | |||
| 68 | + | } | |||
| 69 | + | clearCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") | |||
| 70 | + | ||||
| 71 | + | // Stats subcommand | |||
| 72 | + | statsCmd := &cobra.Command{ | |||
| 73 | + | Use: "stats", | |||
| 74 | + | Short: "Show history statistics", | |||
| 75 | + | Long: `Display statistics about your browsing history.`, | |||
| 76 | + | RunE: showStats, | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Add global flags | |||
| 80 | + | cmd.Flags().IntP("limit", "n", defaultHistoryLimit, "Number of history entries to show") | |||
| 81 | + | cmd.Flags().BoolP("verbose", "v", false, "Show detailed information") | |||
| 82 | + | ||||
| 83 | + | // Add subcommands | |||
| 84 | + | cmd.AddCommand(listCmd) | |||
| 85 | + | cmd.AddCommand(searchCmd) | |||
| 86 | + | cmd.AddCommand(clearCmd) | |||
| 87 | + | cmd.AddCommand(statsCmd) | |||
| 88 | + | ||||
| 89 | + | return cmd | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // listHistory displays recent browsing history | |||
| 93 | + | func listHistory(cmd *cobra.Command, _ []string) error { | |||
| 94 | + | cli, err := NewCLI() | |||
| 95 | + | if err != nil { | |||
| 96 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 97 | + | } | |||
| 98 | + | defer func() { | |||
| 99 | + | if err := cli.Close(); err != nil { | |||
| 100 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err) | |||
| 101 | + | } | |||
| 102 | + | }() | |||
| 103 | + | ||||
| 104 | + | ctx := context.Background() | |||
| 105 | + | ||||
| 106 | + | limit, _ := cmd.Flags().GetInt("limit") | |||
| 107 | + | verbose, _ := cmd.Flags().GetBool("verbose") | |||
| 108 | + | ||||
| 109 | + | history, err := cli.Queries.GetHistory(ctx, int64(limit)) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return fmt.Errorf("failed to get history: %w", err) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | if len(history) == 0 { | |||
| 115 | + | fmt.Println("No browsing history found.") | |||
| 116 | + | return nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Create tabwriter for aligned output | |||
| 120 | + | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |||
| 121 | + | defer w.Flush() | |||
| 122 | + | ||||
| 123 | + | if verbose { | |||
| 124 | + | fmt.Fprintln(w, "ID\tVISITS\tLAST VISITED\tURL\tTITLE") | |||
| 125 | + | fmt.Fprintln(w, "--\t------\t------------\t---\t-----") | |||
| 126 | + | ||||
| 127 | + | for _, entry := range history { | |||
| 128 | + | visits := "1" | |||
| 129 | + | if entry.VisitCount.Valid { | |||
| 130 | + | visits = strconv.FormatInt(entry.VisitCount.Int64, 10) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | lastVisited := unknownValue | |||
| 134 | + | if entry.LastVisited.Valid { | |||
| 135 | + | lastVisited = entry.LastVisited.Time.Format("2006-01-02 15:04") | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | title := entry.Title.String | |||
| 139 | + | if !entry.Title.Valid || title == "" { | |||
| 140 | + | title = "-" | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | url := truncateString(entry.Url, 50) | |||
| 144 | + | title = truncateString(title, 40) | |||
| 145 | + | ||||
| 146 | + | fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", | |||
| 147 | + | entry.ID, visits, lastVisited, url, title) | |||
| 148 | + | } | |||
| 149 | + | } else { | |||
| 150 | + | fmt.Fprintln(w, "VISITS\tLAST VISITED\tURL") | |||
| 151 | + | fmt.Fprintln(w, "------\t------------\t---") | |||
| 152 | + | ||||
| 153 | + | for _, entry := range history { | |||
| 154 | + | visits := "1" | |||
| 155 | + | if entry.VisitCount.Valid { | |||
| 156 | + | visits = strconv.FormatInt(entry.VisitCount.Int64, 10) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | lastVisited := unknownValue | |||
| 160 | + | if entry.LastVisited.Valid { | |||
| 161 | + | // Show relative time for recent entries | |||
| 162 | + | if time.Since(entry.LastVisited.Time) < 24*time.Hour { | |||
| 163 | + | lastVisited = entry.LastVisited.Time.Format("15:04") | |||
| 164 | + | } else { | |||
| 165 | + | lastVisited = entry.LastVisited.Time.Format("Jan 02") | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | url := entry.Url | |||
| 170 | + | if len(url) > 60 { | |||
| 171 | + | url = url[:57] + "..." | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | fmt.Fprintf(w, "%s\t%s\t%s\n", visits, lastVisited, url) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | return nil | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // searchHistory searches through browsing history | |||
| 182 | + | func searchHistory(cmd *cobra.Command, args []string) error { | |||
| 183 | + | cli, err := NewCLI() | |||
| 184 | + | if err != nil { | |||
| 185 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 186 | + | } | |||
| 187 | + | defer func() { | |||
| 188 | + | if err := cli.Close(); err != nil { | |||
| 189 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err) | |||
| 190 | + | } | |||
| 191 | + | }() | |||
| 192 | + | ||||
| 193 | + | ctx := context.Background() | |||
| 194 | + | query := args[0] | |||
| 195 | + | limit, _ := cmd.Flags().GetInt("limit") | |||
| 196 | + | ||||
| 197 | + | // Search both URL and title | |||
| 198 | + | searchQuery := sql.NullString{String: query, Valid: true} | |||
| 199 | + | results, err := cli.Queries.SearchHistory(ctx, searchQuery, searchQuery, int64(limit)) | |||
| 200 | + | if err != nil { | |||
| 201 | + | return fmt.Errorf("failed to search history: %w", err) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | if len(results) == 0 { | |||
| 205 | + | fmt.Printf("No history entries found matching '%s'.\n", query) | |||
| 206 | + | return nil | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | fmt.Printf("Found %d result(s) for '%s':\n\n", len(results), query) | |||
| 210 | + | ||||
| 211 | + | // Create tabwriter for aligned output | |||
| 212 | + | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |||
| 213 | + | defer w.Flush() | |||
| 214 | + | ||||
| 215 | + | fmt.Fprintln(w, "VISITS\tLAST VISITED\tURL\tTITLE") | |||
| 216 | + | fmt.Fprintln(w, "------\t------------\t---\t-----") | |||
| 217 | + | ||||
| 218 | + | for _, entry := range results { | |||
| 219 | + | visits := "1" | |||
| 220 | + | if entry.VisitCount.Valid { | |||
| 221 | + | visits = strconv.FormatInt(entry.VisitCount.Int64, 10) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | lastVisited := "Unknown" | |||
| 225 | + | if entry.LastVisited.Valid { | |||
| 226 | + | lastVisited = entry.LastVisited.Time.Format("Jan 02 15:04") | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | title := entry.Title.String | |||
| 230 | + | if !entry.Title.Valid || title == "" { | |||
| 231 | + | title = "-" | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | url := truncateString(entry.Url, 50) | |||
| 235 | + | title = truncateString(title, 30) | |||
| 236 | + | ||||
| 237 | + | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", visits, lastVisited, url, title) | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | return nil | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // clearHistory clears all browsing history | |||
| 244 | + | func clearHistory(cmd *cobra.Command, _ []string) error { | |||
| 245 | + | force, _ := cmd.Flags().GetBool("force") | |||
| 246 | + | ||||
| 247 | + | // Confirmation unless --force is used | |||
| 248 | + | if !force { | |||
| 249 | + | fmt.Print("This will permanently delete all browsing history. Continue? [y/N]: ") | |||
| 250 | + | var response string | |||
| 251 | + | _, _ = fmt.Scanln(&response) | |||
| 252 | + | response = strings.ToLower(strings.TrimSpace(response)) | |||
| 253 | + | ||||
| 254 | + | if response != "y" && response != "yes" { | |||
| 255 | + | fmt.Println("Operation cancelled.") | |||
| 256 | + | return nil | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | cli, err := NewCLI() | |||
| 261 | + | if err != nil { | |||
| 262 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 263 | + | } | |||
| 264 | + | defer func() { | |||
| 265 | + | if err := cli.Close(); err != nil { | |||
| 266 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err) | |||
| 267 | + | } | |||
| 268 | + | }() | |||
| 269 | + | ||||
| 270 | + | // Execute DELETE query | |||
| 271 | + | result, err := cli.DB.Exec("DELETE FROM history") | |||
| 272 | + | if err != nil { | |||
| 273 | + | return fmt.Errorf("failed to clear history: %w", err) | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | rowsAffected, err := result.RowsAffected() | |||
| 277 | + | if err != nil { | |||
| 278 | + | return fmt.Errorf("failed to get affected rows: %w", err) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | fmt.Printf("Successfully cleared %d history entries.\n", rowsAffected) | |||
| 282 | + | return nil | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // showStats displays statistics about browsing history | |||
| 286 | + | func showStats(_ *cobra.Command, _ []string) error { | |||
| 287 | + | cli, err := NewCLI() | |||
| 288 | + | if err != nil { | |||
| 289 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 290 | + | } | |||
| 291 | + | defer func() { | |||
| 292 | + | if err := cli.Close(); err != nil { | |||
| 293 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err) | |||
| 294 | + | } | |||
| 295 | + | }() | |||
| 296 | + | ||||
| 297 | + | ctx := context.Background() | |||
| 298 | + | ||||
| 299 | + | // Get total entries | |||
| 300 | + | var totalEntries int64 | |||
| 301 | + | err = cli.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM history").Scan(&totalEntries) | |||
| 302 | + | if err != nil { | |||
| 303 | + | return fmt.Errorf("failed to get total entries: %w", err) | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // Get total visits | |||
| 307 | + | var totalVisits sql.NullInt64 | |||
| 308 | + | err = cli.DB.QueryRowContext(ctx, "SELECT SUM(visit_count) FROM history").Scan(&totalVisits) | |||
| 309 | + | if err != nil { | |||
| 310 | + | return fmt.Errorf("failed to get total visits: %w", err) | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | // Get date range | |||
| 314 | + | var oldestDate, newestDate sql.NullTime | |||
| 315 | + | err = cli.DB.QueryRowContext(ctx, "SELECT MIN(created_at), MAX(last_visited) FROM history").Scan(&oldestDate, &newestDate) | |||
| 316 | + | if err != nil { | |||
| 317 | + | return fmt.Errorf("failed to get date range: %w", err) | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | // Get top domains | |||
| 321 | + | rows, err := cli.DB.QueryContext(ctx, ` | |||
| 322 | + | SELECT | |||
| 323 | + | CASE | |||
| 324 | + | WHEN url LIKE 'http://%' THEN substr(url, 8) | |||
| 325 | + | WHEN url LIKE 'https://%' THEN substr(url, 9) | |||
| 326 | + | ELSE url | |||
| 327 | + | END as domain, | |||
| 328 | + | COUNT(*) as count, | |||
| 329 | + | SUM(visit_count) as total_visits | |||
| 330 | + | FROM history | |||
| 331 | + | GROUP BY domain | |||
| 332 | + | ORDER BY total_visits DESC | |||
| 333 | + | LIMIT 5 | |||
| 334 | + | `) | |||
| 335 | + | if err != nil { | |||
| 336 | + | return fmt.Errorf("failed to get top domains: %w", err) | |||
| 337 | + | } | |||
| 338 | + | defer rows.Close() | |||
| 339 | + | ||||
| 340 | + | fmt.Println("Browsing History Statistics") | |||
| 341 | + | fmt.Println("==========================") | |||
| 342 | + | fmt.Printf("Total unique URLs: %d\n", totalEntries) | |||
| 343 | + | ||||
| 344 | + | if totalVisits.Valid { | |||
| 345 | + | fmt.Printf("Total visits: %d\n", totalVisits.Int64) | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | if oldestDate.Valid && newestDate.Valid { | |||
| 349 | + | fmt.Printf("Date range: %s to %s\n", | |||
| 350 | + | oldestDate.Time.Format("2006-01-02"), | |||
| 351 | + | newestDate.Time.Format("2006-01-02")) | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | fmt.Println("\nTop 5 domains:") | |||
| 355 | + | fmt.Println("--------------") | |||
| 356 | + | ||||
| 357 | + | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |||
| 358 | + | defer w.Flush() | |||
| 359 | + | ||||
| 360 | + | fmt.Fprintln(w, "DOMAIN\tURLs\tVISITS") | |||
| 361 | + | fmt.Fprintln(w, "------\t----\t------") | |||
| 362 | + | ||||
| 363 | + | for rows.Next() { | |||
| 364 | + | var domain string | |||
| 365 | + | var urlCount, visitCount int64 | |||
| 366 | + | ||||
| 367 | + | if err := rows.Scan(&domain, &urlCount, &visitCount); err != nil { | |||
| 368 | + | continue | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | // Extract just the domain part | |||
| 372 | + | if idx := strings.Index(domain, "/"); idx > 0 { | |||
| 373 | + | domain = domain[:idx] | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | fmt.Fprintf(w, "%s\t%d\t%d\n", domain, urlCount, visitCount) | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | return nil | |||
| 380 | + | }⏎̸ | |||
A internal/cli/root.go +225 -0
| @@ -0,0 +1,225 @@ | |||||
| 1 | + | // Package cli provides the command-line interface for dumber. | |||
| 2 | + | package cli | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "database/sql" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/config" | |||
| 11 | + | "github.com/bnema/dumber/internal/db" | |||
| 12 | + | ||||
| 13 | + | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql | |||
| 14 | + | _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite for cross-platform compatibility | |||
| 15 | + | "github.com/spf13/cobra" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // CLI holds the database connection, queries, and configuration for the CLI commands | |||
| 19 | + | type CLI struct { | |||
| 20 | + | DB *sql.DB | |||
| 21 | + | Queries *db.Queries | |||
| 22 | + | Config *config.Config | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // NewCLI creates a new CLI instance with database connection and configuration | |||
| 26 | + | func NewCLI() (*CLI, error) { | |||
| 27 | + | // Get configuration | |||
| 28 | + | cfg := config.Get() | |||
| 29 | + | ||||
| 30 | + | // Use configured database path | |||
| 31 | + | dbPath := cfg.Database.Path | |||
| 32 | + | if dbPath == "" { | |||
| 33 | + | // Fallback to default path if not configured | |||
| 34 | + | var err error | |||
| 35 | + | dbPath, err = config.GetDatabaseFile() | |||
| 36 | + | if err != nil { | |||
| 37 | + | return nil, fmt.Errorf("failed to get database path: %w", err) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Ensure database directory exists | |||
| 42 | + | if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { | |||
| 43 | + | return nil, fmt.Errorf("failed to create database directory: %w", err) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Open database connection | |||
| 47 | + | database, err := sql.Open("sqlite3", dbPath) | |||
| 48 | + | if err != nil { | |||
| 49 | + | return nil, fmt.Errorf("failed to open database: %w", err) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // Test the connection | |||
| 53 | + | if err := database.Ping(); err != nil { | |||
| 54 | + | database.Close() | |||
| 55 | + | return nil, fmt.Errorf("failed to connect to database: %w", err) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Initialize database schema with configuration-based shortcuts | |||
| 59 | + | if err := initializeDatabase(database, cfg); err != nil { | |||
| 60 | + | database.Close() | |||
| 61 | + | return nil, fmt.Errorf("failed to initialize database: %w", err) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | return &CLI{ | |||
| 65 | + | DB: database, | |||
| 66 | + | Queries: db.New(database), | |||
| 67 | + | Config: cfg, | |||
| 68 | + | }, nil | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // Close closes the database connection | |||
| 72 | + | func (c *CLI) Close() error { | |||
| 73 | + | if c.DB != nil { | |||
| 74 | + | return c.DB.Close() | |||
| 75 | + | } | |||
| 76 | + | return nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // NewRootCmd creates the root command for dumber | |||
| 80 | + | func NewRootCmd(version, commit, buildDate string) *cobra.Command { | |||
| 81 | + | rootCmd := &cobra.Command{ | |||
| 82 | + | Use: "dumber [url]", | |||
| 83 | + | Short: "A dumb browser for Wayland window managers", | |||
| 84 | + | Long: `A fast, simple browser with rofi/dmenu integration for sway and hyprland window managers.`, | |||
| 85 | + | Args: cobra.MaximumNArgs(1), | |||
| 86 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 87 | + | // Initialize CLI | |||
| 88 | + | cli, err := NewCLI() | |||
| 89 | + | if err != nil { | |||
| 90 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 91 | + | } | |||
| 92 | + | defer func() { | |||
| 93 | + | if closeErr := cli.Close(); closeErr != nil { | |||
| 94 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |||
| 95 | + | } | |||
| 96 | + | }() | |||
| 97 | + | ||||
| 98 | + | // If URL provided, browse it directly | |||
| 99 | + | if len(args) > 0 { | |||
| 100 | + | return browse(cli, args[0]) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // Otherwise show help | |||
| 104 | + | return cmd.Help() | |||
| 105 | + | }, | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // Add dmenu flag for launcher integration | |||
| 109 | + | rootCmd.Flags().Bool("dmenu", false, "Run in dmenu mode for launcher integration") | |||
| 110 | + | ||||
| 111 | + | // Version command | |||
| 112 | + | versionCmd := &cobra.Command{ | |||
| 113 | + | Use: "version", | |||
| 114 | + | Short: "Print version information", | |||
| 115 | + | Run: func(_ *cobra.Command, _ []string) { | |||
| 116 | + | fmt.Printf("dumber %s\n", version) | |||
| 117 | + | fmt.Printf("commit: %s\n", commit) | |||
| 118 | + | fmt.Printf("built: %s\n", buildDate) | |||
| 119 | + | }, | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // Init command (enhanced) | |||
| 123 | + | initCmd := &cobra.Command{ | |||
| 124 | + | Use: "init", | |||
| 125 | + | Short: "Initialize dumber database and configuration", | |||
| 126 | + | RunE: func(_ *cobra.Command, _ []string) error { | |||
| 127 | + | cli, err := NewCLI() | |||
| 128 | + | if err != nil { | |||
| 129 | + | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 130 | + | } | |||
| 131 | + | defer func() { | |||
| 132 | + | if closeErr := cli.Close(); closeErr != nil { | |||
| 133 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |||
| 134 | + | } | |||
| 135 | + | }() | |||
| 136 | + | ||||
| 137 | + | fmt.Printf("dumber %s - Initialization complete!\n", version) | |||
| 138 | + | fmt.Println("Database initialized at:", cli.Config.Database.Path) | |||
| 139 | + | ||||
| 140 | + | // Show XDG directories | |||
| 141 | + | xdgDirs, err := config.GetXDGDirs() | |||
| 142 | + | if err == nil { | |||
| 143 | + | fmt.Println("Configuration directories:") | |||
| 144 | + | fmt.Printf("- Config: %s\n", xdgDirs.ConfigHome) | |||
| 145 | + | fmt.Printf("- Data: %s\n", xdgDirs.DataHome) | |||
| 146 | + | fmt.Printf("- State: %s\n", xdgDirs.StateHome) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Show configured shortcuts | |||
| 150 | + | fmt.Println("Configured shortcuts:") | |||
| 151 | + | for shortcut, cfg := range cli.Config.SearchShortcuts { | |||
| 152 | + | fmt.Printf("- %s:%s -> %s\n", shortcut, "query", cfg.Description) | |||
| 153 | + | } | |||
| 154 | + | return nil | |||
| 155 | + | }, | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // Add subcommands | |||
| 159 | + | rootCmd.AddCommand(versionCmd) | |||
| 160 | + | rootCmd.AddCommand(initCmd) | |||
| 161 | + | rootCmd.AddCommand(NewBrowseCmd()) | |||
| 162 | + | rootCmd.AddCommand(NewDmenuCmd()) | |||
| 163 | + | rootCmd.AddCommand(NewHistoryCmd()) | |||
| 164 | + | ||||
| 165 | + | return rootCmd | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // getDatabasePath returns the path to the SQLite database file (legacy function for compatibility) | |||
| 169 | + | func getDatabasePath() (string, error) { | |||
| 170 | + | return config.GetDatabaseFile() | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // getDatabasePathOrDefault returns the database path or a default message (legacy function for compatibility) | |||
| 174 | + | func getDatabasePathOrDefault() string { | |||
| 175 | + | path, err := config.GetDatabaseFile() | |||
| 176 | + | if err != nil { | |||
| 177 | + | return "~/.local/state/dumber/history.db" | |||
| 178 | + | } | |||
| 179 | + | return path | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // initializeDatabase creates the database schema if it doesn't exist | |||
| 183 | + | func initializeDatabase(db *sql.DB, cfg *config.Config) error { | |||
| 184 | + | schema := ` | |||
| 185 | + | -- History tracking for visited URLs | |||
| 186 | + | CREATE TABLE IF NOT EXISTS history ( | |||
| 187 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| 188 | + | url TEXT NOT NULL UNIQUE, | |||
| 189 | + | title TEXT, | |||
| 190 | + | visit_count INTEGER DEFAULT 1, | |||
| 191 | + | last_visited DATETIME DEFAULT CURRENT_TIMESTAMP, | |||
| 192 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 193 | + | ); | |||
| 194 | + | ||||
| 195 | + | CREATE INDEX IF NOT EXISTS idx_history_url ON history(url); | |||
| 196 | + | CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited); | |||
| 197 | + | ||||
| 198 | + | -- URL shortcuts configuration | |||
| 199 | + | CREATE TABLE IF NOT EXISTS shortcuts ( | |||
| 200 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| 201 | + | shortcut TEXT NOT NULL UNIQUE, | |||
| 202 | + | url_template TEXT NOT NULL, | |||
| 203 | + | description TEXT, | |||
| 204 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 205 | + | ); | |||
| 206 | + | ` | |||
| 207 | + | ||||
| 208 | + | // Execute schema creation | |||
| 209 | + | if _, err := db.Exec(schema); err != nil { | |||
| 210 | + | return err | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // Insert configured shortcuts | |||
| 214 | + | for shortcut, shortcutCfg := range cfg.SearchShortcuts { | |||
| 215 | + | _, err := db.Exec( | |||
| 216 | + | "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)", | |||
| 217 | + | shortcut, shortcutCfg.URL, shortcutCfg.Description, | |||
| 218 | + | ) | |||
| 219 | + | if err != nil { | |||
| 220 | + | return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err) | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | return nil | |||
| 225 | + | } | |||
A internal/config/config.go +373 -0
| @@ -0,0 +1,373 @@ | |||||
| 1 | + | // Package config provides configuration management for dumber with Viper integration. | |||
| 2 | + | package config | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/fsnotify/fsnotify" | |||
| 14 | + | "github.com/spf13/viper" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Config represents the complete configuration for dumber. | |||
| 18 | + | type Config struct { | |||
| 19 | + | Database DatabaseConfig `mapstructure:"database" yaml:"database"` | |||
| 20 | + | History HistoryConfig `mapstructure:"history" yaml:"history"` | |||
| 21 | + | SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"` | |||
| 22 | + | Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"` | |||
| 23 | + | Logging LoggingConfig `mapstructure:"logging" yaml:"logging"` | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // DatabaseConfig holds database-related configuration. | |||
| 27 | + | type DatabaseConfig struct { | |||
| 28 | + | Path string `mapstructure:"path" yaml:"path"` | |||
| 29 | + | MaxConnections int `mapstructure:"max_connections" yaml:"max_connections"` | |||
| 30 | + | MaxIdleTime time.Duration `mapstructure:"max_idle_time" yaml:"max_idle_time"` | |||
| 31 | + | QueryTimeout time.Duration `mapstructure:"query_timeout" yaml:"query_timeout"` | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // HistoryConfig holds history-related configuration. | |||
| 35 | + | type HistoryConfig struct { | |||
| 36 | + | MaxEntries int `mapstructure:"max_entries" yaml:"max_entries"` | |||
| 37 | + | RetentionPeriodDays int `mapstructure:"retention_period_days" yaml:"retention_period_days"` | |||
| 38 | + | CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days"` | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // SearchShortcut represents a search shortcut configuration. | |||
| 42 | + | type SearchShortcut struct { | |||
| 43 | + | URL string `mapstructure:"url" yaml:"url"` | |||
| 44 | + | Description string `mapstructure:"description" yaml:"description"` | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // DmenuConfig holds dmenu/rofi integration configuration. | |||
| 48 | + | type DmenuConfig struct { | |||
| 49 | + | MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items"` | |||
| 50 | + | ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count"` | |||
| 51 | + | ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited"` | |||
| 52 | + | HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix"` | |||
| 53 | + | ShortcutPrefix string `mapstructure:"shortcut_prefix" yaml:"shortcut_prefix"` | |||
| 54 | + | URLPrefix string `mapstructure:"url_prefix" yaml:"url_prefix"` | |||
| 55 | + | DateFormat string `mapstructure:"date_format" yaml:"date_format"` | |||
| 56 | + | SortByVisitCount bool `mapstructure:"sort_by_visit_count" yaml:"sort_by_visit_count"` | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // LoggingConfig holds logging configuration. | |||
| 60 | + | type LoggingConfig struct { | |||
| 61 | + | Level string `mapstructure:"level" yaml:"level"` | |||
| 62 | + | Format string `mapstructure:"format" yaml:"format"` | |||
| 63 | + | Filename string `mapstructure:"filename" yaml:"filename"` | |||
| 64 | + | MaxSize int `mapstructure:"max_size" yaml:"max_size"` | |||
| 65 | + | MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"` | |||
| 66 | + | MaxAge int `mapstructure:"max_age" yaml:"max_age"` | |||
| 67 | + | Compress bool `mapstructure:"compress" yaml:"compress"` | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Manager handles configuration loading, watching, and reloading. | |||
| 71 | + | type Manager struct { | |||
| 72 | + | config *Config | |||
| 73 | + | viper *viper.Viper | |||
| 74 | + | mu sync.RWMutex | |||
| 75 | + | callbacks []func(*Config) | |||
| 76 | + | watching bool | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // NewManager creates a new configuration manager. | |||
| 80 | + | func NewManager() (*Manager, error) { | |||
| 81 | + | v := viper.New() | |||
| 82 | + | ||||
| 83 | + | // Configure Viper - supports yaml, json, toml automatically | |||
| 84 | + | v.SetConfigName("config") // Will find config.yaml, config.json, config.toml, etc. | |||
| 85 | + | ||||
| 86 | + | // Add config paths | |||
| 87 | + | configDir, err := GetConfigDir() | |||
| 88 | + | if err != nil { | |||
| 89 | + | return nil, fmt.Errorf("failed to get config directory: %w", err) | |||
| 90 | + | } | |||
| 91 | + | v.AddConfigPath(configDir) | |||
| 92 | + | v.AddConfigPath(".") // Current directory for development | |||
| 93 | + | ||||
| 94 | + | // Set up environment variable support | |||
| 95 | + | v.SetEnvPrefix("DUMB_BROWSER") | |||
| 96 | + | v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) | |||
| 97 | + | v.AutomaticEnv() | |||
| 98 | + | ||||
| 99 | + | // Bind specific environment variables | |||
| 100 | + | bindings := map[string]string{ | |||
| 101 | + | "database.path": "DATABASE_PATH", | |||
| 102 | + | "database.max_connections": "DATABASE_MAX_CONNECTIONS", | |||
| 103 | + | "database.max_idle_time": "DATABASE_MAX_IDLE_TIME", | |||
| 104 | + | "database.query_timeout": "DATABASE_QUERY_TIMEOUT", | |||
| 105 | + | "browser.command": "BROWSER_COMMAND", | |||
| 106 | + | "browser.timeout": "BROWSER_TIMEOUT", | |||
| 107 | + | "browser.detach_process": "BROWSER_DETACH_PROCESS", | |||
| 108 | + | "history.max_entries": "HISTORY_MAX_ENTRIES", | |||
| 109 | + | "history.retention_period": "HISTORY_RETENTION_PERIOD", | |||
| 110 | + | "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL", | |||
| 111 | + | "dmenu.max_history_items": "DMENU_MAX_HISTORY_ITEMS", | |||
| 112 | + | "dmenu.show_visit_count": "DMENU_SHOW_VISIT_COUNT", | |||
| 113 | + | "dmenu.show_last_visited": "DMENU_SHOW_LAST_VISITED", | |||
| 114 | + | "dmenu.history_prefix": "DMENU_HISTORY_PREFIX", | |||
| 115 | + | "dmenu.shortcut_prefix": "DMENU_SHORTCUT_PREFIX", | |||
| 116 | + | "dmenu.url_prefix": "DMENU_URL_PREFIX", | |||
| 117 | + | "dmenu.date_format": "DMENU_DATE_FORMAT", | |||
| 118 | + | "dmenu.sort_by_visit_count": "DMENU_SORT_BY_VISIT_COUNT", | |||
| 119 | + | "logging.level": "LOGGING_LEVEL", | |||
| 120 | + | "logging.format": "LOGGING_FORMAT", | |||
| 121 | + | "logging.filename": "LOGGING_FILENAME", | |||
| 122 | + | "logging.max_size": "LOGGING_MAX_SIZE", | |||
| 123 | + | "logging.max_backups": "LOGGING_MAX_BACKUPS", | |||
| 124 | + | "logging.max_age": "LOGGING_MAX_AGE", | |||
| 125 | + | "logging.compress": "LOGGING_COMPRESS", | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | for key, env := range bindings { | |||
| 129 | + | if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil { | |||
| 130 | + | return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | return &Manager{ | |||
| 135 | + | viper: v, | |||
| 136 | + | callbacks: make([]func(*Config), 0), | |||
| 137 | + | }, nil | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Load loads the configuration from file and environment variables. | |||
| 141 | + | func (m *Manager) Load() error { | |||
| 142 | + | m.mu.Lock() | |||
| 143 | + | defer m.mu.Unlock() | |||
| 144 | + | ||||
| 145 | + | // Ensure directories exist | |||
| 146 | + | if err := EnsureDirectories(); err != nil { | |||
| 147 | + | return fmt.Errorf("failed to ensure directories: %w", err) | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // Set defaults | |||
| 151 | + | m.setDefaults() | |||
| 152 | + | ||||
| 153 | + | // Read config file if it exists | |||
| 154 | + | if err := m.viper.ReadInConfig(); err != nil { | |||
| 155 | + | if _, ok := err.(viper.ConfigFileNotFoundError); ok { | |||
| 156 | + | // Config file not found, create default one | |||
| 157 | + | if err := m.createDefaultConfig(); err != nil { | |||
| 158 | + | return fmt.Errorf("failed to create default config: %w", err) | |||
| 159 | + | } | |||
| 160 | + | } else { | |||
| 161 | + | return fmt.Errorf("failed to read config file: %w", err) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // Unmarshal into config struct | |||
| 166 | + | config := &Config{} | |||
| 167 | + | if err := m.viper.Unmarshal(config); err != nil { | |||
| 168 | + | return fmt.Errorf("failed to unmarshal config: %w", err) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // Set database path if not specified | |||
| 172 | + | if config.Database.Path == "" { | |||
| 173 | + | dbPath, err := GetDatabaseFile() | |||
| 174 | + | if err != nil { | |||
| 175 | + | return fmt.Errorf("failed to get database path: %w", err) | |||
| 176 | + | } | |||
| 177 | + | config.Database.Path = dbPath | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | m.config = config | |||
| 181 | + | return nil | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // Get returns the current configuration (thread-safe). | |||
| 185 | + | func (m *Manager) Get() *Config { | |||
| 186 | + | m.mu.RLock() | |||
| 187 | + | defer m.mu.RUnlock() | |||
| 188 | + | ||||
| 189 | + | // Return a copy to prevent external modification | |||
| 190 | + | configCopy := *m.config | |||
| 191 | + | return &configCopy | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // Watch starts watching the config file for changes and reloads automatically. | |||
| 195 | + | func (m *Manager) Watch() error { | |||
| 196 | + | m.mu.Lock() | |||
| 197 | + | defer m.mu.Unlock() | |||
| 198 | + | ||||
| 199 | + | if m.watching { | |||
| 200 | + | return nil // Already watching | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | m.viper.WatchConfig() | |||
| 204 | + | m.viper.OnConfigChange(func(_ fsnotify.Event) { | |||
| 205 | + | // Reload config | |||
| 206 | + | if err := m.reload(); err != nil { | |||
| 207 | + | fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err) | |||
| 208 | + | return | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // Notify callbacks | |||
| 212 | + | m.mu.RLock() | |||
| 213 | + | config := m.config | |||
| 214 | + | callbacks := make([]func(*Config), len(m.callbacks)) | |||
| 215 | + | copy(callbacks, m.callbacks) | |||
| 216 | + | m.mu.RUnlock() | |||
| 217 | + | ||||
| 218 | + | for _, callback := range callbacks { | |||
| 219 | + | callback(config) | |||
| 220 | + | } | |||
| 221 | + | }) | |||
| 222 | + | ||||
| 223 | + | m.watching = true | |||
| 224 | + | return nil | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // OnConfigChange registers a callback function to be called when config changes. | |||
| 228 | + | func (m *Manager) OnConfigChange(callback func(*Config)) { | |||
| 229 | + | m.mu.Lock() | |||
| 230 | + | defer m.mu.Unlock() | |||
| 231 | + | ||||
| 232 | + | m.callbacks = append(m.callbacks, callback) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // reload reloads the configuration (internal method, must be called with lock). | |||
| 236 | + | func (m *Manager) reload() error { | |||
| 237 | + | if err := m.viper.ReadInConfig(); err != nil { | |||
| 238 | + | return err | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | config := &Config{} | |||
| 242 | + | if err := m.viper.Unmarshal(config); err != nil { | |||
| 243 | + | return err | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // Set database path if not specified | |||
| 247 | + | if config.Database.Path == "" { | |||
| 248 | + | dbPath, err := GetDatabaseFile() | |||
| 249 | + | if err != nil { | |||
| 250 | + | return fmt.Errorf("failed to get database path: %w", err) | |||
| 251 | + | } | |||
| 252 | + | config.Database.Path = dbPath | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | m.config = config | |||
| 256 | + | return nil | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // setDefaults sets default configuration values in Viper. | |||
| 260 | + | func (m *Manager) setDefaults() { | |||
| 261 | + | defaults := DefaultConfig() | |||
| 262 | + | ||||
| 263 | + | // Database defaults | |||
| 264 | + | m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections) | |||
| 265 | + | m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime) | |||
| 266 | + | m.viper.SetDefault("database.query_timeout", defaults.Database.QueryTimeout) | |||
| 267 | + | ||||
| 268 | + | // Note: Browser config removed - we build our own browser with Wails | |||
| 269 | + | ||||
| 270 | + | // History defaults | |||
| 271 | + | m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries) | |||
| 272 | + | m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays) | |||
| 273 | + | m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays) | |||
| 274 | + | ||||
| 275 | + | // Search shortcuts defaults | |||
| 276 | + | m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts) | |||
| 277 | + | ||||
| 278 | + | // Dmenu defaults | |||
| 279 | + | m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems) | |||
| 280 | + | m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount) | |||
| 281 | + | m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited) | |||
| 282 | + | m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix) | |||
| 283 | + | m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix) | |||
| 284 | + | m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix) | |||
| 285 | + | m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat) | |||
| 286 | + | m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount) | |||
| 287 | + | ||||
| 288 | + | // Logging defaults | |||
| 289 | + | m.viper.SetDefault("logging.level", defaults.Logging.Level) | |||
| 290 | + | m.viper.SetDefault("logging.format", defaults.Logging.Format) | |||
| 291 | + | m.viper.SetDefault("logging.filename", defaults.Logging.Filename) | |||
| 292 | + | m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize) | |||
| 293 | + | m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups) | |||
| 294 | + | m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge) | |||
| 295 | + | m.viper.SetDefault("logging.compress", defaults.Logging.Compress) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // createDefaultConfig creates a default configuration file. | |||
| 299 | + | func (m *Manager) createDefaultConfig() error { | |||
| 300 | + | configFile, err := GetConfigFile() | |||
| 301 | + | if err != nil { | |||
| 302 | + | return err | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // Create config directory if it doesn't exist | |||
| 306 | + | if err := os.MkdirAll(filepath.Dir(configFile), 0755); err != nil { | |||
| 307 | + | return err | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | // Get the default configuration | |||
| 311 | + | defaultConfig := DefaultConfig() | |||
| 312 | + | ||||
| 313 | + | // Marshal to JSON with proper indentation | |||
| 314 | + | configData, err := json.MarshalIndent(defaultConfig, "", " ") | |||
| 315 | + | if err != nil { | |||
| 316 | + | return fmt.Errorf("failed to marshal default config: %w", err) | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // Write JSON config file | |||
| 320 | + | if err := os.WriteFile(configFile, configData, 0644); err != nil { | |||
| 321 | + | return fmt.Errorf("failed to write config file: %w", err) | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | fmt.Printf("Created default configuration file: %s\n", configFile) | |||
| 325 | + | return nil | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | // GetConfigFile returns the path to the configuration file being used. | |||
| 329 | + | func (m *Manager) GetConfigFile() string { | |||
| 330 | + | return m.viper.ConfigFileUsed() | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | // Global configuration manager instance | |||
| 334 | + | var globalManager *Manager | |||
| 335 | + | var globalManagerOnce sync.Once | |||
| 336 | + | ||||
| 337 | + | // Init initializes the global configuration manager. | |||
| 338 | + | func Init() error { | |||
| 339 | + | var err error | |||
| 340 | + | globalManagerOnce.Do(func() { | |||
| 341 | + | globalManager, err = NewManager() | |||
| 342 | + | if err != nil { | |||
| 343 | + | return | |||
| 344 | + | } | |||
| 345 | + | err = globalManager.Load() | |||
| 346 | + | }) | |||
| 347 | + | return err | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | // Get returns the global configuration. | |||
| 351 | + | func Get() *Config { | |||
| 352 | + | if globalManager == nil { | |||
| 353 | + | // Return defaults if not initialized | |||
| 354 | + | return DefaultConfig() | |||
| 355 | + | } | |||
| 356 | + | return globalManager.Get() | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | // Watch starts watching the global configuration for changes. | |||
| 360 | + | func Watch() error { | |||
| 361 | + | if globalManager == nil { | |||
| 362 | + | return fmt.Errorf("configuration not initialized") | |||
| 363 | + | } | |||
| 364 | + | return globalManager.Watch() | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | // OnConfigChange registers a callback for global configuration changes. | |||
| 368 | + | func OnConfigChange(callback func(*Config)) { | |||
| 369 | + | if globalManager == nil { | |||
| 370 | + | return | |||
| 371 | + | } | |||
| 372 | + | globalManager.OnConfigChange(callback) | |||
| 373 | + | } | |||
A internal/config/defaults.go +107 -0
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | // Package config provides default configuration values for dumber. | |||
| 2 | + | package config | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // DefaultConfig returns the default configuration values for dumber. | |||
| 9 | + | func DefaultConfig() *Config { | |||
| 10 | + | return &Config{ | |||
| 11 | + | Database: DatabaseConfig{ | |||
| 12 | + | MaxConnections: 1, | |||
| 13 | + | MaxIdleTime: time.Minute * 5, | |||
| 14 | + | QueryTimeout: time.Second * 30, | |||
| 15 | + | }, | |||
| 16 | + | History: HistoryConfig{ | |||
| 17 | + | MaxEntries: 10000, | |||
| 18 | + | RetentionPeriodDays: 365, // 1 year | |||
| 19 | + | CleanupIntervalDays: 1, // daily cleanup | |||
| 20 | + | }, | |||
| 21 | + | SearchShortcuts: map[string]SearchShortcut{ | |||
| 22 | + | "g": { | |||
| 23 | + | URL: "https://www.google.com/search?q=%s", | |||
| 24 | + | Description: "Google search", | |||
| 25 | + | }, | |||
| 26 | + | "gh": { | |||
| 27 | + | URL: "https://github.com/search?q=%s", | |||
| 28 | + | Description: "GitHub search", | |||
| 29 | + | }, | |||
| 30 | + | "yt": { | |||
| 31 | + | URL: "https://www.youtube.com/results?search_query=%s", | |||
| 32 | + | Description: "YouTube search", | |||
| 33 | + | }, | |||
| 34 | + | "w": { | |||
| 35 | + | URL: "https://en.wikipedia.org/wiki/%s", | |||
| 36 | + | Description: "Wikipedia search", | |||
| 37 | + | }, | |||
| 38 | + | }, | |||
| 39 | + | Dmenu: DmenuConfig{ | |||
| 40 | + | MaxHistoryItems: 20, | |||
| 41 | + | ShowVisitCount: true, | |||
| 42 | + | ShowLastVisited: true, | |||
| 43 | + | HistoryPrefix: "🕒", | |||
| 44 | + | ShortcutPrefix: "🔍", | |||
| 45 | + | URLPrefix: "🌐", | |||
| 46 | + | DateFormat: "2006-01-02 15:04", | |||
| 47 | + | SortByVisitCount: true, | |||
| 48 | + | }, | |||
| 49 | + | Logging: LoggingConfig{ | |||
| 50 | + | Level: "info", | |||
| 51 | + | Format: "text", // text or json | |||
| 52 | + | Filename: "", // empty means stdout | |||
| 53 | + | MaxSize: 100, // MB | |||
| 54 | + | MaxBackups: 3, | |||
| 55 | + | MaxAge: 7, // days | |||
| 56 | + | Compress: true, | |||
| 57 | + | }, | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // GetDefaultSearchShortcuts returns the default search shortcuts. | |||
| 62 | + | func GetDefaultSearchShortcuts() map[string]SearchShortcut { | |||
| 63 | + | return map[string]SearchShortcut{ | |||
| 64 | + | "g": { | |||
| 65 | + | URL: "https://www.google.com/search?q=%s", | |||
| 66 | + | Description: "Google search", | |||
| 67 | + | }, | |||
| 68 | + | "gh": { | |||
| 69 | + | URL: "https://github.com/search?q=%s", | |||
| 70 | + | Description: "GitHub search", | |||
| 71 | + | }, | |||
| 72 | + | "yt": { | |||
| 73 | + | URL: "https://www.youtube.com/results?search_query=%s", | |||
| 74 | + | Description: "YouTube search", | |||
| 75 | + | }, | |||
| 76 | + | "w": { | |||
| 77 | + | URL: "https://en.wikipedia.org/wiki/%s", | |||
| 78 | + | Description: "Wikipedia search", | |||
| 79 | + | }, | |||
| 80 | + | "ddg": { | |||
| 81 | + | URL: "https://duckduckgo.com/?q=%s", | |||
| 82 | + | Description: "DuckDuckGo search", | |||
| 83 | + | }, | |||
| 84 | + | "so": { | |||
| 85 | + | URL: "https://stackoverflow.com/search?q=%s", | |||
| 86 | + | Description: "Stack Overflow search", | |||
| 87 | + | }, | |||
| 88 | + | "r": { | |||
| 89 | + | URL: "https://www.reddit.com/search?q=%s", | |||
| 90 | + | Description: "Reddit search", | |||
| 91 | + | }, | |||
| 92 | + | "npm": { | |||
| 93 | + | URL: "https://www.npmjs.com/search?q=%s", | |||
| 94 | + | Description: "npm package search", | |||
| 95 | + | }, | |||
| 96 | + | "go": { | |||
| 97 | + | URL: "https://pkg.go.dev/search?q=%s", | |||
| 98 | + | Description: "Go package search", | |||
| 99 | + | }, | |||
| 100 | + | "mdn": { | |||
| 101 | + | URL: "https://developer.mozilla.org/en-US/search?q=%s", | |||
| 102 | + | Description: "MDN Web Docs search", | |||
| 103 | + | }, | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Note: We build our own browser with Wails, so no external browser commands needed | |||
A internal/config/xdg.go +123 -0
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | // Package config provides XDG Base Directory specification compliance utilities. | |||
| 2 | + | package config | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const appName = "dumber" | |||
| 10 | + | ||||
| 11 | + | // XDGDirs holds the XDG Base Directory paths for the application. | |||
| 12 | + | type XDGDirs struct { | |||
| 13 | + | ConfigHome string | |||
| 14 | + | DataHome string | |||
| 15 | + | StateHome string | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // GetXDGDirs returns the XDG Base Directory paths for dumber. | |||
| 19 | + | // It follows the XDG Base Directory specification: | |||
| 20 | + | // - $XDG_CONFIG_HOME/dumber (default: ~/.config/dumber) | |||
| 21 | + | // - $XDG_DATA_HOME/dumber (default: ~/.local/share/dumber) | |||
| 22 | + | // - $XDG_STATE_HOME/dumber (default: ~/.local/state/dumber) | |||
| 23 | + | func GetXDGDirs() (*XDGDirs, error) { | |||
| 24 | + | homeDir, err := os.UserHomeDir() | |||
| 25 | + | if err != nil { | |||
| 26 | + | return nil, err | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // XDG_CONFIG_HOME | |||
| 30 | + | configHome := os.Getenv("XDG_CONFIG_HOME") | |||
| 31 | + | if configHome == "" { | |||
| 32 | + | configHome = filepath.Join(homeDir, ".config") | |||
| 33 | + | } | |||
| 34 | + | configHome = filepath.Join(configHome, appName) | |||
| 35 | + | ||||
| 36 | + | // XDG_DATA_HOME | |||
| 37 | + | dataHome := os.Getenv("XDG_DATA_HOME") | |||
| 38 | + | if dataHome == "" { | |||
| 39 | + | dataHome = filepath.Join(homeDir, ".local", "share") | |||
| 40 | + | } | |||
| 41 | + | dataHome = filepath.Join(dataHome, appName) | |||
| 42 | + | ||||
| 43 | + | // XDG_STATE_HOME | |||
| 44 | + | stateHome := os.Getenv("XDG_STATE_HOME") | |||
| 45 | + | if stateHome == "" { | |||
| 46 | + | stateHome = filepath.Join(homeDir, ".local", "state") | |||
| 47 | + | } | |||
| 48 | + | stateHome = filepath.Join(stateHome, appName) | |||
| 49 | + | ||||
| 50 | + | return &XDGDirs{ | |||
| 51 | + | ConfigHome: configHome, | |||
| 52 | + | DataHome: dataHome, | |||
| 53 | + | StateHome: stateHome, | |||
| 54 | + | }, nil | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // GetConfigDir returns the XDG config directory for dumber. | |||
| 58 | + | func GetConfigDir() (string, error) { | |||
| 59 | + | dirs, err := GetXDGDirs() | |||
| 60 | + | if err != nil { | |||
| 61 | + | return "", err | |||
| 62 | + | } | |||
| 63 | + | return dirs.ConfigHome, nil | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // GetDataDir returns the XDG data directory for dumber. | |||
| 67 | + | func GetDataDir() (string, error) { | |||
| 68 | + | dirs, err := GetXDGDirs() | |||
| 69 | + | if err != nil { | |||
| 70 | + | return "", err | |||
| 71 | + | } | |||
| 72 | + | return dirs.DataHome, nil | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // GetStateDir returns the XDG state directory for dumber. | |||
| 76 | + | func GetStateDir() (string, error) { | |||
| 77 | + | dirs, err := GetXDGDirs() | |||
| 78 | + | if err != nil { | |||
| 79 | + | return "", err | |||
| 80 | + | } | |||
| 81 | + | return dirs.StateHome, nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // GetConfigFile returns the path to the main configuration file. | |||
| 85 | + | func GetConfigFile() (string, error) { | |||
| 86 | + | configDir, err := GetConfigDir() | |||
| 87 | + | if err != nil { | |||
| 88 | + | return "", err | |||
| 89 | + | } | |||
| 90 | + | return filepath.Join(configDir, "config.json"), nil | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // GetDatabaseFile returns the path to the database file in the state directory. | |||
| 94 | + | func GetDatabaseFile() (string, error) { | |||
| 95 | + | stateDir, err := GetStateDir() | |||
| 96 | + | if err != nil { | |||
| 97 | + | return "", err | |||
| 98 | + | } | |||
| 99 | + | return filepath.Join(stateDir, "history.db"), nil | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // EnsureDirectories creates the XDG directories if they don't exist. | |||
| 103 | + | func EnsureDirectories() error { | |||
| 104 | + | dirs, err := GetXDGDirs() | |||
| 105 | + | if err != nil { | |||
| 106 | + | return err | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Create all directories with proper permissions | |||
| 110 | + | directories := []string{ | |||
| 111 | + | dirs.ConfigHome, | |||
| 112 | + | dirs.DataHome, | |||
| 113 | + | dirs.StateHome, | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | for _, dir := range directories { | |||
| 117 | + | if err := os.MkdirAll(dir, 0755); err != nil { | |||
| 118 | + | return err | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | return nil | |||
| 123 | + | } | |||
A internal/db/db.go +31 -0
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | // Code generated by sqlc. DO NOT EDIT. | |||
| 2 | + | // versions: | |||
| 3 | + | // sqlc v1.30.0 | |||
| 4 | + | ||||
| 5 | + | package db | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "database/sql" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type DBTX interface { | |||
| 13 | + | ExecContext(context.Context, string, ...interface{}) (sql.Result, error) | |||
| 14 | + | PrepareContext(context.Context, string) (*sql.Stmt, error) | |||
| 15 | + | QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) | |||
| 16 | + | QueryRowContext(context.Context, string, ...interface{}) *sql.Row | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func New(db DBTX) *Queries { | |||
| 20 | + | return &Queries{db: db} | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | type Queries struct { | |||
| 24 | + | db DBTX | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (q *Queries) WithTx(tx *sql.Tx) *Queries { | |||
| 28 | + | return &Queries{ | |||
| 29 | + | db: tx, | |||
| 30 | + | } | |||
| 31 | + | } | |||
A internal/db/history.sql.go +136 -0
| @@ -0,0 +1,136 @@ | |||||
| 1 | + | // Code generated by sqlc. DO NOT EDIT. | |||
| 2 | + | // versions: | |||
| 3 | + | // sqlc v1.30.0 | |||
| 4 | + | // source: history.sql | |||
| 5 | + | ||||
| 6 | + | package db | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 9 | + | "context" | |||
| 10 | + | "database/sql" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const AddOrUpdateHistory = `-- name: AddOrUpdateHistory :exec | |||
| 14 | + | INSERT INTO history (url, title) | |||
| 15 | + | VALUES (?, ?) | |||
| 16 | + | ON CONFLICT(url) | |||
| 17 | + | DO UPDATE SET | |||
| 18 | + | visit_count = visit_count + 1, | |||
| 19 | + | last_visited = CURRENT_TIMESTAMP, | |||
| 20 | + | title = EXCLUDED.title | |||
| 21 | + | ` | |||
| 22 | + | ||||
| 23 | + | func (q *Queries) AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error { | |||
| 24 | + | _, err := q.db.ExecContext(ctx, AddOrUpdateHistory, url, title) | |||
| 25 | + | return err | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | const GetHistory = `-- name: GetHistory :many | |||
| 29 | + | SELECT id, url, title, visit_count, last_visited, created_at | |||
| 30 | + | FROM history | |||
| 31 | + | ORDER BY last_visited DESC | |||
| 32 | + | LIMIT ? | |||
| 33 | + | ` | |||
| 34 | + | ||||
| 35 | + | func (q *Queries) GetHistory(ctx context.Context, limit int64) ([]History, error) { | |||
| 36 | + | rows, err := q.db.QueryContext(ctx, GetHistory, limit) | |||
| 37 | + | if err != nil { | |||
| 38 | + | return nil, err | |||
| 39 | + | } | |||
| 40 | + | defer rows.Close() | |||
| 41 | + | items := []History{} | |||
| 42 | + | for rows.Next() { | |||
| 43 | + | var i History | |||
| 44 | + | if err := rows.Scan( | |||
| 45 | + | &i.ID, | |||
| 46 | + | &i.Url, | |||
| 47 | + | &i.Title, | |||
| 48 | + | &i.VisitCount, | |||
| 49 | + | &i.LastVisited, | |||
| 50 | + | &i.CreatedAt, | |||
| 51 | + | ); err != nil { | |||
| 52 | + | return nil, err | |||
| 53 | + | } | |||
| 54 | + | items = append(items, i) | |||
| 55 | + | } | |||
| 56 | + | if err := rows.Close(); err != nil { | |||
| 57 | + | return nil, err | |||
| 58 | + | } | |||
| 59 | + | if err := rows.Err(); err != nil { | |||
| 60 | + | return nil, err | |||
| 61 | + | } | |||
| 62 | + | return items, nil | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | const GetShortcuts = `-- name: GetShortcuts :many | |||
| 66 | + | SELECT id, shortcut, url_template, description, created_at | |||
| 67 | + | FROM shortcuts | |||
| 68 | + | ORDER BY shortcut | |||
| 69 | + | ` | |||
| 70 | + | ||||
| 71 | + | func (q *Queries) GetShortcuts(ctx context.Context) ([]Shortcut, error) { | |||
| 72 | + | rows, err := q.db.QueryContext(ctx, GetShortcuts) | |||
| 73 | + | if err != nil { | |||
| 74 | + | return nil, err | |||
| 75 | + | } | |||
| 76 | + | defer rows.Close() | |||
| 77 | + | items := []Shortcut{} | |||
| 78 | + | for rows.Next() { | |||
| 79 | + | var i Shortcut | |||
| 80 | + | if err := rows.Scan( | |||
| 81 | + | &i.ID, | |||
| 82 | + | &i.Shortcut, | |||
| 83 | + | &i.UrlTemplate, | |||
| 84 | + | &i.Description, | |||
| 85 | + | &i.CreatedAt, | |||
| 86 | + | ); err != nil { | |||
| 87 | + | return nil, err | |||
| 88 | + | } | |||
| 89 | + | items = append(items, i) | |||
| 90 | + | } | |||
| 91 | + | if err := rows.Close(); err != nil { | |||
| 92 | + | return nil, err | |||
| 93 | + | } | |||
| 94 | + | if err := rows.Err(); err != nil { | |||
| 95 | + | return nil, err | |||
| 96 | + | } | |||
| 97 | + | return items, nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | const SearchHistory = `-- name: SearchHistory :many | |||
| 101 | + | SELECT id, url, title, visit_count, last_visited, created_at | |||
| 102 | + | FROM history | |||
| 103 | + | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | |||
| 104 | + | ORDER BY visit_count DESC, last_visited DESC | |||
| 105 | + | LIMIT ? | |||
| 106 | + | ` | |||
| 107 | + | ||||
| 108 | + | func (q *Queries) SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) { | |||
| 109 | + | rows, err := q.db.QueryContext(ctx, SearchHistory, column1, column2, limit) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return nil, err | |||
| 112 | + | } | |||
| 113 | + | defer rows.Close() | |||
| 114 | + | items := []History{} | |||
| 115 | + | for rows.Next() { | |||
| 116 | + | var i History | |||
| 117 | + | if err := rows.Scan( | |||
| 118 | + | &i.ID, | |||
| 119 | + | &i.Url, | |||
| 120 | + | &i.Title, | |||
| 121 | + | &i.VisitCount, | |||
| 122 | + | &i.LastVisited, | |||
| 123 | + | &i.CreatedAt, | |||
| 124 | + | ); err != nil { | |||
| 125 | + | return nil, err | |||
| 126 | + | } | |||
| 127 | + | items = append(items, i) | |||
| 128 | + | } | |||
| 129 | + | if err := rows.Close(); err != nil { | |||
| 130 | + | return nil, err | |||
| 131 | + | } | |||
| 132 | + | if err := rows.Err(); err != nil { | |||
| 133 | + | return nil, err | |||
| 134 | + | } | |||
| 135 | + | return items, nil | |||
| 136 | + | } | |||
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 | + | }⏎̸ | |||
A internal/db/models.go +26 -0
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | // Code generated by sqlc. DO NOT EDIT. | |||
| 2 | + | // versions: | |||
| 3 | + | // sqlc v1.30.0 | |||
| 4 | + | ||||
| 5 | + | package db | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "database/sql" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type History struct { | |||
| 12 | + | ID int64 `json:"id"` | |||
| 13 | + | Url string `json:"url"` | |||
| 14 | + | Title sql.NullString `json:"title"` | |||
| 15 | + | VisitCount sql.NullInt64 `json:"visit_count"` | |||
| 16 | + | LastVisited sql.NullTime `json:"last_visited"` | |||
| 17 | + | CreatedAt sql.NullTime `json:"created_at"` | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | type Shortcut struct { | |||
| 21 | + | ID int64 `json:"id"` | |||
| 22 | + | Shortcut string `json:"shortcut"` | |||
| 23 | + | UrlTemplate string `json:"url_template"` | |||
| 24 | + | Description sql.NullString `json:"description"` | |||
| 25 | + | CreatedAt sql.NullTime `json:"created_at"` | |||
| 26 | + | } | |||
A internal/db/querier.go +19 -0
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | // Code generated by sqlc. DO NOT EDIT. | |||
| 2 | + | // versions: | |||
| 3 | + | // sqlc v1.30.0 | |||
| 4 | + | ||||
| 5 | + | package db | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "database/sql" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type Querier interface { | |||
| 13 | + | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | |||
| 14 | + | GetHistory(ctx context.Context, limit int64) ([]History, error) | |||
| 15 | + | GetShortcuts(ctx context.Context) ([]Shortcut, error) | |||
| 16 | + | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | var _ Querier = (*Queries)(nil) | |||
A internal/db/queries/history.sql +26 -0
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | -- name: GetHistory :many | |||
| 2 | + | SELECT id, url, title, visit_count, last_visited, created_at | |||
| 3 | + | FROM history | |||
| 4 | + | ORDER BY last_visited DESC | |||
| 5 | + | LIMIT ?; | |||
| 6 | + | ||||
| 7 | + | -- name: AddOrUpdateHistory :exec | |||
| 8 | + | INSERT INTO history (url, title) | |||
| 9 | + | VALUES (?, ?) | |||
| 10 | + | ON CONFLICT(url) | |||
| 11 | + | DO UPDATE SET | |||
| 12 | + | visit_count = visit_count + 1, | |||
| 13 | + | last_visited = CURRENT_TIMESTAMP, | |||
| 14 | + | title = EXCLUDED.title; | |||
| 15 | + | ||||
| 16 | + | -- name: SearchHistory :many | |||
| 17 | + | SELECT id, url, title, visit_count, last_visited, created_at | |||
| 18 | + | FROM history | |||
| 19 | + | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | |||
| 20 | + | ORDER BY visit_count DESC, last_visited DESC | |||
| 21 | + | LIMIT ?; | |||
| 22 | + | ||||
| 23 | + | -- name: GetShortcuts :many | |||
| 24 | + | SELECT id, shortcut, url_template, description, created_at | |||
| 25 | + | FROM shortcuts | |||
| 26 | + | ORDER BY shortcut;⏎̸ | |||
A internal/parser/fuzzy.go +471 -0
| @@ -0,0 +1,471 @@ | |||||
| 1 | + | package parser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "database/sql" | |||
| 5 | + | "math" | |||
| 6 | + | "sort" | |||
| 7 | + | "strings" | |||
| 8 | + | "time" | |||
| 9 | + | "unicode/utf8" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/db" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // FuzzyMatcher implements efficient fuzzy string matching algorithms. | |||
| 15 | + | type FuzzyMatcher struct { | |||
| 16 | + | config *FuzzyConfig | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // NewFuzzyMatcher creates a new FuzzyMatcher with the given configuration. | |||
| 20 | + | func NewFuzzyMatcher(config *FuzzyConfig) *FuzzyMatcher { | |||
| 21 | + | return &FuzzyMatcher{ | |||
| 22 | + | config: config, | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // SearchHistory performs fuzzy search on history entries and returns ranked matches. | |||
| 27 | + | func (fm *FuzzyMatcher) SearchHistory(query string, history []*db.History) []FuzzyMatch { | |||
| 28 | + | if len(history) == 0 || query == "" { | |||
| 29 | + | return nil | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | query = strings.ToLower(strings.TrimSpace(query)) | |||
| 33 | + | matches := make([]FuzzyMatch, 0) | |||
| 34 | + | ||||
| 35 | + | for _, entry := range history { | |||
| 36 | + | match := fm.matchHistoryEntry(query, entry) | |||
| 37 | + | if match.Score >= fm.config.MinSimilarityThreshold { | |||
| 38 | + | matches = append(matches, match) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Sort by score (descending) | |||
| 43 | + | sort.Slice(matches, func(i, j int) bool { | |||
| 44 | + | return matches[i].Score > matches[j].Score | |||
| 45 | + | }) | |||
| 46 | + | ||||
| 47 | + | // Limit results | |||
| 48 | + | if len(matches) > fm.config.MaxResults { | |||
| 49 | + | matches = matches[:fm.config.MaxResults] | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | return matches | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // matchHistoryEntry calculates fuzzy match score for a history entry. | |||
| 56 | + | func (fm *FuzzyMatcher) matchHistoryEntry(query string, entry *db.History) FuzzyMatch { | |||
| 57 | + | match := FuzzyMatch{ | |||
| 58 | + | HistoryEntry: entry, | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | url := strings.ToLower(entry.Url) | |||
| 62 | + | title := "" | |||
| 63 | + | if entry.Title.Valid { | |||
| 64 | + | title = strings.ToLower(entry.Title.String) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Calculate URL similarity | |||
| 68 | + | match.URLScore = fm.calculateSimilarity(query, url) | |||
| 69 | + | urlSubstring := fm.substringMatch(query, url) | |||
| 70 | + | if urlSubstring > match.URLScore { | |||
| 71 | + | match.URLScore = urlSubstring | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // Calculate title similarity | |||
| 75 | + | if title != "" { | |||
| 76 | + | match.TitleScore = fm.calculateSimilarity(query, title) | |||
| 77 | + | titleSubstring := fm.substringMatch(query, title) | |||
| 78 | + | if titleSubstring > match.TitleScore { | |||
| 79 | + | match.TitleScore = titleSubstring | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // Calculate recency score | |||
| 84 | + | match.RecencyScore = fm.calculateRecencyScore(entry.LastVisited) | |||
| 85 | + | ||||
| 86 | + | // Calculate visit count score | |||
| 87 | + | match.VisitScore = fm.calculateVisitScore(entry.VisitCount) | |||
| 88 | + | ||||
| 89 | + | // Determine matched field | |||
| 90 | + | if match.URLScore >= match.TitleScore { | |||
| 91 | + | match.MatchedField = "url" | |||
| 92 | + | if match.URLScore > 0 && match.TitleScore > 0 { | |||
| 93 | + | match.MatchedField = "both" | |||
| 94 | + | } | |||
| 95 | + | } else { | |||
| 96 | + | match.MatchedField = "title" | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // Calculate weighted final score | |||
| 100 | + | match.Score = fm.config.URLWeight*match.URLScore + | |||
| 101 | + | fm.config.TitleWeight*match.TitleScore + | |||
| 102 | + | fm.config.RecencyWeight*match.RecencyScore + | |||
| 103 | + | fm.config.VisitWeight*match.VisitScore | |||
| 104 | + | ||||
| 105 | + | return match | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // calculateSimilarity computes similarity using Jaro-Winkler distance. | |||
| 109 | + | func (fm *FuzzyMatcher) calculateSimilarity(s1, s2 string) float64 { | |||
| 110 | + | if s1 == s2 { | |||
| 111 | + | return 1.0 | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | if len(s1) == 0 || len(s2) == 0 { | |||
| 115 | + | return 0.0 | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // Use Jaro-Winkler distance for better results | |||
| 119 | + | return fm.jaroWinklerSimilarity(s1, s2) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // substringMatch calculates substring match score with position weighting. | |||
| 123 | + | func (fm *FuzzyMatcher) substringMatch(query, text string) float64 { | |||
| 124 | + | if query == "" || text == "" { | |||
| 125 | + | return 0.0 | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // Check for exact substring match | |||
| 129 | + | index := strings.Index(text, query) | |||
| 130 | + | if index == -1 { | |||
| 131 | + | return 0.0 | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // Base score for substring match | |||
| 135 | + | baseScore := float64(len(query)) / float64(len(text)) | |||
| 136 | + | ||||
| 137 | + | // Boost score if match is at the beginning | |||
| 138 | + | positionWeight := 1.0 | |||
| 139 | + | if index == 0 { | |||
| 140 | + | positionWeight = 1.5 | |||
| 141 | + | } else if index < len(text)/3 { | |||
| 142 | + | positionWeight = 1.2 | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | score := baseScore * positionWeight | |||
| 146 | + | if score > 1.0 { | |||
| 147 | + | score = 1.0 | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | return score | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // jaroWinklerSimilarity calculates Jaro-Winkler similarity between two strings. | |||
| 154 | + | func (fm *FuzzyMatcher) jaroWinklerSimilarity(s1, s2 string) float64 { | |||
| 155 | + | jaroSim := fm.jaroSimilarity(s1, s2) | |||
| 156 | + | ||||
| 157 | + | if jaroSim < 0.7 { | |||
| 158 | + | return jaroSim | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // Calculate common prefix length (up to 4 characters) | |||
| 162 | + | prefixLength := 0 | |||
| 163 | + | maxPrefix := 4 | |||
| 164 | + | minLen := len(s1) | |||
| 165 | + | if len(s2) < minLen { | |||
| 166 | + | minLen = len(s2) | |||
| 167 | + | } | |||
| 168 | + | if maxPrefix > minLen { | |||
| 169 | + | maxPrefix = minLen | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | for i := 0; i < maxPrefix; i++ { | |||
| 173 | + | if s1[i] == s2[i] { | |||
| 174 | + | prefixLength++ | |||
| 175 | + | } else { | |||
| 176 | + | break | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // Winkler modification | |||
| 181 | + | return jaroSim + (0.1 * float64(prefixLength) * (1 - jaroSim)) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // jaroSimilarity calculates Jaro similarity between two strings. | |||
| 185 | + | func (fm *FuzzyMatcher) jaroSimilarity(s1, s2 string) float64 { | |||
| 186 | + | len1 := utf8.RuneCountInString(s1) | |||
| 187 | + | len2 := utf8.RuneCountInString(s2) | |||
| 188 | + | ||||
| 189 | + | if len1 == 0 && len2 == 0 { | |||
| 190 | + | return 1.0 | |||
| 191 | + | } | |||
| 192 | + | if len1 == 0 || len2 == 0 { | |||
| 193 | + | return 0.0 | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // Convert to rune slices for proper Unicode handling | |||
| 197 | + | runes1 := []rune(s1) | |||
| 198 | + | runes2 := []rune(s2) | |||
| 199 | + | ||||
| 200 | + | // Calculate match window | |||
| 201 | + | matchWindow := (maxInt(len1, len2) / 2) - 1 | |||
| 202 | + | if matchWindow < 0 { | |||
| 203 | + | matchWindow = 0 | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // Track matches | |||
| 207 | + | matches1 := make([]bool, len1) | |||
| 208 | + | matches2 := make([]bool, len2) | |||
| 209 | + | matchCount := 0 | |||
| 210 | + | ||||
| 211 | + | // Find matches | |||
| 212 | + | for i := 0; i < len1; i++ { | |||
| 213 | + | start := maxInt(0, i-matchWindow) | |||
| 214 | + | end := minInt(i+matchWindow+1, len2) | |||
| 215 | + | ||||
| 216 | + | for j := start; j < end; j++ { | |||
| 217 | + | if matches2[j] || runes1[i] != runes2[j] { | |||
| 218 | + | continue | |||
| 219 | + | } | |||
| 220 | + | matches1[i] = true | |||
| 221 | + | matches2[j] = true | |||
| 222 | + | matchCount++ | |||
| 223 | + | break | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | if matchCount == 0 { | |||
| 228 | + | return 0.0 | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // Count transpositions | |||
| 232 | + | transpositions := 0 | |||
| 233 | + | k := 0 | |||
| 234 | + | for i := 0; i < len1; i++ { | |||
| 235 | + | if !matches1[i] { | |||
| 236 | + | continue | |||
| 237 | + | } | |||
| 238 | + | for !matches2[k] { | |||
| 239 | + | k++ | |||
| 240 | + | } | |||
| 241 | + | if runes1[i] != runes2[k] { | |||
| 242 | + | transpositions++ | |||
| 243 | + | } | |||
| 244 | + | k++ | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // Calculate Jaro similarity | |||
| 248 | + | return (float64(matchCount)/float64(len1) + | |||
| 249 | + | float64(matchCount)/float64(len2) + | |||
| 250 | + | float64(matchCount-transpositions/2)/float64(matchCount)) / 3.0 | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | // calculateRecencyScore calculates score based on how recently the entry was visited. | |||
| 254 | + | func (fm *FuzzyMatcher) calculateRecencyScore(lastVisited sql.NullTime) float64 { | |||
| 255 | + | if !lastVisited.Valid { | |||
| 256 | + | return 0.0 | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | now := time.Now() | |||
| 260 | + | daysSince := now.Sub(lastVisited.Time).Hours() / 24 | |||
| 261 | + | ||||
| 262 | + | // Exponential decay based on days since last visit | |||
| 263 | + | decayRate := float64(fm.config.RecencyDecayDays) | |||
| 264 | + | score := math.Exp(-daysSince / decayRate) | |||
| 265 | + | ||||
| 266 | + | return score | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | // calculateVisitScore calculates score based on visit count with logarithmic scaling. | |||
| 270 | + | func (fm *FuzzyMatcher) calculateVisitScore(visitCount sql.NullInt64) float64 { | |||
| 271 | + | if !visitCount.Valid || visitCount.Int64 <= 0 { | |||
| 272 | + | return 0.0 | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // Logarithmic scaling to prevent highly visited sites from dominating | |||
| 276 | + | score := math.Log1p(float64(visitCount.Int64)) / math.Log1p(1000) // Normalize to max 1000 visits | |||
| 277 | + | if score > 1.0 { | |||
| 278 | + | score = 1.0 | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | return score | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // LevenshteinDistance calculates the Levenshtein distance between two strings. | |||
| 285 | + | func (fm *FuzzyMatcher) LevenshteinDistance(s1, s2 string) int { | |||
| 286 | + | runes1 := []rune(s1) | |||
| 287 | + | runes2 := []rune(s2) | |||
| 288 | + | ||||
| 289 | + | len1 := len(runes1) | |||
| 290 | + | len2 := len(runes2) | |||
| 291 | + | ||||
| 292 | + | // Create a matrix to store distances | |||
| 293 | + | matrix := make([][]int, len1+1) | |||
| 294 | + | for i := range matrix { | |||
| 295 | + | matrix[i] = make([]int, len2+1) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // Initialize first row and column | |||
| 299 | + | for i := 0; i <= len1; i++ { | |||
| 300 | + | matrix[i][0] = i | |||
| 301 | + | } | |||
| 302 | + | for j := 0; j <= len2; j++ { | |||
| 303 | + | matrix[0][j] = j | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // Fill the matrix | |||
| 307 | + | for i := 1; i <= len1; i++ { | |||
| 308 | + | for j := 1; j <= len2; j++ { | |||
| 309 | + | cost := 1 | |||
| 310 | + | if runes1[i-1] == runes2[j-1] { | |||
| 311 | + | cost = 0 | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | deletion := matrix[i-1][j] + 1 | |||
| 315 | + | insertion := matrix[i][j-1] + 1 | |||
| 316 | + | substitution := matrix[i-1][j-1] + cost | |||
| 317 | + | ||||
| 318 | + | matrix[i][j] = minInt(deletion, minInt(insertion, substitution)) | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | return matrix[len1][len2] | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | // LevenshteinSimilarity converts Levenshtein distance to similarity score (0.0-1.0). | |||
| 326 | + | func (fm *FuzzyMatcher) LevenshteinSimilarity(s1, s2 string) float64 { | |||
| 327 | + | if s1 == s2 { | |||
| 328 | + | return 1.0 | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | distance := fm.LevenshteinDistance(s1, s2) | |||
| 332 | + | maxLen := maxInt(utf8.RuneCountInString(s1), utf8.RuneCountInString(s2)) | |||
| 333 | + | ||||
| 334 | + | if maxLen == 0 { | |||
| 335 | + | return 1.0 | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | return 1.0 - float64(distance)/float64(maxLen) | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | // TokenizedMatch performs token-based fuzzy matching for better phrase matching. | |||
| 342 | + | func (fm *FuzzyMatcher) TokenizedMatch(query, text string) float64 { | |||
| 343 | + | queryTokens := strings.Fields(strings.ToLower(query)) | |||
| 344 | + | textTokens := strings.Fields(strings.ToLower(text)) | |||
| 345 | + | ||||
| 346 | + | if len(queryTokens) == 0 || len(textTokens) == 0 { | |||
| 347 | + | return 0.0 | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | totalScore := 0.0 | |||
| 351 | + | matchedTokens := 0 | |||
| 352 | + | ||||
| 353 | + | for _, queryToken := range queryTokens { | |||
| 354 | + | bestScore := 0.0 | |||
| 355 | + | for _, textToken := range textTokens { | |||
| 356 | + | score := fm.calculateSimilarity(queryToken, textToken) | |||
| 357 | + | if score > bestScore { | |||
| 358 | + | bestScore = score | |||
| 359 | + | } | |||
| 360 | + | } | |||
| 361 | + | if bestScore >= 0.5 { // Threshold for token match | |||
| 362 | + | totalScore += bestScore | |||
| 363 | + | matchedTokens++ | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | if matchedTokens == 0 { | |||
| 368 | + | return 0.0 | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | // Average score weighted by matched token ratio | |||
| 372 | + | avgScore := totalScore / float64(matchedTokens) | |||
| 373 | + | tokenRatio := float64(matchedTokens) / float64(len(queryTokens)) | |||
| 374 | + | ||||
| 375 | + | return avgScore * tokenRatio | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | // RankMatches re-ranks fuzzy matches based on additional criteria. | |||
| 379 | + | func (fm *FuzzyMatcher) RankMatches(matches []FuzzyMatch, query string) []FuzzyMatch { | |||
| 380 | + | if len(matches) == 0 { | |||
| 381 | + | return matches | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | // Apply additional ranking factors | |||
| 385 | + | for i := range matches { | |||
| 386 | + | match := &matches[i] | |||
| 387 | + | ||||
| 388 | + | // Boost exact matches | |||
| 389 | + | if strings.Contains(strings.ToLower(match.HistoryEntry.Url), strings.ToLower(query)) { | |||
| 390 | + | match.Score *= 1.2 | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | // Boost domain matches | |||
| 394 | + | if fm.isDomainMatch(query, match.HistoryEntry.Url) { | |||
| 395 | + | match.Score *= 1.15 | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | // Boost short URLs (likely more relevant) | |||
| 399 | + | if len(match.HistoryEntry.Url) < 50 { | |||
| 400 | + | match.Score *= 1.05 | |||
| 401 | + | } | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | // Re-sort by updated scores | |||
| 405 | + | sort.Slice(matches, func(i, j int) bool { | |||
| 406 | + | return matches[i].Score > matches[j].Score | |||
| 407 | + | }) | |||
| 408 | + | ||||
| 409 | + | return matches | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | // isDomainMatch checks if the query matches the domain of the URL. | |||
| 413 | + | func (fm *FuzzyMatcher) isDomainMatch(query, url string) bool { | |||
| 414 | + | query = strings.ToLower(strings.TrimSpace(query)) | |||
| 415 | + | ||||
| 416 | + | // Extract domain from URL | |||
| 417 | + | domain := url | |||
| 418 | + | if strings.HasPrefix(url, "http://") { | |||
| 419 | + | domain = url[7:] | |||
| 420 | + | } else if strings.HasPrefix(url, "https://") { | |||
| 421 | + | domain = url[8:] | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | // Remove path | |||
| 425 | + | if idx := strings.Index(domain, "/"); idx != -1 { | |||
| 426 | + | domain = domain[:idx] | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | // Remove port | |||
| 430 | + | if idx := strings.Index(domain, ":"); idx != -1 { | |||
| 431 | + | domain = domain[:idx] | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | domain = strings.ToLower(domain) | |||
| 435 | + | ||||
| 436 | + | // Check for exact domain match | |||
| 437 | + | if domain == query { | |||
| 438 | + | return true | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | // Check if query is part of domain (e.g., "github" in "github.com" or "www.github.com") | |||
| 442 | + | if strings.HasSuffix(domain, "."+query) || strings.HasPrefix(domain, query+".") { | |||
| 443 | + | return true | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | // Check if query matches a significant part of domain (not just a substring) | |||
| 447 | + | // Split domain by dots and check if any part matches query | |||
| 448 | + | domainParts := strings.Split(domain, ".") | |||
| 449 | + | for _, part := range domainParts { | |||
| 450 | + | if part == query { | |||
| 451 | + | return true | |||
| 452 | + | } | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | return false | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | // Helper functions | |||
| 459 | + | func minInt(a, b int) int { | |||
| 460 | + | if a < b { | |||
| 461 | + | return a | |||
| 462 | + | } | |||
| 463 | + | return b | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | func maxInt(a, b int) int { | |||
| 467 | + | if a > b { | |||
| 468 | + | return a | |||
| 469 | + | } | |||
| 470 | + | return b | |||
| 471 | + | } | |||
A internal/parser/fuzzy_test.go +520 -0
| @@ -0,0 +1,520 @@ | |||||
| 1 | + | package parser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "database/sql" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/db" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestFuzzyMatcher_JaroWinklerSimilarity(t *testing.T) { | |||
| 12 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 13 | + | ||||
| 14 | + | tests := []struct { | |||
| 15 | + | s1 string | |||
| 16 | + | s2 string | |||
| 17 | + | expected float64 | |||
| 18 | + | delta float64 // Acceptable difference for float comparison | |||
| 19 | + | }{ | |||
| 20 | + | {"", "", 1.0, 0.0}, | |||
| 21 | + | {"", "test", 0.0, 0.0}, | |||
| 22 | + | {"test", "", 0.0, 0.0}, | |||
| 23 | + | {"test", "test", 1.0, 0.0}, | |||
| 24 | + | {"github", "github", 1.0, 0.0}, | |||
| 25 | + | {"github", "gihub", 0.95, 0.05}, // High similarity | |||
| 26 | + | {"github", "bitbucket", 0.62, 0.15}, // Lower similarity | |||
| 27 | + | {"stackoverflow", "stack", 0.87, 0.05}, // Partial match | |||
| 28 | + | {"google", "goggle", 0.9, 0.1}, // Single char difference | |||
| 29 | + | {"programming", "programing", 0.95, 0.05}, // Missing char | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | for _, tt := range tests { | |||
| 33 | + | t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) { | |||
| 34 | + | result := fuzzyMatcher.jaroWinklerSimilarity(tt.s1, tt.s2) | |||
| 35 | + | ||||
| 36 | + | if result < 0.0 || result > 1.0 { | |||
| 37 | + | t.Errorf("jaroWinklerSimilarity() = %v, should be between 0.0 and 1.0", result) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | diff := result - tt.expected | |||
| 41 | + | if diff < 0 { | |||
| 42 | + | diff = -diff | |||
| 43 | + | } | |||
| 44 | + | if diff > tt.delta { | |||
| 45 | + | t.Errorf("jaroWinklerSimilarity(%q, %q) = %v, want %v ±%v", tt.s1, tt.s2, result, tt.expected, tt.delta) | |||
| 46 | + | } | |||
| 47 | + | }) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestFuzzyMatcher_LevenshteinDistance(t *testing.T) { | |||
| 52 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 53 | + | ||||
| 54 | + | tests := []struct { | |||
| 55 | + | s1 string | |||
| 56 | + | s2 string | |||
| 57 | + | expected int | |||
| 58 | + | }{ | |||
| 59 | + | {"", "", 0}, | |||
| 60 | + | {"", "test", 4}, | |||
| 61 | + | {"test", "", 4}, | |||
| 62 | + | {"test", "test", 0}, | |||
| 63 | + | {"github", "github", 0}, | |||
| 64 | + | {"github", "gihub", 1}, // 1 deletion | |||
| 65 | + | {"cat", "bat", 1}, // 1 substitution | |||
| 66 | + | {"kitten", "sitting", 3}, // Classic example | |||
| 67 | + | {"programming", "programing", 1}, // 1 deletion | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | for _, tt := range tests { | |||
| 71 | + | t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) { | |||
| 72 | + | result := fuzzyMatcher.LevenshteinDistance(tt.s1, tt.s2) | |||
| 73 | + | if result != tt.expected { | |||
| 74 | + | t.Errorf("LevenshteinDistance(%q, %q) = %v, want %v", tt.s1, tt.s2, result, tt.expected) | |||
| 75 | + | } | |||
| 76 | + | }) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestFuzzyMatcher_LevenshteinSimilarity(t *testing.T) { | |||
| 81 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 82 | + | ||||
| 83 | + | tests := []struct { | |||
| 84 | + | s1 string | |||
| 85 | + | s2 string | |||
| 86 | + | expected float64 | |||
| 87 | + | delta float64 | |||
| 88 | + | }{ | |||
| 89 | + | {"test", "test", 1.0, 0.0}, | |||
| 90 | + | {"", "", 1.0, 0.0}, | |||
| 91 | + | {"github", "gihub", 0.83, 0.05}, // 1 error out of 6 chars | |||
| 92 | + | {"cat", "bat", 0.67, 0.05}, // 1 error out of 3 chars | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | for _, tt := range tests { | |||
| 96 | + | t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) { | |||
| 97 | + | result := fuzzyMatcher.LevenshteinSimilarity(tt.s1, tt.s2) | |||
| 98 | + | ||||
| 99 | + | if result < 0.0 || result > 1.0 { | |||
| 100 | + | t.Errorf("LevenshteinSimilarity() = %v, should be between 0.0 and 1.0", result) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | diff := result - tt.expected | |||
| 104 | + | if diff < 0 { | |||
| 105 | + | diff = -diff | |||
| 106 | + | } | |||
| 107 | + | if diff > tt.delta { | |||
| 108 | + | t.Errorf("LevenshteinSimilarity(%q, %q) = %v, want %v ±%v", tt.s1, tt.s2, result, tt.expected, tt.delta) | |||
| 109 | + | } | |||
| 110 | + | }) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestFuzzyMatcher_SubstringMatch(t *testing.T) { | |||
| 115 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 116 | + | ||||
| 117 | + | tests := []struct { | |||
| 118 | + | query string | |||
| 119 | + | text string | |||
| 120 | + | expected float64 | |||
| 121 | + | delta float64 | |||
| 122 | + | }{ | |||
| 123 | + | {"", "test", 0.0, 0.0}, | |||
| 124 | + | {"test", "", 0.0, 0.0}, | |||
| 125 | + | {"github", "github.com", 0.9, 0.1}, // Full match at start gets boost | |||
| 126 | + | {"hub", "github.com", 0.3, 0.1}, // Partial match | |||
| 127 | + | {"git", "github.com", 0.45, 0.1}, // Match at beginning gets boost | |||
| 128 | + | {"com", "github.com", 0.3, 0.1}, // Match at end | |||
| 129 | + | {"xyz", "github.com", 0.0, 0.0}, // No match | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | for _, tt := range tests { | |||
| 133 | + | t.Run(tt.query+"_in_"+tt.text, func(t *testing.T) { | |||
| 134 | + | result := fuzzyMatcher.substringMatch(tt.query, tt.text) | |||
| 135 | + | ||||
| 136 | + | if result < 0.0 || result > 1.0 { | |||
| 137 | + | t.Errorf("substringMatch() = %v, should be between 0.0 and 1.0", result) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | diff := result - tt.expected | |||
| 141 | + | if diff < 0 { | |||
| 142 | + | diff = -diff | |||
| 143 | + | } | |||
| 144 | + | if diff > tt.delta { | |||
| 145 | + | t.Errorf("substringMatch(%q, %q) = %v, want %v ±%v", tt.query, tt.text, result, tt.expected, tt.delta) | |||
| 146 | + | } | |||
| 147 | + | }) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestFuzzyMatcher_TokenizedMatch(t *testing.T) { | |||
| 152 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 153 | + | ||||
| 154 | + | tests := []struct { | |||
| 155 | + | query string | |||
| 156 | + | text string | |||
| 157 | + | expected float64 | |||
| 158 | + | delta float64 | |||
| 159 | + | }{ | |||
| 160 | + | {"go programming", "The Go Programming Language", 0.8, 0.2}, | |||
| 161 | + | {"stack overflow", "Stack Overflow - Where Developers Learn", 0.8, 0.2}, | |||
| 162 | + | {"github repo", "GitHub Repository", 0.94, 0.1}, | |||
| 163 | + | {"missing words", "Something completely different", 0.34, 0.1}, | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | for _, tt := range tests { | |||
| 167 | + | t.Run(tt.query+"_vs_"+tt.text, func(t *testing.T) { | |||
| 168 | + | result := fuzzyMatcher.TokenizedMatch(tt.query, tt.text) | |||
| 169 | + | ||||
| 170 | + | if result < 0.0 || result > 1.0 { | |||
| 171 | + | t.Errorf("TokenizedMatch() = %v, should be between 0.0 and 1.0", result) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | diff := result - tt.expected | |||
| 175 | + | if diff < 0 { | |||
| 176 | + | diff = -diff | |||
| 177 | + | } | |||
| 178 | + | if diff > tt.delta { | |||
| 179 | + | t.Errorf("TokenizedMatch(%q, %q) = %v, want %v ±%v", tt.query, tt.text, result, tt.expected, tt.delta) | |||
| 180 | + | } | |||
| 181 | + | }) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func TestFuzzyMatcher_SearchHistory(t *testing.T) { | |||
| 186 | + | config := DefaultFuzzyConfig() | |||
| 187 | + | config.MinSimilarityThreshold = 0.3 | |||
| 188 | + | config.MaxResults = 10 | |||
| 189 | + | fuzzyMatcher := NewFuzzyMatcher(config) | |||
| 190 | + | ||||
| 191 | + | now := time.Now() | |||
| 192 | + | history := []*db.History{ | |||
| 193 | + | { | |||
| 194 | + | ID: 1, | |||
| 195 | + | Url: "https://github.com", | |||
| 196 | + | Title: sql.NullString{String: "GitHub", Valid: true}, | |||
| 197 | + | VisitCount: sql.NullInt64{Int64: 25, Valid: true}, | |||
| 198 | + | LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true}, | |||
| 199 | + | }, | |||
| 200 | + | { | |||
| 201 | + | ID: 2, | |||
| 202 | + | Url: "https://stackoverflow.com", | |||
| 203 | + | Title: sql.NullString{String: "Stack Overflow", Valid: true}, | |||
| 204 | + | VisitCount: sql.NullInt64{Int64: 15, Valid: true}, | |||
| 205 | + | LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true}, | |||
| 206 | + | }, | |||
| 207 | + | { | |||
| 208 | + | ID: 3, | |||
| 209 | + | Url: "https://golang.org", | |||
| 210 | + | Title: sql.NullString{String: "The Go Programming Language", Valid: true}, | |||
| 211 | + | VisitCount: sql.NullInt64{Int64: 8, Valid: true}, | |||
| 212 | + | LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true}, | |||
| 213 | + | }, | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | tests := []struct { | |||
| 217 | + | name string | |||
| 218 | + | query string | |||
| 219 | + | expectMatches int | |||
| 220 | + | expectFirst string // Expected first match URL | |||
| 221 | + | }{ | |||
| 222 | + | { | |||
| 223 | + | name: "Exact domain match", | |||
| 224 | + | query: "github", | |||
| 225 | + | expectMatches: 1, | |||
| 226 | + | expectFirst: "https://github.com", | |||
| 227 | + | }, | |||
| 228 | + | { | |||
| 229 | + | name: "Partial title match", | |||
| 230 | + | query: "programming", | |||
| 231 | + | expectMatches: 1, | |||
| 232 | + | expectFirst: "https://golang.org", | |||
| 233 | + | }, | |||
| 234 | + | { | |||
| 235 | + | name: "Multiple matches", | |||
| 236 | + | query: "o", // Should match github.com, stackoverflow.com, golang.org | |||
| 237 | + | expectMatches: 3, | |||
| 238 | + | }, | |||
| 239 | + | { | |||
| 240 | + | name: "No matches", | |||
| 241 | + | query: "nonexistent", | |||
| 242 | + | expectMatches: 0, | |||
| 243 | + | }, | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | for _, tt := range tests { | |||
| 247 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 248 | + | matches := fuzzyMatcher.SearchHistory(tt.query, history) | |||
| 249 | + | ||||
| 250 | + | if len(matches) < tt.expectMatches { | |||
| 251 | + | t.Errorf("SearchHistory() returned %d matches, want >= %d", len(matches), tt.expectMatches) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | if len(matches) > 0 && tt.expectFirst != "" { | |||
| 255 | + | if matches[0].HistoryEntry.Url != tt.expectFirst { | |||
| 256 | + | t.Errorf("SearchHistory() first match = %q, want %q", matches[0].HistoryEntry.Url, tt.expectFirst) | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // Verify scores are in descending order | |||
| 261 | + | for i := 1; i < len(matches); i++ { | |||
| 262 | + | if matches[i].Score > matches[i-1].Score { | |||
| 263 | + | t.Errorf("SearchHistory() results not sorted by score: %f > %f", matches[i].Score, matches[i-1].Score) | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // Verify all scores meet threshold | |||
| 268 | + | for i, match := range matches { | |||
| 269 | + | if match.Score < config.MinSimilarityThreshold { | |||
| 270 | + | t.Errorf("SearchHistory() match %d score %f below threshold %f", i, match.Score, config.MinSimilarityThreshold) | |||
| 271 | + | } | |||
| 272 | + | } | |||
| 273 | + | }) | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func TestFuzzyMatcher_CalculateRecencyScore(t *testing.T) { | |||
| 278 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 279 | + | now := time.Now() | |||
| 280 | + | ||||
| 281 | + | tests := []struct { | |||
| 282 | + | name string | |||
| 283 | + | lastVisit sql.NullTime | |||
| 284 | + | expected float64 | |||
| 285 | + | delta float64 | |||
| 286 | + | }{ | |||
| 287 | + | { | |||
| 288 | + | name: "Invalid time", | |||
| 289 | + | lastVisit: sql.NullTime{Valid: false}, | |||
| 290 | + | expected: 0.0, | |||
| 291 | + | delta: 0.0, | |||
| 292 | + | }, | |||
| 293 | + | { | |||
| 294 | + | name: "Recent visit (1 hour ago)", | |||
| 295 | + | lastVisit: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true}, | |||
| 296 | + | expected: 0.95, | |||
| 297 | + | delta: 0.1, | |||
| 298 | + | }, | |||
| 299 | + | { | |||
| 300 | + | name: "Old visit (30 days ago)", | |||
| 301 | + | lastVisit: sql.NullTime{Time: now.Add(-30 * 24 * time.Hour), Valid: true}, | |||
| 302 | + | expected: 0.37, | |||
| 303 | + | delta: 0.1, | |||
| 304 | + | }, | |||
| 305 | + | { | |||
| 306 | + | name: "Very old visit (90 days ago)", | |||
| 307 | + | lastVisit: sql.NullTime{Time: now.Add(-90 * 24 * time.Hour), Valid: true}, | |||
| 308 | + | expected: 0.05, | |||
| 309 | + | delta: 0.05, | |||
| 310 | + | }, | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | for _, tt := range tests { | |||
| 314 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 315 | + | result := fuzzyMatcher.calculateRecencyScore(tt.lastVisit) | |||
| 316 | + | ||||
| 317 | + | if result < 0.0 || result > 1.0 { | |||
| 318 | + | t.Errorf("calculateRecencyScore() = %v, should be between 0.0 and 1.0", result) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | diff := result - tt.expected | |||
| 322 | + | if diff < 0 { | |||
| 323 | + | diff = -diff | |||
| 324 | + | } | |||
| 325 | + | if diff > tt.delta { | |||
| 326 | + | t.Errorf("calculateRecencyScore() = %v, want %v ±%v", result, tt.expected, tt.delta) | |||
| 327 | + | } | |||
| 328 | + | }) | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | func TestFuzzyMatcher_CalculateVisitScore(t *testing.T) { | |||
| 333 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 334 | + | ||||
| 335 | + | tests := []struct { | |||
| 336 | + | name string | |||
| 337 | + | visitCount sql.NullInt64 | |||
| 338 | + | expected float64 | |||
| 339 | + | delta float64 | |||
| 340 | + | }{ | |||
| 341 | + | { | |||
| 342 | + | name: "Invalid count", | |||
| 343 | + | visitCount: sql.NullInt64{Valid: false}, | |||
| 344 | + | expected: 0.0, | |||
| 345 | + | delta: 0.0, | |||
| 346 | + | }, | |||
| 347 | + | { | |||
| 348 | + | name: "Zero visits", | |||
| 349 | + | visitCount: sql.NullInt64{Int64: 0, Valid: true}, | |||
| 350 | + | expected: 0.0, | |||
| 351 | + | delta: 0.0, | |||
| 352 | + | }, | |||
| 353 | + | { | |||
| 354 | + | name: "Single visit", | |||
| 355 | + | visitCount: sql.NullInt64{Int64: 1, Valid: true}, | |||
| 356 | + | expected: 0.1, | |||
| 357 | + | delta: 0.05, | |||
| 358 | + | }, | |||
| 359 | + | { | |||
| 360 | + | name: "Many visits", | |||
| 361 | + | visitCount: sql.NullInt64{Int64: 100, Valid: true}, | |||
| 362 | + | expected: 0.67, | |||
| 363 | + | delta: 0.1, | |||
| 364 | + | }, | |||
| 365 | + | { | |||
| 366 | + | name: "Very many visits", | |||
| 367 | + | visitCount: sql.NullInt64{Int64: 1000, Valid: true}, | |||
| 368 | + | expected: 1.0, | |||
| 369 | + | delta: 0.01, | |||
| 370 | + | }, | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | for _, tt := range tests { | |||
| 374 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 375 | + | result := fuzzyMatcher.calculateVisitScore(tt.visitCount) | |||
| 376 | + | ||||
| 377 | + | if result < 0.0 || result > 1.0 { | |||
| 378 | + | t.Errorf("calculateVisitScore() = %v, should be between 0.0 and 1.0", result) | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | diff := result - tt.expected | |||
| 382 | + | if diff < 0 { | |||
| 383 | + | diff = -diff | |||
| 384 | + | } | |||
| 385 | + | if diff > tt.delta { | |||
| 386 | + | t.Errorf("calculateVisitScore() = %v, want %v ±%v", result, tt.expected, tt.delta) | |||
| 387 | + | } | |||
| 388 | + | }) | |||
| 389 | + | } | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | func TestFuzzyMatcher_IsDomainMatch(t *testing.T) { | |||
| 393 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 394 | + | ||||
| 395 | + | tests := []struct { | |||
| 396 | + | query string | |||
| 397 | + | url string | |||
| 398 | + | expected bool | |||
| 399 | + | }{ | |||
| 400 | + | {"github", "https://github.com", true}, | |||
| 401 | + | {"github", "https://www.github.com", true}, | |||
| 402 | + | {"github", "https://api.github.com", true}, | |||
| 403 | + | {"github", "https://stackoverflow.com", false}, | |||
| 404 | + | {"stackoverflow", "https://stackoverflow.com/questions", true}, | |||
| 405 | + | {"google", "https://www.google.com/search", true}, | |||
| 406 | + | {"localhost", "http://localhost:8080", true}, | |||
| 407 | + | {"example", "https://example.org", true}, // Matches domain part | |||
| 408 | + | {"sub.example", "https://sub.example.com", true}, | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | for _, tt := range tests { | |||
| 412 | + | t.Run(tt.query+"_vs_"+tt.url, func(t *testing.T) { | |||
| 413 | + | result := fuzzyMatcher.isDomainMatch(tt.query, tt.url) | |||
| 414 | + | if result != tt.expected { | |||
| 415 | + | t.Errorf("isDomainMatch(%q, %q) = %v, want %v", tt.query, tt.url, result, tt.expected) | |||
| 416 | + | } | |||
| 417 | + | }) | |||
| 418 | + | } | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | func TestFuzzyMatcher_RankMatches(t *testing.T) { | |||
| 422 | + | config := DefaultFuzzyConfig() | |||
| 423 | + | fuzzyMatcher := NewFuzzyMatcher(config) | |||
| 424 | + | ||||
| 425 | + | now := time.Now() | |||
| 426 | + | matches := []FuzzyMatch{ | |||
| 427 | + | { | |||
| 428 | + | HistoryEntry: &db.History{ | |||
| 429 | + | Url: "https://example.com/very/long/path/that/makes/url/longer", | |||
| 430 | + | VisitCount: sql.NullInt64{Int64: 5, Valid: true}, | |||
| 431 | + | LastVisited: sql.NullTime{Time: now.Add(-10 * time.Hour), Valid: true}, | |||
| 432 | + | }, | |||
| 433 | + | Score: 0.5, | |||
| 434 | + | }, | |||
| 435 | + | { | |||
| 436 | + | HistoryEntry: &db.History{ | |||
| 437 | + | Url: "https://github.com", | |||
| 438 | + | VisitCount: sql.NullInt64{Int64: 25, Valid: true}, | |||
| 439 | + | LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true}, | |||
| 440 | + | }, | |||
| 441 | + | Score: 0.7, | |||
| 442 | + | }, | |||
| 443 | + | { | |||
| 444 | + | HistoryEntry: &db.History{ | |||
| 445 | + | Url: "https://test.com", | |||
| 446 | + | VisitCount: sql.NullInt64{Int64: 1, Valid: true}, | |||
| 447 | + | LastVisited: sql.NullTime{Time: now.Add(-24 * time.Hour), Valid: true}, | |||
| 448 | + | }, | |||
| 449 | + | Score: 0.6, | |||
| 450 | + | }, | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | rankedMatches := fuzzyMatcher.RankMatches(matches, "github") | |||
| 454 | + | ||||
| 455 | + | // Verify the matches are still sorted by score (descending) | |||
| 456 | + | for i := 1; i < len(rankedMatches); i++ { | |||
| 457 | + | if rankedMatches[i].Score > rankedMatches[i-1].Score { | |||
| 458 | + | t.Errorf("RankMatches() results not sorted: %f > %f", rankedMatches[i].Score, rankedMatches[i-1].Score) | |||
| 459 | + | } | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | // The github.com match should get a boost and likely be first | |||
| 463 | + | githubFound := false | |||
| 464 | + | for i, match := range rankedMatches { | |||
| 465 | + | if match.HistoryEntry.Url == "https://github.com" { | |||
| 466 | + | githubFound = true | |||
| 467 | + | // Should be boosted due to domain match | |||
| 468 | + | if i > 0 && rankedMatches[0].HistoryEntry.Url != "https://github.com" { | |||
| 469 | + | // It's possible another match scored higher even after boost | |||
| 470 | + | t.Logf("GitHub match at position %d with score %f", i, match.Score) | |||
| 471 | + | } | |||
| 472 | + | break | |||
| 473 | + | } | |||
| 474 | + | } | |||
| 475 | + | ||||
| 476 | + | if !githubFound { | |||
| 477 | + | t.Errorf("RankMatches() didn't preserve GitHub match") | |||
| 478 | + | } | |||
| 479 | + | } | |||
| 480 | + | ||||
| 481 | + | func BenchmarkFuzzyMatcher_JaroWinklerSimilarity(b *testing.B) { | |||
| 482 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 483 | + | ||||
| 484 | + | b.ResetTimer() | |||
| 485 | + | for i := 0; i < b.N; i++ { | |||
| 486 | + | fuzzyMatcher.jaroWinklerSimilarity("github", "gihub") | |||
| 487 | + | } | |||
| 488 | + | } | |||
| 489 | + | ||||
| 490 | + | func BenchmarkFuzzyMatcher_LevenshteinDistance(b *testing.B) { | |||
| 491 | + | fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig()) | |||
| 492 | + | ||||
| 493 | + | b.ResetTimer() | |||
| 494 | + | for i := 0; i < b.N; i++ { | |||
| 495 | + | fuzzyMatcher.LevenshteinDistance("programming", "programing") | |||
| 496 | + | } | |||
| 497 | + | } | |||
| 498 | + | ||||
| 499 | + | func BenchmarkFuzzyMatcher_SearchHistory(b *testing.B) { | |||
| 500 | + | config := DefaultFuzzyConfig() | |||
| 501 | + | fuzzyMatcher := NewFuzzyMatcher(config) | |||
| 502 | + | ||||
| 503 | + | now := time.Now() | |||
| 504 | + | // Create larger history set for more realistic benchmark | |||
| 505 | + | history := make([]*db.History, 100) | |||
| 506 | + | for i := 0; i < 100; i++ { | |||
| 507 | + | history[i] = &db.History{ | |||
| 508 | + | ID: int64(i + 1), | |||
| 509 | + | Url: "https://example" + string(rune(i%26+'a')) + ".com", | |||
| 510 | + | Title: sql.NullString{String: "Test Site " + string(rune(i%26+'A')), Valid: true}, | |||
| 511 | + | VisitCount: sql.NullInt64{Int64: int64(i % 50), Valid: true}, | |||
| 512 | + | LastVisited: sql.NullTime{Time: now.Add(-time.Duration(i) * time.Hour), Valid: true}, | |||
| 513 | + | } | |||
| 514 | + | } | |||
| 515 | + | ||||
| 516 | + | b.ResetTimer() | |||
| 517 | + | for i := 0; i < b.N; i++ { | |||
| 518 | + | fuzzyMatcher.SearchHistory("example", history) | |||
| 519 | + | } | |||
| 520 | + | } | |||
A internal/parser/history.go +407 -0
| @@ -0,0 +1,407 @@ | |||||
| 1 | + | package parser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/db" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // HistorySearcher provides history search integration with fuzzy matching. | |||
| 12 | + | type HistorySearcher struct { | |||
| 13 | + | provider HistoryProvider | |||
| 14 | + | fuzzyMatcher *FuzzyMatcher | |||
| 15 | + | fuzzyConfig *FuzzyConfig | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // NewHistorySearcher creates a new HistorySearcher. | |||
| 19 | + | func NewHistorySearcher(provider HistoryProvider, fuzzyConfig *FuzzyConfig) *HistorySearcher { | |||
| 20 | + | if fuzzyConfig == nil { | |||
| 21 | + | fuzzyConfig = DefaultFuzzyConfig() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | return &HistorySearcher{ | |||
| 25 | + | provider: provider, | |||
| 26 | + | fuzzyMatcher: NewFuzzyMatcher(fuzzyConfig), | |||
| 27 | + | fuzzyConfig: fuzzyConfig, | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // SearchWithFuzzy performs fuzzy search on history entries. | |||
| 32 | + | func (hs *HistorySearcher) SearchWithFuzzy(query string, options ...SearchOption) (*HistorySearchResult, error) { | |||
| 33 | + | if query == "" { | |||
| 34 | + | return &HistorySearchResult{}, nil | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Apply search options | |||
| 38 | + | searchConfig := &SearchConfig{ | |||
| 39 | + | Limit: hs.fuzzyConfig.MaxResults, | |||
| 40 | + | MinSimilarityThreshold: hs.fuzzyConfig.MinSimilarityThreshold, | |||
| 41 | + | IncludeExactMatches: true, | |||
| 42 | + | IncludeFuzzyMatches: true, | |||
| 43 | + | SortByRelevance: true, | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | for _, opt := range options { | |||
| 47 | + | opt(searchConfig) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | startTime := time.Now() | |||
| 51 | + | ||||
| 52 | + | // Get all history for fuzzy search | |||
| 53 | + | allHistory, err := hs.provider.GetAllHistory() | |||
| 54 | + | if err != nil { | |||
| 55 | + | return nil, fmt.Errorf("failed to retrieve history: %w", err) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | result := &HistorySearchResult{ | |||
| 59 | + | Query: query, | |||
| 60 | + | TotalEntries: len(allHistory), | |||
| 61 | + | SearchTime: 0, // Will be set below | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // Perform exact search first if requested | |||
| 65 | + | if searchConfig.IncludeExactMatches { | |||
| 66 | + | exactMatches, err := hs.provider.SearchHistory(query, searchConfig.Limit) | |||
| 67 | + | if err == nil && len(exactMatches) > 0 { | |||
| 68 | + | // Convert to FuzzyMatch format with perfect scores | |||
| 69 | + | for _, entry := range exactMatches { | |||
| 70 | + | match := FuzzyMatch{ | |||
| 71 | + | HistoryEntry: entry, | |||
| 72 | + | Score: 1.0, | |||
| 73 | + | URLScore: 1.0, | |||
| 74 | + | TitleScore: 1.0, | |||
| 75 | + | RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited), | |||
| 76 | + | VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount), | |||
| 77 | + | MatchedField: "exact", | |||
| 78 | + | } | |||
| 79 | + | result.ExactMatches = append(result.ExactMatches, match) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // Perform fuzzy search if requested | |||
| 85 | + | if searchConfig.IncludeFuzzyMatches { | |||
| 86 | + | // Create temporary config with search options | |||
| 87 | + | tempConfig := *hs.fuzzyConfig | |||
| 88 | + | tempConfig.MinSimilarityThreshold = searchConfig.MinSimilarityThreshold | |||
| 89 | + | tempConfig.MaxResults = searchConfig.Limit | |||
| 90 | + | ||||
| 91 | + | tempMatcher := NewFuzzyMatcher(&tempConfig) | |||
| 92 | + | fuzzyMatches := tempMatcher.SearchHistory(query, allHistory) | |||
| 93 | + | ||||
| 94 | + | // Filter out matches that are already in exact matches | |||
| 95 | + | exactURLs := make(map[string]bool) | |||
| 96 | + | for _, exact := range result.ExactMatches { | |||
| 97 | + | exactURLs[exact.HistoryEntry.Url] = true | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | for _, match := range fuzzyMatches { | |||
| 101 | + | if !exactURLs[match.HistoryEntry.Url] { | |||
| 102 | + | result.FuzzyMatches = append(result.FuzzyMatches, match) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Combine and sort results if requested | |||
| 108 | + | if searchConfig.SortByRelevance { | |||
| 109 | + | result.RankedMatches = hs.combineAndRankMatches(result.ExactMatches, result.FuzzyMatches, query) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Limit final results | |||
| 113 | + | if len(result.RankedMatches) > searchConfig.Limit { | |||
| 114 | + | result.RankedMatches = result.RankedMatches[:searchConfig.Limit] | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | result.MatchCount = len(result.RankedMatches) | |||
| 118 | + | result.SearchTime = time.Since(startTime) | |||
| 119 | + | ||||
| 120 | + | return result, nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // SearchByDomain searches history entries by domain with fuzzy matching. | |||
| 124 | + | func (hs *HistorySearcher) SearchByDomain(domain string, options ...SearchOption) (*HistorySearchResult, error) { | |||
| 125 | + | // Get all history entries | |||
| 126 | + | allHistory, err := hs.provider.GetAllHistory() | |||
| 127 | + | if err != nil { | |||
| 128 | + | return nil, fmt.Errorf("failed to retrieve history: %w", err) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // Filter by domain and apply fuzzy matching | |||
| 132 | + | domainEntries := make([]*db.History, 0) | |||
| 133 | + | domain = strings.ToLower(domain) | |||
| 134 | + | ||||
| 135 | + | for _, entry := range allHistory { | |||
| 136 | + | entryDomain := hs.extractDomain(entry.Url) | |||
| 137 | + | if strings.Contains(strings.ToLower(entryDomain), domain) { | |||
| 138 | + | domainEntries = append(domainEntries, entry) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // If we have domain matches, rank them by fuzzy score | |||
| 143 | + | matches := make([]FuzzyMatch, 0) | |||
| 144 | + | for _, entry := range domainEntries { | |||
| 145 | + | match := hs.fuzzyMatcher.matchHistoryEntry(domain, entry) | |||
| 146 | + | matches = append(matches, match) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Sort by score | |||
| 150 | + | matches = hs.fuzzyMatcher.RankMatches(matches, domain) | |||
| 151 | + | ||||
| 152 | + | return &HistorySearchResult{ | |||
| 153 | + | Query: domain, | |||
| 154 | + | TotalEntries: len(allHistory), | |||
| 155 | + | MatchCount: len(matches), | |||
| 156 | + | RankedMatches: matches, | |||
| 157 | + | SearchTime: time.Since(time.Now()), | |||
| 158 | + | }, nil | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // SearchByTimeRange searches history entries within a time range with fuzzy matching. | |||
| 162 | + | func (hs *HistorySearcher) SearchByTimeRange(query string, startTime, endTime time.Time) (*HistorySearchResult, error) { | |||
| 163 | + | // Get all history entries | |||
| 164 | + | allHistory, err := hs.provider.GetAllHistory() | |||
| 165 | + | if err != nil { | |||
| 166 | + | return nil, fmt.Errorf("failed to retrieve history: %w", err) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // Filter by time range | |||
| 170 | + | filteredHistory := make([]*db.History, 0) | |||
| 171 | + | for _, entry := range allHistory { | |||
| 172 | + | if entry.LastVisited.Valid { | |||
| 173 | + | visitTime := entry.LastVisited.Time | |||
| 174 | + | if visitTime.After(startTime) && visitTime.Before(endTime) { | |||
| 175 | + | filteredHistory = append(filteredHistory, entry) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // Perform fuzzy search on filtered entries | |||
| 181 | + | matches := hs.fuzzyMatcher.SearchHistory(query, filteredHistory) | |||
| 182 | + | ||||
| 183 | + | return &HistorySearchResult{ | |||
| 184 | + | Query: query, | |||
| 185 | + | TotalEntries: len(filteredHistory), | |||
| 186 | + | MatchCount: len(matches), | |||
| 187 | + | RankedMatches: matches, | |||
| 188 | + | SearchTime: time.Since(time.Now()), | |||
| 189 | + | }, nil | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // GetTopVisited returns the most visited sites with optional fuzzy filtering. | |||
| 193 | + | func (hs *HistorySearcher) GetTopVisited(limit int, filterQuery string) ([]FuzzyMatch, error) { | |||
| 194 | + | allHistory, err := hs.provider.GetAllHistory() | |||
| 195 | + | if err != nil { | |||
| 196 | + | return nil, fmt.Errorf("failed to retrieve history: %w", err) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // Sort by visit count | |||
| 200 | + | matches := make([]FuzzyMatch, 0) | |||
| 201 | + | for _, entry := range allHistory { | |||
| 202 | + | // Apply filter if provided | |||
| 203 | + | if filterQuery != "" { | |||
| 204 | + | match := hs.fuzzyMatcher.matchHistoryEntry(filterQuery, entry) | |||
| 205 | + | if match.Score < hs.fuzzyConfig.MinSimilarityThreshold { | |||
| 206 | + | continue | |||
| 207 | + | } | |||
| 208 | + | matches = append(matches, match) | |||
| 209 | + | } else { | |||
| 210 | + | // No filter, just create basic match with visit score | |||
| 211 | + | match := FuzzyMatch{ | |||
| 212 | + | HistoryEntry: entry, | |||
| 213 | + | Score: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount), | |||
| 214 | + | VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount), | |||
| 215 | + | RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited), | |||
| 216 | + | MatchedField: "visit_count", | |||
| 217 | + | } | |||
| 218 | + | matches = append(matches, match) | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // Sort by visit count (descending) | |||
| 223 | + | for i := 0; i < len(matches); i++ { | |||
| 224 | + | for j := i + 1; j < len(matches); j++ { | |||
| 225 | + | iVisits := int64(0) | |||
| 226 | + | jVisits := int64(0) | |||
| 227 | + | if matches[i].HistoryEntry.VisitCount.Valid { | |||
| 228 | + | iVisits = matches[i].HistoryEntry.VisitCount.Int64 | |||
| 229 | + | } | |||
| 230 | + | if matches[j].HistoryEntry.VisitCount.Valid { | |||
| 231 | + | jVisits = matches[j].HistoryEntry.VisitCount.Int64 | |||
| 232 | + | } | |||
| 233 | + | if jVisits > iVisits { | |||
| 234 | + | matches[i], matches[j] = matches[j], matches[i] | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // Limit results | |||
| 240 | + | if len(matches) > limit { | |||
| 241 | + | matches = matches[:limit] | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | return matches, nil | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // GetRecentWithFuzzy returns recent entries with optional fuzzy filtering. | |||
| 248 | + | func (hs *HistorySearcher) GetRecentWithFuzzy(limit int, filterQuery string) ([]FuzzyMatch, error) { | |||
| 249 | + | recentHistory, err := hs.provider.GetRecentHistory(limit * 2) // Get more to filter | |||
| 250 | + | if err != nil { | |||
| 251 | + | return nil, fmt.Errorf("failed to retrieve recent history: %w", err) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | if filterQuery == "" { | |||
| 255 | + | // No filter, convert to FuzzyMatch format | |||
| 256 | + | matches := make([]FuzzyMatch, 0, len(recentHistory)) | |||
| 257 | + | for _, entry := range recentHistory { | |||
| 258 | + | match := FuzzyMatch{ | |||
| 259 | + | HistoryEntry: entry, | |||
| 260 | + | Score: 1.0, // Recent entries get full score | |||
| 261 | + | RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited), | |||
| 262 | + | VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount), | |||
| 263 | + | MatchedField: "recent", | |||
| 264 | + | } | |||
| 265 | + | matches = append(matches, match) | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | if len(matches) > limit { | |||
| 269 | + | matches = matches[:limit] | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | return matches, nil | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // Apply fuzzy filter | |||
| 276 | + | matches := hs.fuzzyMatcher.SearchHistory(filterQuery, recentHistory) | |||
| 277 | + | if len(matches) > limit { | |||
| 278 | + | matches = matches[:limit] | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | return matches, nil | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // combineAndRankMatches combines exact and fuzzy matches with proper ranking. | |||
| 285 | + | func (hs *HistorySearcher) combineAndRankMatches(exactMatches, fuzzyMatches []FuzzyMatch, query string) []FuzzyMatch { | |||
| 286 | + | combined := make([]FuzzyMatch, 0, len(exactMatches)+len(fuzzyMatches)) | |||
| 287 | + | ||||
| 288 | + | // Add exact matches first (they get priority) | |||
| 289 | + | combined = append(combined, exactMatches...) | |||
| 290 | + | ||||
| 291 | + | // Add fuzzy matches | |||
| 292 | + | combined = append(combined, fuzzyMatches...) | |||
| 293 | + | ||||
| 294 | + | // Re-rank all matches | |||
| 295 | + | return hs.fuzzyMatcher.RankMatches(combined, query) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // extractDomain extracts domain from URL for domain-based searches. | |||
| 299 | + | func (hs *HistorySearcher) extractDomain(url string) string { | |||
| 300 | + | // Simple domain extraction | |||
| 301 | + | domain := url | |||
| 302 | + | if strings.HasPrefix(domain, "http://") { | |||
| 303 | + | domain = domain[7:] | |||
| 304 | + | } else if strings.HasPrefix(domain, "https://") { | |||
| 305 | + | domain = domain[8:] | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // Remove path | |||
| 309 | + | if idx := strings.Index(domain, "/"); idx != -1 { | |||
| 310 | + | domain = domain[:idx] | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | // Remove port | |||
| 314 | + | if idx := strings.LastIndex(domain, ":"); idx != -1 { | |||
| 315 | + | domain = domain[:idx] | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | return domain | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | // HistorySearchResult represents the result of a history search operation. | |||
| 322 | + | type HistorySearchResult struct { | |||
| 323 | + | Query string `json:"query"` | |||
| 324 | + | TotalEntries int `json:"total_entries"` | |||
| 325 | + | MatchCount int `json:"match_count"` | |||
| 326 | + | ExactMatches []FuzzyMatch `json:"exact_matches,omitempty"` | |||
| 327 | + | FuzzyMatches []FuzzyMatch `json:"fuzzy_matches,omitempty"` | |||
| 328 | + | RankedMatches []FuzzyMatch `json:"ranked_matches"` | |||
| 329 | + | SearchTime time.Duration `json:"search_time"` | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | // SearchConfig holds configuration for history search operations. | |||
| 333 | + | type SearchConfig struct { | |||
| 334 | + | Limit int `json:"limit"` | |||
| 335 | + | MinSimilarityThreshold float64 `json:"min_similarity_threshold"` | |||
| 336 | + | IncludeExactMatches bool `json:"include_exact_matches"` | |||
| 337 | + | IncludeFuzzyMatches bool `json:"include_fuzzy_matches"` | |||
| 338 | + | SortByRelevance bool `json:"sort_by_relevance"` | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | // SearchOption is a functional option for configuring search operations. | |||
| 342 | + | type SearchOption func(*SearchConfig) | |||
| 343 | + | ||||
| 344 | + | // WithLimit sets the maximum number of results to return. | |||
| 345 | + | func WithLimit(limit int) SearchOption { | |||
| 346 | + | return func(config *SearchConfig) { | |||
| 347 | + | config.Limit = limit | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | // WithSimilarityThreshold sets the minimum similarity threshold for matches. | |||
| 352 | + | func WithSimilarityThreshold(threshold float64) SearchOption { | |||
| 353 | + | return func(config *SearchConfig) { | |||
| 354 | + | config.MinSimilarityThreshold = threshold | |||
| 355 | + | } | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | // WithExactMatches enables/disables exact match inclusion. | |||
| 359 | + | func WithExactMatches(include bool) SearchOption { | |||
| 360 | + | return func(config *SearchConfig) { | |||
| 361 | + | config.IncludeExactMatches = include | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | // WithFuzzyMatches enables/disables fuzzy match inclusion. | |||
| 366 | + | func WithFuzzyMatches(include bool) SearchOption { | |||
| 367 | + | return func(config *SearchConfig) { | |||
| 368 | + | config.IncludeFuzzyMatches = include | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | // WithRelevanceSort enables/disables relevance-based sorting. | |||
| 373 | + | func WithRelevanceSort(enable bool) SearchOption { | |||
| 374 | + | return func(config *SearchConfig) { | |||
| 375 | + | config.SortByRelevance = enable | |||
| 376 | + | } | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | // GetBestMatch returns the single best match for a query. | |||
| 380 | + | func (hs *HistorySearcher) GetBestMatch(query string) (*FuzzyMatch, error) { | |||
| 381 | + | result, err := hs.SearchWithFuzzy(query, WithLimit(1)) | |||
| 382 | + | if err != nil { | |||
| 383 | + | return nil, err | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | if len(result.RankedMatches) == 0 { | |||
| 387 | + | return nil, nil | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | return &result.RankedMatches[0], nil | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | // GetMatchesByScore returns matches above a specific score threshold. | |||
| 394 | + | func (hs *HistorySearcher) GetMatchesByScore(query string, minScore float64) ([]FuzzyMatch, error) { | |||
| 395 | + | result, err := hs.SearchWithFuzzy(query, WithSimilarityThreshold(minScore)) | |||
| 396 | + | if err != nil { | |||
| 397 | + | return nil, err | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | return result.RankedMatches, nil | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | // UpdateFuzzyConfig updates the fuzzy matching configuration. | |||
| 404 | + | func (hs *HistorySearcher) UpdateFuzzyConfig(config *FuzzyConfig) { | |||
| 405 | + | hs.fuzzyConfig = config | |||
| 406 | + | hs.fuzzyMatcher = NewFuzzyMatcher(config) | |||
| 407 | + | } | |||
A internal/parser/parser.go +355 -0
| @@ -0,0 +1,355 @@ | |||||
| 1 | + | package parser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/config" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // ParseInput parses user input and returns a ParseResult with URL and metadata. | |||
| 12 | + | // | |||
| 13 | + | // Learning Behavior: When users search with shortcuts like "g: viper cli" and visit | |||
| 14 | + | // results, those URLs are recorded in history. Future searches for "viper cli" will | |||
| 15 | + | // find the GitHub page in history via fuzzy matching, creating intelligent learning. | |||
| 16 | + | func (p *Parser) ParseInput(input string) (*ParseResult, error) { | |||
| 17 | + | startTime := time.Now() | |||
| 18 | + | ||||
| 19 | + | // Sanitize input | |||
| 20 | + | validator := NewURLValidator() | |||
| 21 | + | cleanInput := validator.SanitizeInput(input) | |||
| 22 | + | ||||
| 23 | + | if cleanInput == "" { | |||
| 24 | + | return &ParseResult{ | |||
| 25 | + | Type: InputTypeFallbackSearch, | |||
| 26 | + | URL: p.buildSearchURL("", cleanInput), | |||
| 27 | + | Query: input, | |||
| 28 | + | Confidence: 0.0, | |||
| 29 | + | ProcessingTime: time.Since(startTime), | |||
| 30 | + | }, nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // Determine input type | |||
| 34 | + | inputType := validator.GetURLType(cleanInput) | |||
| 35 | + | ||||
| 36 | + | switch inputType { | |||
| 37 | + | case InputTypeDirectURL: | |||
| 38 | + | return p.parseDirectURL(cleanInput, input, startTime) | |||
| 39 | + | ||||
| 40 | + | case InputTypeSearchShortcut: | |||
| 41 | + | return p.parseSearchShortcut(cleanInput, input, startTime) | |||
| 42 | + | ||||
| 43 | + | case InputTypeHistorySearch: | |||
| 44 | + | return p.parseHistorySearch(cleanInput, input, startTime) | |||
| 45 | + | ||||
| 46 | + | default: | |||
| 47 | + | return p.parseFallbackSearch(cleanInput, input, startTime) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // parseDirectURL handles direct URL inputs. | |||
| 52 | + | func (p *Parser) parseDirectURL(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) { | |||
| 53 | + | validator := NewURLValidator() | |||
| 54 | + | normalizedURL := validator.NormalizeURL(cleanInput) | |||
| 55 | + | ||||
| 56 | + | return &ParseResult{ | |||
| 57 | + | Type: InputTypeDirectURL, | |||
| 58 | + | URL: normalizedURL, | |||
| 59 | + | Query: originalInput, | |||
| 60 | + | Confidence: 1.0, // High confidence for direct URLs | |||
| 61 | + | ProcessingTime: time.Since(startTime), | |||
| 62 | + | }, nil | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // parseSearchShortcut handles search shortcut inputs. | |||
| 66 | + | func (p *Parser) parseSearchShortcut(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) { | |||
| 67 | + | validator := NewURLValidator() | |||
| 68 | + | isShortcut, shortcutKey, query := validator.IsSearchShortcut(cleanInput) | |||
| 69 | + | ||||
| 70 | + | if !isShortcut { | |||
| 71 | + | return nil, fmt.Errorf("input is not a valid search shortcut") | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // Look up the shortcut in configuration | |||
| 75 | + | shortcuts := p.config.SearchShortcuts | |||
| 76 | + | shortcutConfig, exists := shortcuts[shortcutKey] | |||
| 77 | + | if !exists { | |||
| 78 | + | // Unknown shortcut, treat as search query | |||
| 79 | + | return p.parseHistorySearch(cleanInput, originalInput, startTime) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Build URL from shortcut template | |||
| 83 | + | finalURL, err := p.processShortcut(shortcutKey, query, shortcutConfig) | |||
| 84 | + | if err != nil { | |||
| 85 | + | return nil, fmt.Errorf("failed to process shortcut: %w", err) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | return &ParseResult{ | |||
| 89 | + | Type: InputTypeSearchShortcut, | |||
| 90 | + | URL: finalURL, | |||
| 91 | + | Query: originalInput, | |||
| 92 | + | Confidence: 0.95, // High confidence for known shortcuts | |||
| 93 | + | Shortcut: &DetectedShortcut{ | |||
| 94 | + | Key: shortcutKey, | |||
| 95 | + | Query: query, | |||
| 96 | + | URL: finalURL, | |||
| 97 | + | Description: shortcutConfig.Description, | |||
| 98 | + | }, | |||
| 99 | + | ProcessingTime: time.Since(startTime), | |||
| 100 | + | }, nil | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // parseHistorySearch handles history search with fuzzy finding. | |||
| 104 | + | func (p *Parser) parseHistorySearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) { | |||
| 105 | + | // Get history entries for fuzzy search | |||
| 106 | + | history, err := p.historyProvider.GetAllHistory() | |||
| 107 | + | if err != nil { | |||
| 108 | + | // If history search fails, fall back to web search | |||
| 109 | + | return p.parseFallbackSearch(cleanInput, originalInput, startTime) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Perform fuzzy search | |||
| 113 | + | fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig) | |||
| 114 | + | matches := fuzzyMatcher.SearchHistory(cleanInput, history) | |||
| 115 | + | ||||
| 116 | + | // Check if we have good matches | |||
| 117 | + | if len(matches) > 0 && matches[0].Score >= p.fuzzyConfig.MinSimilarityThreshold { | |||
| 118 | + | // Use the best match | |||
| 119 | + | bestMatch := matches[0] | |||
| 120 | + | ||||
| 121 | + | return &ParseResult{ | |||
| 122 | + | Type: InputTypeHistorySearch, | |||
| 123 | + | URL: bestMatch.HistoryEntry.Url, | |||
| 124 | + | Query: originalInput, | |||
| 125 | + | Confidence: bestMatch.Score, | |||
| 126 | + | FuzzyMatches: matches, | |||
| 127 | + | ProcessingTime: time.Since(startTime), | |||
| 128 | + | }, nil | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // No good history matches, fall back to search | |||
| 132 | + | fallbackResult, err := p.parseFallbackSearch(cleanInput, originalInput, startTime) | |||
| 133 | + | if err != nil { | |||
| 134 | + | return nil, err | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // Include the fuzzy matches even though we're falling back | |||
| 138 | + | fallbackResult.FuzzyMatches = matches | |||
| 139 | + | ||||
| 140 | + | return fallbackResult, nil | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // parseFallbackSearch handles fallback web search. | |||
| 144 | + | func (p *Parser) parseFallbackSearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) { | |||
| 145 | + | // Use default search shortcut (usually "g" for Google) | |||
| 146 | + | searchURL := p.buildSearchURL("", cleanInput) | |||
| 147 | + | ||||
| 148 | + | return &ParseResult{ | |||
| 149 | + | Type: InputTypeFallbackSearch, | |||
| 150 | + | URL: searchURL, | |||
| 151 | + | Query: originalInput, | |||
| 152 | + | Confidence: 0.1, // Low confidence for fallback | |||
| 153 | + | ProcessingTime: time.Since(startTime), | |||
| 154 | + | }, nil | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // processShortcut processes a search shortcut and builds the final URL. | |||
| 158 | + | func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) { | |||
| 159 | + | urlTemplate := shortcut.URL | |||
| 160 | + | ||||
| 161 | + | // Replace placeholder with query | |||
| 162 | + | finalURL := strings.ReplaceAll(urlTemplate, "{query}", query) | |||
| 163 | + | ||||
| 164 | + | // Validate the resulting URL | |||
| 165 | + | validator := NewURLValidator() | |||
| 166 | + | if !validator.IsValidURL(finalURL) && !strings.HasPrefix(finalURL, "http") { | |||
| 167 | + | return "", fmt.Errorf("invalid URL generated from shortcut template: %s", finalURL) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | return finalURL, nil | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // buildSearchURL builds a search URL using the default search engine. | |||
| 174 | + | func (p *Parser) buildSearchURL(searchEngine, query string) string { | |||
| 175 | + | // If no specific search engine, use Google as default | |||
| 176 | + | if searchEngine == "" { | |||
| 177 | + | defaultShortcut, exists := p.config.SearchShortcuts["g"] | |||
| 178 | + | if exists { | |||
| 179 | + | url := strings.ReplaceAll(defaultShortcut.URL, "{query}", query) | |||
| 180 | + | return url | |||
| 181 | + | } | |||
| 182 | + | // Fallback to Google if no "g" shortcut configured | |||
| 183 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", query) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // Use specified search engine | |||
| 187 | + | shortcut, exists := p.config.SearchShortcuts[searchEngine] | |||
| 188 | + | if !exists { | |||
| 189 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", query) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | return strings.ReplaceAll(shortcut.URL, "{query}", query) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // FuzzySearchHistory performs fuzzy search on history and returns ranked matches. | |||
| 196 | + | func (p *Parser) FuzzySearchHistory(query string, threshold float64) ([]FuzzyMatch, error) { | |||
| 197 | + | if query == "" { | |||
| 198 | + | return nil, nil | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // Get all history entries | |||
| 202 | + | history, err := p.historyProvider.GetAllHistory() | |||
| 203 | + | if err != nil { | |||
| 204 | + | return nil, fmt.Errorf("failed to retrieve history: %w", err) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // Create fuzzy config with custom threshold if provided | |||
| 208 | + | fuzzyConfig := *p.fuzzyConfig | |||
| 209 | + | if threshold > 0 && threshold <= 1.0 { | |||
| 210 | + | fuzzyConfig.MinSimilarityThreshold = threshold | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // Perform fuzzy search | |||
| 214 | + | fuzzyMatcher := NewFuzzyMatcher(&fuzzyConfig) | |||
| 215 | + | matches := fuzzyMatcher.SearchHistory(query, history) | |||
| 216 | + | ||||
| 217 | + | // Apply additional ranking | |||
| 218 | + | matches = fuzzyMatcher.RankMatches(matches, query) | |||
| 219 | + | ||||
| 220 | + | return matches, nil | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // CalculateSimilarity calculates similarity between two strings. | |||
| 224 | + | func (p *Parser) CalculateSimilarity(s1, s2 string) float64 { | |||
| 225 | + | fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig) | |||
| 226 | + | return fuzzyMatcher.calculateSimilarity(s1, s2) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // RankMatches re-ranks fuzzy matches based on additional criteria. | |||
| 230 | + | func (p *Parser) RankMatches(matches []FuzzyMatch, query string) []FuzzyMatch { | |||
| 231 | + | fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig) | |||
| 232 | + | return fuzzyMatcher.RankMatches(matches, query) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // IsValidURL checks if the input represents a valid URL. | |||
| 236 | + | func (p *Parser) IsValidURL(input string) bool { | |||
| 237 | + | validator := NewURLValidator() | |||
| 238 | + | return validator.IsValidURL(input) | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // ProcessShortcut processes a shortcut and returns the resulting URL. | |||
| 242 | + | func (p *Parser) ProcessShortcut(shortcut, query string, shortcuts map[string]config.SearchShortcut) (string, error) { | |||
| 243 | + | shortcutConfig, exists := shortcuts[shortcut] | |||
| 244 | + | if !exists { | |||
| 245 | + | return "", fmt.Errorf("shortcut '%s' not found", shortcut) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | return p.processShortcut(shortcut, query, shortcutConfig) | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | // GetSupportedShortcuts returns a list of configured search shortcuts. | |||
| 252 | + | func (p *Parser) GetSupportedShortcuts() map[string]config.SearchShortcut { | |||
| 253 | + | return p.config.SearchShortcuts | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // UpdateFuzzyConfig updates the fuzzy matching configuration. | |||
| 257 | + | func (p *Parser) UpdateFuzzyConfig(newConfig *FuzzyConfig) error { | |||
| 258 | + | if !newConfig.IsValid() { | |||
| 259 | + | return fmt.Errorf("invalid fuzzy configuration") | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | p.fuzzyConfig = newConfig | |||
| 263 | + | return nil | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // GetFuzzyConfig returns the current fuzzy matching configuration. | |||
| 267 | + | func (p *Parser) GetFuzzyConfig() *FuzzyConfig { | |||
| 268 | + | return p.fuzzyConfig | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | // PreviewParse provides a preview of what ParseInput would return without side effects. | |||
| 272 | + | func (p *Parser) PreviewParse(input string) (*ParseResult, error) { | |||
| 273 | + | // This is essentially the same as ParseInput but could be extended | |||
| 274 | + | // to avoid any side effects if needed in the future | |||
| 275 | + | return p.ParseInput(input) | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | // SuggestCompletions provides URL/search completions based on input. | |||
| 279 | + | func (p *Parser) SuggestCompletions(input string, limit int) ([]string, error) { | |||
| 280 | + | if input == "" || limit <= 0 { | |||
| 281 | + | return nil, nil | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // Get fuzzy matches from history | |||
| 285 | + | matches, err := p.FuzzySearchHistory(input, 0.2) // Lower threshold for suggestions | |||
| 286 | + | if err != nil { | |||
| 287 | + | return nil, err | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | completions := make([]string, 0, limit) | |||
| 291 | + | seen := make(map[string]bool) | |||
| 292 | + | ||||
| 293 | + | // Add URLs from fuzzy matches | |||
| 294 | + | for _, match := range matches { | |||
| 295 | + | if len(completions) >= limit { | |||
| 296 | + | break | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | url := match.HistoryEntry.Url | |||
| 300 | + | if !seen[url] { | |||
| 301 | + | completions = append(completions, url) | |||
| 302 | + | seen[url] = true | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // Add search shortcuts if input looks like it might be a shortcut | |||
| 307 | + | if strings.Contains(input, ":") || len(input) <= 3 { | |||
| 308 | + | for shortcut, config := range p.config.SearchShortcuts { | |||
| 309 | + | if len(completions) >= limit { | |||
| 310 | + | break | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | if strings.HasPrefix(shortcut, strings.ToLower(input)) { | |||
| 314 | + | suggestion := fmt.Sprintf("%s: %s", shortcut, config.Description) | |||
| 315 | + | if !seen[suggestion] { | |||
| 316 | + | completions = append(completions, suggestion) | |||
| 317 | + | seen[suggestion] = true | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | return completions, nil | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | // ValidateConfig validates the parser configuration. | |||
| 327 | + | func (p *Parser) ValidateConfig() error { | |||
| 328 | + | if p.config == nil { | |||
| 329 | + | return fmt.Errorf("parser config is nil") | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | if p.fuzzyConfig == nil { | |||
| 333 | + | return fmt.Errorf("fuzzy config is nil") | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | if !p.fuzzyConfig.IsValid() { | |||
| 337 | + | return fmt.Errorf("invalid fuzzy config") | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | if p.historyProvider == nil { | |||
| 341 | + | return fmt.Errorf("history provider is nil") | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | // Validate search shortcuts | |||
| 345 | + | if len(p.config.SearchShortcuts) == 0 { | |||
| 346 | + | return fmt.Errorf("no search shortcuts configured") | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | // Check for default search shortcut | |||
| 350 | + | if _, hasGoogle := p.config.SearchShortcuts["g"]; !hasGoogle { | |||
| 351 | + | // Warning: no default search configured | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | return nil | |||
| 355 | + | } | |||
A internal/parser/parser_test.go +611 -0
| @@ -0,0 +1,611 @@ | |||||
| 1 | + | package parser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "database/sql" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/config" | |||
| 9 | + | "github.com/bnema/dumber/internal/db" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // MockHistoryProvider implements HistoryProvider for testing. | |||
| 13 | + | type MockHistoryProvider struct { | |||
| 14 | + | history []*db.History | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func (m *MockHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) { | |||
| 18 | + | if limit >= len(m.history) { | |||
| 19 | + | return m.history, nil | |||
| 20 | + | } | |||
| 21 | + | return m.history[:limit], nil | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (m *MockHistoryProvider) GetAllHistory() ([]*db.History, error) { | |||
| 25 | + | return m.history, nil | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (m *MockHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) { | |||
| 29 | + | // Simple mock search - just return entries that contain the query | |||
| 30 | + | result := make([]*db.History, 0) | |||
| 31 | + | for _, entry := range m.history { | |||
| 32 | + | if len(result) >= limit { | |||
| 33 | + | break | |||
| 34 | + | } | |||
| 35 | + | // Simple contains check | |||
| 36 | + | result = append(result, entry) | |||
| 37 | + | } | |||
| 38 | + | return result, nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (m *MockHistoryProvider) GetHistoryByURL(url string) (*db.History, error) { | |||
| 42 | + | for _, entry := range m.history { | |||
| 43 | + | if entry.Url == url { | |||
| 44 | + | return entry, nil | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | return nil, sql.ErrNoRows | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // createMockHistory creates mock history data for testing. | |||
| 51 | + | func createMockHistory() []*db.History { | |||
| 52 | + | now := time.Now() | |||
| 53 | + | return []*db.History{ | |||
| 54 | + | { | |||
| 55 | + | ID: 1, | |||
| 56 | + | Url: "https://github.com", | |||
| 57 | + | Title: sql.NullString{String: "GitHub", Valid: true}, | |||
| 58 | + | VisitCount: sql.NullInt64{Int64: 25, Valid: true}, | |||
| 59 | + | LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true}, | |||
| 60 | + | CreatedAt: sql.NullTime{Time: now.Add(-30 * 24 * time.Hour), Valid: true}, | |||
| 61 | + | }, | |||
| 62 | + | { | |||
| 63 | + | ID: 2, | |||
| 64 | + | Url: "https://stackoverflow.com", | |||
| 65 | + | Title: sql.NullString{String: "Stack Overflow", Valid: true}, | |||
| 66 | + | VisitCount: sql.NullInt64{Int64: 15, Valid: true}, | |||
| 67 | + | LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true}, | |||
| 68 | + | CreatedAt: sql.NullTime{Time: now.Add(-20 * 24 * time.Hour), Valid: true}, | |||
| 69 | + | }, | |||
| 70 | + | { | |||
| 71 | + | ID: 3, | |||
| 72 | + | Url: "https://reddit.com", | |||
| 73 | + | Title: sql.NullString{String: "Reddit", Valid: true}, | |||
| 74 | + | VisitCount: sql.NullInt64{Int64: 50, Valid: true}, | |||
| 75 | + | LastVisited: sql.NullTime{Time: now.Add(-3 * time.Hour), Valid: true}, | |||
| 76 | + | CreatedAt: sql.NullTime{Time: now.Add(-10 * 24 * time.Hour), Valid: true}, | |||
| 77 | + | }, | |||
| 78 | + | { | |||
| 79 | + | ID: 4, | |||
| 80 | + | Url: "https://golang.org", | |||
| 81 | + | Title: sql.NullString{String: "The Go Programming Language", Valid: true}, | |||
| 82 | + | VisitCount: sql.NullInt64{Int64: 8, Valid: true}, | |||
| 83 | + | LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true}, | |||
| 84 | + | CreatedAt: sql.NullTime{Time: now.Add(-5 * 24 * time.Hour), Valid: true}, | |||
| 85 | + | }, | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // createTestConfig creates a test configuration. | |||
| 90 | + | func createTestConfig() *config.Config { | |||
| 91 | + | return &config.Config{ | |||
| 92 | + | SearchShortcuts: map[string]config.SearchShortcut{ | |||
| 93 | + | "g": { | |||
| 94 | + | URL: "https://www.google.com/search?q={query}", | |||
| 95 | + | Description: "Google Search", | |||
| 96 | + | }, | |||
| 97 | + | "gh": { | |||
| 98 | + | URL: "https://github.com/search?q={query}", | |||
| 99 | + | Description: "GitHub Search", | |||
| 100 | + | }, | |||
| 101 | + | "so": { | |||
| 102 | + | URL: "https://stackoverflow.com/search?q={query}", | |||
| 103 | + | Description: "Stack Overflow Search", | |||
| 104 | + | }, | |||
| 105 | + | }, | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestParser_ParseInput_DirectURL(t *testing.T) { | |||
| 110 | + | mockHistory := createMockHistory() | |||
| 111 | + | provider := &MockHistoryProvider{history: mockHistory} | |||
| 112 | + | config := createTestConfig() | |||
| 113 | + | parser := NewParser(config, provider) | |||
| 114 | + | ||||
| 115 | + | tests := []struct { | |||
| 116 | + | name string | |||
| 117 | + | input string | |||
| 118 | + | expectType InputType | |||
| 119 | + | expectURL string | |||
| 120 | + | expectConf float64 | |||
| 121 | + | }{ | |||
| 122 | + | { | |||
| 123 | + | name: "HTTPS URL", | |||
| 124 | + | input: "https://example.com", | |||
| 125 | + | expectType: InputTypeDirectURL, | |||
| 126 | + | expectURL: "https://example.com", | |||
| 127 | + | expectConf: 1.0, | |||
| 128 | + | }, | |||
| 129 | + | { | |||
| 130 | + | name: "HTTP URL", | |||
| 131 | + | input: "http://example.com", | |||
| 132 | + | expectType: InputTypeDirectURL, | |||
| 133 | + | expectURL: "http://example.com", | |||
| 134 | + | expectConf: 1.0, | |||
| 135 | + | }, | |||
| 136 | + | { | |||
| 137 | + | name: "Domain only", | |||
| 138 | + | input: "example.com", | |||
| 139 | + | expectType: InputTypeDirectURL, | |||
| 140 | + | expectURL: "https://example.com", | |||
| 141 | + | expectConf: 1.0, | |||
| 142 | + | }, | |||
| 143 | + | { | |||
| 144 | + | name: "Subdomain", | |||
| 145 | + | input: "www.example.com", | |||
| 146 | + | expectType: InputTypeDirectURL, | |||
| 147 | + | expectURL: "https://www.example.com", | |||
| 148 | + | expectConf: 1.0, | |||
| 149 | + | }, | |||
| 150 | + | { | |||
| 151 | + | name: "Domain with path", | |||
| 152 | + | input: "example.com/path", | |||
| 153 | + | expectType: InputTypeDirectURL, | |||
| 154 | + | expectURL: "https://example.com/path", | |||
| 155 | + | expectConf: 1.0, | |||
| 156 | + | }, | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | for _, tt := range tests { | |||
| 160 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 161 | + | result, err := parser.ParseInput(tt.input) | |||
| 162 | + | if err != nil { | |||
| 163 | + | t.Fatalf("ParseInput() error = %v", err) | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | if result.Type != tt.expectType { | |||
| 167 | + | t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | if result.URL != tt.expectURL { | |||
| 171 | + | t.Errorf("ParseInput() URL = %v, want %v", result.URL, tt.expectURL) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | if result.Confidence != tt.expectConf { | |||
| 175 | + | t.Errorf("ParseInput() confidence = %v, want %v", result.Confidence, tt.expectConf) | |||
| 176 | + | } | |||
| 177 | + | }) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestParser_ParseInput_SearchShortcut(t *testing.T) { | |||
| 182 | + | mockHistory := createMockHistory() | |||
| 183 | + | provider := &MockHistoryProvider{history: mockHistory} | |||
| 184 | + | config := createTestConfig() | |||
| 185 | + | parser := NewParser(config, provider) | |||
| 186 | + | ||||
| 187 | + | tests := []struct { | |||
| 188 | + | name string | |||
| 189 | + | input string | |||
| 190 | + | expectType InputType | |||
| 191 | + | expectURL string | |||
| 192 | + | expectKey string | |||
| 193 | + | expectQuery string | |||
| 194 | + | }{ | |||
| 195 | + | { | |||
| 196 | + | name: "Google search", | |||
| 197 | + | input: "g: golang tutorial", | |||
| 198 | + | expectType: InputTypeSearchShortcut, | |||
| 199 | + | expectURL: "https://www.google.com/search?q=golang tutorial", | |||
| 200 | + | expectKey: "g", | |||
| 201 | + | expectQuery: "golang tutorial", | |||
| 202 | + | }, | |||
| 203 | + | { | |||
| 204 | + | name: "GitHub search", | |||
| 205 | + | input: "gh: cobra cli", | |||
| 206 | + | expectType: InputTypeSearchShortcut, | |||
| 207 | + | expectURL: "https://github.com/search?q=cobra cli", | |||
| 208 | + | expectKey: "gh", | |||
| 209 | + | expectQuery: "cobra cli", | |||
| 210 | + | }, | |||
| 211 | + | { | |||
| 212 | + | name: "Stack Overflow search", | |||
| 213 | + | input: "so: how to fuzzy search", | |||
| 214 | + | expectType: InputTypeSearchShortcut, | |||
| 215 | + | expectURL: "https://stackoverflow.com/search?q=how to fuzzy search", | |||
| 216 | + | expectKey: "so", | |||
| 217 | + | expectQuery: "how to fuzzy search", | |||
| 218 | + | }, | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | for _, tt := range tests { | |||
| 222 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 223 | + | result, err := parser.ParseInput(tt.input) | |||
| 224 | + | if err != nil { | |||
| 225 | + | t.Fatalf("ParseInput() error = %v", err) | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | if result.Type != tt.expectType { | |||
| 229 | + | t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | if result.URL != tt.expectURL { | |||
| 233 | + | t.Errorf("ParseInput() URL = %v, want %v", result.URL, tt.expectURL) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | if result.Shortcut == nil { | |||
| 237 | + | t.Fatalf("ParseInput() shortcut is nil") | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | if result.Shortcut.Key != tt.expectKey { | |||
| 241 | + | t.Errorf("ParseInput() shortcut key = %v, want %v", result.Shortcut.Key, tt.expectKey) | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | if result.Shortcut.Query != tt.expectQuery { | |||
| 245 | + | t.Errorf("ParseInput() shortcut query = %v, want %v", result.Shortcut.Query, tt.expectQuery) | |||
| 246 | + | } | |||
| 247 | + | }) | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func TestParser_ParseInput_HistorySearch(t *testing.T) { | |||
| 252 | + | mockHistory := createMockHistory() | |||
| 253 | + | provider := &MockHistoryProvider{history: mockHistory} | |||
| 254 | + | config := createTestConfig() | |||
| 255 | + | parser := NewParser(config, provider) | |||
| 256 | + | ||||
| 257 | + | tests := []struct { | |||
| 258 | + | name string | |||
| 259 | + | input string | |||
| 260 | + | expectType InputType | |||
| 261 | + | expectMatches bool | |||
| 262 | + | minConfidence float64 | |||
| 263 | + | }{ | |||
| 264 | + | { | |||
| 265 | + | name: "Exact domain match", | |||
| 266 | + | input: "github", | |||
| 267 | + | expectType: InputTypeHistorySearch, | |||
| 268 | + | expectMatches: true, | |||
| 269 | + | minConfidence: 0.5, | |||
| 270 | + | }, | |||
| 271 | + | { | |||
| 272 | + | name: "Partial title match", | |||
| 273 | + | input: "stack", | |||
| 274 | + | expectType: InputTypeHistorySearch, | |||
| 275 | + | expectMatches: true, | |||
| 276 | + | minConfidence: 0.3, | |||
| 277 | + | }, | |||
| 278 | + | { | |||
| 279 | + | name: "Programming language search", | |||
| 280 | + | input: "golang", | |||
| 281 | + | expectType: InputTypeHistorySearch, | |||
| 282 | + | expectMatches: true, | |||
| 283 | + | minConfidence: 0.3, | |||
| 284 | + | }, | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | for _, tt := range tests { | |||
| 288 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 289 | + | result, err := parser.ParseInput(tt.input) | |||
| 290 | + | if err != nil { | |||
| 291 | + | t.Fatalf("ParseInput() error = %v", err) | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | if result.Type != tt.expectType { | |||
| 295 | + | t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | if tt.expectMatches && len(result.FuzzyMatches) == 0 { | |||
| 299 | + | t.Errorf("ParseInput() expected fuzzy matches but got none") | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | if result.Confidence < tt.minConfidence { | |||
| 303 | + | t.Errorf("ParseInput() confidence = %v, want >= %v", result.Confidence, tt.minConfidence) | |||
| 304 | + | } | |||
| 305 | + | }) | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func TestParser_FuzzySearchHistory(t *testing.T) { | |||
| 310 | + | mockHistory := createMockHistory() | |||
| 311 | + | provider := &MockHistoryProvider{history: mockHistory} | |||
| 312 | + | config := createTestConfig() | |||
| 313 | + | parser := NewParser(config, provider) | |||
| 314 | + | ||||
| 315 | + | tests := []struct { | |||
| 316 | + | name string | |||
| 317 | + | query string | |||
| 318 | + | threshold float64 | |||
| 319 | + | expectMatches int | |||
| 320 | + | expectFirst string // Expected first match URL | |||
| 321 | + | }{ | |||
| 322 | + | { | |||
| 323 | + | name: "GitHub search", | |||
| 324 | + | query: "github", | |||
| 325 | + | threshold: 0.3, | |||
| 326 | + | expectMatches: 1, | |||
| 327 | + | expectFirst: "https://github.com", | |||
| 328 | + | }, | |||
| 329 | + | { | |||
| 330 | + | name: "Programming search", | |||
| 331 | + | query: "programming", | |||
| 332 | + | threshold: 0.2, | |||
| 333 | + | expectMatches: 1, | |||
| 334 | + | expectFirst: "https://golang.org", | |||
| 335 | + | }, | |||
| 336 | + | { | |||
| 337 | + | name: "Stack search", | |||
| 338 | + | query: "stack", | |||
| 339 | + | threshold: 0.3, | |||
| 340 | + | expectMatches: 1, | |||
| 341 | + | expectFirst: "https://stackoverflow.com", | |||
| 342 | + | }, | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | for _, tt := range tests { | |||
| 346 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 347 | + | matches, err := parser.FuzzySearchHistory(tt.query, tt.threshold) | |||
| 348 | + | if err != nil { | |||
| 349 | + | t.Fatalf("FuzzySearchHistory() error = %v", err) | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | if len(matches) < tt.expectMatches { | |||
| 353 | + | t.Errorf("FuzzySearchHistory() matches = %d, want >= %d", len(matches), tt.expectMatches) | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | if len(matches) > 0 && tt.expectFirst != "" { | |||
| 357 | + | if matches[0].HistoryEntry.Url != tt.expectFirst { | |||
| 358 | + | t.Errorf("FuzzySearchHistory() first match = %v, want %v", matches[0].HistoryEntry.Url, tt.expectFirst) | |||
| 359 | + | } | |||
| 360 | + | } | |||
| 361 | + | }) | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func TestParser_SuggestCompletions(t *testing.T) { | |||
| 366 | + | mockHistory := createMockHistory() | |||
| 367 | + | provider := &MockHistoryProvider{history: mockHistory} | |||
| 368 | + | config := createTestConfig() | |||
| 369 | + | parser := NewParser(config, provider) | |||
| 370 | + | ||||
| 371 | + | tests := []struct { | |||
| 372 | + | name string | |||
| 373 | + | input string | |||
| 374 | + | limit int | |||
| 375 | + | expectCount int | |||
| 376 | + | }{ | |||
| 377 | + | { | |||
| 378 | + | name: "GitHub completion", | |||
| 379 | + | input: "gith", | |||
| 380 | + | limit: 5, | |||
| 381 | + | expectCount: 1, | |||
| 382 | + | }, | |||
| 383 | + | { | |||
| 384 | + | name: "Shortcut completion", | |||
| 385 | + | input: "g", | |||
| 386 | + | limit: 5, | |||
| 387 | + | expectCount: 2, // "g" and "gh" shortcuts | |||
| 388 | + | }, | |||
| 389 | + | { | |||
| 390 | + | name: "Empty input", | |||
| 391 | + | input: "", | |||
| 392 | + | limit: 5, | |||
| 393 | + | expectCount: 0, | |||
| 394 | + | }, | |||
| 395 | + | } | |||
| 396 | + | ||||
| 397 | + | for _, tt := range tests { | |||
| 398 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 399 | + | completions, err := parser.SuggestCompletions(tt.input, tt.limit) | |||
| 400 | + | if err != nil { | |||
| 401 | + | t.Fatalf("SuggestCompletions() error = %v", err) | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | if len(completions) != tt.expectCount { | |||
| 405 | + | t.Errorf("SuggestCompletions() count = %d, want %d", len(completions), tt.expectCount) | |||
| 406 | + | } | |||
| 407 | + | }) | |||
| 408 | + | } | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | func TestParser_ProcessShortcut(t *testing.T) { | |||
| 412 | + | config := createTestConfig() | |||
| 413 | + | mockHistory := createMockHistory() | |||
| 414 | + | provider := &MockHistoryProvider{history: mockHistory} | |||
| 415 | + | parser := NewParser(config, provider) | |||
| 416 | + | ||||
| 417 | + | tests := []struct { | |||
| 418 | + | name string | |||
| 419 | + | shortcut string | |||
| 420 | + | query string | |||
| 421 | + | expectURL string | |||
| 422 | + | expectErr bool | |||
| 423 | + | }{ | |||
| 424 | + | { | |||
| 425 | + | name: "Google shortcut", | |||
| 426 | + | shortcut: "g", | |||
| 427 | + | query: "test query", | |||
| 428 | + | expectURL: "https://www.google.com/search?q=test query", | |||
| 429 | + | expectErr: false, | |||
| 430 | + | }, | |||
| 431 | + | { | |||
| 432 | + | name: "GitHub shortcut", | |||
| 433 | + | shortcut: "gh", | |||
| 434 | + | query: "golang", | |||
| 435 | + | expectURL: "https://github.com/search?q=golang", | |||
| 436 | + | expectErr: false, | |||
| 437 | + | }, | |||
| 438 | + | { | |||
| 439 | + | name: "Unknown shortcut", | |||
| 440 | + | shortcut: "unknown", | |||
| 441 | + | query: "test", | |||
| 442 | + | expectURL: "", | |||
| 443 | + | expectErr: true, | |||
| 444 | + | }, | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | for _, tt := range tests { | |||
| 448 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 449 | + | url, err := parser.ProcessShortcut(tt.shortcut, tt.query, config.SearchShortcuts) | |||
| 450 | + | ||||
| 451 | + | if tt.expectErr { | |||
| 452 | + | if err == nil { | |||
| 453 | + | t.Errorf("ProcessShortcut() expected error but got none") | |||
| 454 | + | } | |||
| 455 | + | return | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | if err != nil { | |||
| 459 | + | t.Fatalf("ProcessShortcut() error = %v", err) | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | if url != tt.expectURL { | |||
| 463 | + | t.Errorf("ProcessShortcut() URL = %v, want %v", url, tt.expectURL) | |||
| 464 | + | } | |||
| 465 | + | }) | |||
| 466 | + | } | |||
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | func TestParser_ValidateConfig(t *testing.T) { | |||
| 470 | + | mockHistory := createMockHistory() | |||
| 471 | + | provider := &MockHistoryProvider{history: mockHistory} | |||
| 472 | + | ||||
| 473 | + | tests := []struct { | |||
| 474 | + | name string | |||
| 475 | + | config *config.Config | |||
| 476 | + | fuzzy *FuzzyConfig | |||
| 477 | + | provider HistoryProvider | |||
| 478 | + | expectErr bool | |||
| 479 | + | }{ | |||
| 480 | + | { | |||
| 481 | + | name: "Valid configuration", | |||
| 482 | + | config: createTestConfig(), | |||
| 483 | + | fuzzy: DefaultFuzzyConfig(), | |||
| 484 | + | provider: provider, | |||
| 485 | + | expectErr: false, | |||
| 486 | + | }, | |||
| 487 | + | { | |||
| 488 | + | name: "Nil config", | |||
| 489 | + | config: nil, | |||
| 490 | + | fuzzy: DefaultFuzzyConfig(), | |||
| 491 | + | provider: provider, | |||
| 492 | + | expectErr: true, | |||
| 493 | + | }, | |||
| 494 | + | { | |||
| 495 | + | name: "Nil fuzzy config", | |||
| 496 | + | config: createTestConfig(), | |||
| 497 | + | fuzzy: nil, | |||
| 498 | + | provider: provider, | |||
| 499 | + | expectErr: true, | |||
| 500 | + | }, | |||
| 501 | + | { | |||
| 502 | + | name: "Nil history provider", | |||
| 503 | + | config: createTestConfig(), | |||
| 504 | + | fuzzy: DefaultFuzzyConfig(), | |||
| 505 | + | provider: nil, | |||
| 506 | + | expectErr: true, | |||
| 507 | + | }, | |||
| 508 | + | } | |||
| 509 | + | ||||
| 510 | + | for _, tt := range tests { | |||
| 511 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 512 | + | parser := NewParser(tt.config, tt.provider, WithFuzzyConfig(tt.fuzzy)) | |||
| 513 | + | ||||
| 514 | + | err := parser.ValidateConfig() | |||
| 515 | + | ||||
| 516 | + | if tt.expectErr { | |||
| 517 | + | if err == nil { | |||
| 518 | + | t.Errorf("ValidateConfig() expected error but got none") | |||
| 519 | + | } | |||
| 520 | + | } else { | |||
| 521 | + | if err != nil { | |||
| 522 | + | t.Errorf("ValidateConfig() unexpected error = %v", err) | |||
| 523 | + | } | |||
| 524 | + | } | |||
| 525 | + | }) | |||
| 526 | + | } | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func TestFuzzyConfig_IsValid(t *testing.T) { | |||
| 530 | + | tests := []struct { | |||
| 531 | + | name string | |||
| 532 | + | config *FuzzyConfig | |||
| 533 | + | expect bool | |||
| 534 | + | }{ | |||
| 535 | + | { | |||
| 536 | + | name: "Default config", | |||
| 537 | + | config: DefaultFuzzyConfig(), | |||
| 538 | + | expect: true, | |||
| 539 | + | }, | |||
| 540 | + | { | |||
| 541 | + | name: "Invalid threshold (negative)", | |||
| 542 | + | config: &FuzzyConfig{ | |||
| 543 | + | MinSimilarityThreshold: -0.1, | |||
| 544 | + | MaxResults: 10, | |||
| 545 | + | URLWeight: 0.4, | |||
| 546 | + | TitleWeight: 0.3, | |||
| 547 | + | RecencyWeight: 0.2, | |||
| 548 | + | VisitWeight: 0.1, | |||
| 549 | + | RecencyDecayDays: 30, | |||
| 550 | + | }, | |||
| 551 | + | expect: false, | |||
| 552 | + | }, | |||
| 553 | + | { | |||
| 554 | + | name: "Invalid threshold (>1.0)", | |||
| 555 | + | config: &FuzzyConfig{ | |||
| 556 | + | MinSimilarityThreshold: 1.1, | |||
| 557 | + | MaxResults: 10, | |||
| 558 | + | URLWeight: 0.4, | |||
| 559 | + | TitleWeight: 0.3, | |||
| 560 | + | RecencyWeight: 0.2, | |||
| 561 | + | VisitWeight: 0.1, | |||
| 562 | + | RecencyDecayDays: 30, | |||
| 563 | + | }, | |||
| 564 | + | expect: false, | |||
| 565 | + | }, | |||
| 566 | + | { | |||
| 567 | + | name: "Invalid MaxResults", | |||
| 568 | + | config: &FuzzyConfig{ | |||
| 569 | + | MinSimilarityThreshold: 0.3, | |||
| 570 | + | MaxResults: 0, | |||
| 571 | + | URLWeight: 0.4, | |||
| 572 | + | TitleWeight: 0.3, | |||
| 573 | + | RecencyWeight: 0.2, | |||
| 574 | + | VisitWeight: 0.1, | |||
| 575 | + | RecencyDecayDays: 30, | |||
| 576 | + | }, | |||
| 577 | + | expect: false, | |||
| 578 | + | }, | |||
| 579 | + | } | |||
| 580 | + | ||||
| 581 | + | for _, tt := range tests { | |||
| 582 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 583 | + | result := tt.config.IsValid() | |||
| 584 | + | if result != tt.expect { | |||
| 585 | + | t.Errorf("FuzzyConfig.IsValid() = %v, want %v", result, tt.expect) | |||
| 586 | + | } | |||
| 587 | + | }) | |||
| 588 | + | } | |||
| 589 | + | } | |||
| 590 | + | ||||
| 591 | + | func TestInputType_String(t *testing.T) { | |||
| 592 | + | tests := []struct { | |||
| 593 | + | inputType InputType | |||
| 594 | + | expected string | |||
| 595 | + | }{ | |||
| 596 | + | {InputTypeDirectURL, "direct_url"}, | |||
| 597 | + | {InputTypeSearchShortcut, "search_shortcut"}, | |||
| 598 | + | {InputTypeHistorySearch, "history_search"}, | |||
| 599 | + | {InputTypeFallbackSearch, "fallback_search"}, | |||
| 600 | + | {InputType(99), "unknown"}, // Unknown type | |||
| 601 | + | } | |||
| 602 | + | ||||
| 603 | + | for _, tt := range tests { | |||
| 604 | + | t.Run(tt.expected, func(t *testing.T) { | |||
| 605 | + | result := tt.inputType.String() | |||
| 606 | + | if result != tt.expected { | |||
| 607 | + | t.Errorf("InputType.String() = %v, want %v", result, tt.expected) | |||
| 608 | + | } | |||
| 609 | + | }) | |||
| 610 | + | } | |||
| 611 | + | } | |||
A internal/parser/types.go +171 -0
| @@ -0,0 +1,171 @@ | |||||
| 1 | + | // Package parser provides URL parsing and fuzzy finding functionality for dumber. | |||
| 2 | + | package parser | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/db" | |||
| 8 | + | "github.com/bnema/dumber/internal/config" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // InputType represents the type of user input detected. | |||
| 12 | + | type InputType int | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | // InputTypeDirectURL represents a direct URL input (e.g., "reddit.com", "https://example.com") | |||
| 16 | + | InputTypeDirectURL InputType = iota | |||
| 17 | + | // InputTypeSearchShortcut represents a search shortcut (e.g., "g: golang tutorial") | |||
| 18 | + | InputTypeSearchShortcut | |||
| 19 | + | // InputTypeHistorySearch represents a history search query | |||
| 20 | + | InputTypeHistorySearch | |||
| 21 | + | // InputTypeFallbackSearch represents a fallback web search | |||
| 22 | + | InputTypeFallbackSearch | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // ParseResult represents the result of parsing user input. | |||
| 26 | + | type ParseResult struct { | |||
| 27 | + | // Type indicates the detected input type | |||
| 28 | + | Type InputType `json:"type"` | |||
| 29 | + | // URL is the final URL to navigate to | |||
| 30 | + | URL string `json:"url"` | |||
| 31 | + | // Query is the original user query | |||
| 32 | + | Query string `json:"query"` | |||
| 33 | + | // Confidence is the confidence score of the parsing (0.0-1.0) | |||
| 34 | + | Confidence float64 `json:"confidence"` | |||
| 35 | + | // FuzzyMatches contains fuzzy search results if applicable | |||
| 36 | + | FuzzyMatches []FuzzyMatch `json:"fuzzy_matches,omitempty"` | |||
| 37 | + | // Shortcut contains the detected shortcut information | |||
| 38 | + | Shortcut *DetectedShortcut `json:"shortcut,omitempty"` | |||
| 39 | + | // ProcessingTime is the time taken to process the input | |||
| 40 | + | ProcessingTime time.Duration `json:"processing_time"` | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // DetectedShortcut represents a detected search shortcut. | |||
| 44 | + | type DetectedShortcut struct { | |||
| 45 | + | // Key is the shortcut key (e.g., "g", "gh") | |||
| 46 | + | Key string `json:"key"` | |||
| 47 | + | // Query is the search query part | |||
| 48 | + | Query string `json:"query"` | |||
| 49 | + | // URL is the constructed URL | |||
| 50 | + | URL string `json:"url"` | |||
| 51 | + | // Description is the shortcut description | |||
| 52 | + | Description string `json:"description"` | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // FuzzyMatch represents a fuzzy search match from history. | |||
| 56 | + | type FuzzyMatch struct { | |||
| 57 | + | // HistoryEntry is the matched history entry | |||
| 58 | + | HistoryEntry *db.History `json:"history_entry"` | |||
| 59 | + | // Score is the similarity score (0.0-1.0, higher is better) | |||
| 60 | + | Score float64 `json:"score"` | |||
| 61 | + | // URLScore is the URL similarity score | |||
| 62 | + | URLScore float64 `json:"url_score"` | |||
| 63 | + | // TitleScore is the title similarity score | |||
| 64 | + | TitleScore float64 `json:"title_score"` | |||
| 65 | + | // RecencyScore is the recency score based on last visit | |||
| 66 | + | RecencyScore float64 `json:"recency_score"` | |||
| 67 | + | // VisitScore is the score based on visit count | |||
| 68 | + | VisitScore float64 `json:"visit_score"` | |||
| 69 | + | // MatchedField indicates which field matched (url, title, or both) | |||
| 70 | + | MatchedField string `json:"matched_field"` | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // HistoryProvider defines the interface for history data access. | |||
| 74 | + | type HistoryProvider interface { | |||
| 75 | + | // GetRecentHistory returns recent history entries limited by count | |||
| 76 | + | GetRecentHistory(limit int) ([]*db.History, error) | |||
| 77 | + | // GetAllHistory returns all history entries for fuzzy search | |||
| 78 | + | GetAllHistory() ([]*db.History, error) | |||
| 79 | + | // SearchHistory performs a basic text search in history | |||
| 80 | + | SearchHistory(query string, limit int) ([]*db.History, error) | |||
| 81 | + | // GetHistoryByURL retrieves history entry by exact URL match | |||
| 82 | + | GetHistoryByURL(url string) (*db.History, error) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // FuzzyConfig holds configuration for fuzzy matching algorithms. | |||
| 86 | + | type FuzzyConfig struct { | |||
| 87 | + | // MinSimilarityThreshold is the minimum similarity score to consider a match (0.0-1.0) | |||
| 88 | + | MinSimilarityThreshold float64 `json:"min_similarity_threshold"` | |||
| 89 | + | // MaxResults is the maximum number of fuzzy results to return | |||
| 90 | + | MaxResults int `json:"max_results"` | |||
| 91 | + | // URLWeight is the weight for URL similarity in scoring | |||
| 92 | + | URLWeight float64 `json:"url_weight"` | |||
| 93 | + | // TitleWeight is the weight for title similarity in scoring | |||
| 94 | + | TitleWeight float64 `json:"title_weight"` | |||
| 95 | + | // RecencyWeight is the weight for recency in scoring | |||
| 96 | + | RecencyWeight float64 `json:"recency_weight"` | |||
| 97 | + | // VisitWeight is the weight for visit count in scoring | |||
| 98 | + | VisitWeight float64 `json:"visit_weight"` | |||
| 99 | + | // RecencyDecayDays is the number of days for recency score decay | |||
| 100 | + | RecencyDecayDays int `json:"recency_decay_days"` | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // Parser handles URL parsing and fuzzy finding operations. | |||
| 104 | + | type Parser struct { | |||
| 105 | + | config *config.Config | |||
| 106 | + | fuzzyConfig *FuzzyConfig | |||
| 107 | + | historyProvider HistoryProvider | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // ParserOption is a functional option for configuring the Parser. | |||
| 111 | + | type ParserOption func(*Parser) | |||
| 112 | + | ||||
| 113 | + | // NewParser creates a new Parser instance with the given configuration. | |||
| 114 | + | func NewParser(cfg *config.Config, historyProvider HistoryProvider, opts ...ParserOption) *Parser { | |||
| 115 | + | p := &Parser{ | |||
| 116 | + | config: cfg, | |||
| 117 | + | historyProvider: historyProvider, | |||
| 118 | + | fuzzyConfig: DefaultFuzzyConfig(), | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | for _, opt := range opts { | |||
| 122 | + | opt(p) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | return p | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // WithFuzzyConfig sets custom fuzzy matching configuration. | |||
| 129 | + | func WithFuzzyConfig(fuzzyConfig *FuzzyConfig) ParserOption { | |||
| 130 | + | return func(p *Parser) { | |||
| 131 | + | p.fuzzyConfig = fuzzyConfig | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // DefaultFuzzyConfig returns default fuzzy matching configuration. | |||
| 136 | + | func DefaultFuzzyConfig() *FuzzyConfig { | |||
| 137 | + | return &FuzzyConfig{ | |||
| 138 | + | MinSimilarityThreshold: 0.3, | |||
| 139 | + | MaxResults: 10, | |||
| 140 | + | URLWeight: 0.4, | |||
| 141 | + | TitleWeight: 0.3, | |||
| 142 | + | RecencyWeight: 0.2, | |||
| 143 | + | VisitWeight: 0.1, | |||
| 144 | + | RecencyDecayDays: 30, | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // String returns a string representation of InputType. | |||
| 149 | + | func (t InputType) String() string { | |||
| 150 | + | switch t { | |||
| 151 | + | case InputTypeDirectURL: | |||
| 152 | + | return "direct_url" | |||
| 153 | + | case InputTypeSearchShortcut: | |||
| 154 | + | return "search_shortcut" | |||
| 155 | + | case InputTypeHistorySearch: | |||
| 156 | + | return "history_search" | |||
| 157 | + | case InputTypeFallbackSearch: | |||
| 158 | + | return "fallback_search" | |||
| 159 | + | default: | |||
| 160 | + | return "unknown" | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // IsValid returns true if the FuzzyConfig has valid values. | |||
| 165 | + | func (fc *FuzzyConfig) IsValid() bool { | |||
| 166 | + | return fc.MinSimilarityThreshold >= 0.0 && fc.MinSimilarityThreshold <= 1.0 && | |||
| 167 | + | fc.MaxResults > 0 && | |||
| 168 | + | fc.URLWeight >= 0.0 && fc.TitleWeight >= 0.0 && | |||
| 169 | + | fc.RecencyWeight >= 0.0 && fc.VisitWeight >= 0.0 && | |||
| 170 | + | fc.RecencyDecayDays > 0 | |||
| 171 | + | } | |||
A internal/parser/validation.go +407 -0
| @@ -0,0 +1,407 @@ | |||||
| 1 | + | package parser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "net/url" | |||
| 6 | + | "regexp" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | var ( | |||
| 11 | + | // domainRegex matches valid domain names | |||
| 12 | + | domainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$`) | |||
| 13 | + | ||||
| 14 | + | // ipRegex matches IPv4 addresses | |||
| 15 | + | ipRegex = regexp.MustCompile(`^(\d{1,3}\.){3}\d{1,3}$`) | |||
| 16 | + | ||||
| 17 | + | // urlSchemeRegex matches URL schemes | |||
| 18 | + | urlSchemeRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*://`) | |||
| 19 | + | ||||
| 20 | + | // shortcutRegex matches search shortcuts (e.g., "g:", "gh:") | |||
| 21 | + | shortcutRegex = regexp.MustCompile(`^([a-zA-Z0-9]+):\s*(.*)$`) | |||
| 22 | + | ||||
| 23 | + | // Common top-level domains for validation | |||
| 24 | + | commonTLDs = map[string]bool{ | |||
| 25 | + | "com": true, "org": true, "net": true, "edu": true, "gov": true, "mil": true, | |||
| 26 | + | "int": true, "co": true, "io": true, "ai": true, "ly": true, "me": true, | |||
| 27 | + | "tv": true, "cc": true, "to": true, "app": true, "dev": true, "tech": true, | |||
| 28 | + | "info": true, "biz": true, "name": true, "museum": true, "travel": true, | |||
| 29 | + | "xyz": true, "fr": true, "de": true, "uk": true, "ca": true, "us": true, | |||
| 30 | + | "au": true, "jp": true, "cn": true, "in": true, "ru": true, "br": true, | |||
| 31 | + | } | |||
| 32 | + | ) | |||
| 33 | + | ||||
| 34 | + | // URLValidator provides URL validation utilities. | |||
| 35 | + | type URLValidator struct{} | |||
| 36 | + | ||||
| 37 | + | // NewURLValidator creates a new URLValidator. | |||
| 38 | + | func NewURLValidator() *URLValidator { | |||
| 39 | + | return &URLValidator{} | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // IsValidURL checks if the input represents a valid URL. | |||
| 43 | + | func (v *URLValidator) IsValidURL(input string) bool { | |||
| 44 | + | if input == "" { | |||
| 45 | + | return false | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Check if it already has a scheme | |||
| 49 | + | if urlSchemeRegex.MatchString(input) { | |||
| 50 | + | return v.isWellFormedURL(input) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // Check if it looks like a domain or IP | |||
| 54 | + | return v.isDomain(input) || v.isIPAddress(input) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // IsDirectURL checks if the input should be treated as a direct URL. | |||
| 58 | + | func (v *URLValidator) IsDirectURL(input string) bool { | |||
| 59 | + | if input == "" { | |||
| 60 | + | return false | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | input = strings.TrimSpace(input) | |||
| 64 | + | ||||
| 65 | + | // Has explicit scheme | |||
| 66 | + | if urlSchemeRegex.MatchString(input) { | |||
| 67 | + | return v.isWellFormedURL(input) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Looks like a domain | |||
| 71 | + | if v.isDomain(input) { | |||
| 72 | + | return true | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Looks like an IP address | |||
| 76 | + | if v.isIPAddress(input) { | |||
| 77 | + | return true | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // Contains domain-like patterns | |||
| 81 | + | if v.looksLikeDomain(input) { | |||
| 82 | + | return true | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | return false | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // IsSearchShortcut checks if the input matches a search shortcut pattern. | |||
| 89 | + | func (v *URLValidator) IsSearchShortcut(input string) (bool, string, string) { | |||
| 90 | + | input = strings.TrimSpace(input) | |||
| 91 | + | ||||
| 92 | + | // Don't match URLs with schemes (like https://example.com) | |||
| 93 | + | if urlSchemeRegex.MatchString(input) { | |||
| 94 | + | return false, "", "" | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | matches := shortcutRegex.FindStringSubmatch(input) | |||
| 98 | + | if len(matches) != 3 { | |||
| 99 | + | return false, "", "" | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | shortcutKey := strings.ToLower(matches[1]) | |||
| 103 | + | query := strings.TrimSpace(matches[2]) | |||
| 104 | + | ||||
| 105 | + | return true, shortcutKey, query | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // NormalizeURL normalizes a URL by adding scheme if missing. | |||
| 109 | + | func (v *URLValidator) NormalizeURL(input string) string { | |||
| 110 | + | input = strings.TrimSpace(input) | |||
| 111 | + | ||||
| 112 | + | if input == "" { | |||
| 113 | + | return input | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // Already has a scheme | |||
| 117 | + | if urlSchemeRegex.MatchString(input) { | |||
| 118 | + | return input | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // Add https:// for domains and IPs | |||
| 122 | + | if v.isDomain(input) || v.isIPAddress(input) || v.looksLikeDomain(input) { | |||
| 123 | + | return "https://" + input | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | return input | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // ExtractDomain extracts the domain from a URL. | |||
| 130 | + | func (v *URLValidator) ExtractDomain(rawURL string) string { | |||
| 131 | + | // Parse the URL | |||
| 132 | + | parsedURL, err := url.Parse(v.NormalizeURL(rawURL)) | |||
| 133 | + | if err != nil { | |||
| 134 | + | // Fallback: try to extract domain manually | |||
| 135 | + | return v.extractDomainManual(rawURL) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // Extract hostname (without port) from Host | |||
| 139 | + | host := parsedURL.Host | |||
| 140 | + | if host == "" { | |||
| 141 | + | return v.extractDomainManual(rawURL) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // Remove port from host if present | |||
| 145 | + | hostname := host | |||
| 146 | + | if idx := strings.LastIndex(host, ":"); idx != -1 { | |||
| 147 | + | // Check if this looks like a port (numeric) | |||
| 148 | + | portPart := host[idx+1:] | |||
| 149 | + | if isNumeric(portPart) { | |||
| 150 | + | hostname = host[:idx] | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // Handle IPv6 addresses in brackets | |||
| 155 | + | if strings.HasPrefix(hostname, "[") && strings.HasSuffix(hostname, "]") { | |||
| 156 | + | hostname = hostname[1 : len(hostname)-1] | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | return hostname | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // extractDomainManual manually extracts domain when URL parsing fails. | |||
| 163 | + | func (v *URLValidator) extractDomainManual(rawURL string) string { | |||
| 164 | + | // Handle empty or invalid input | |||
| 165 | + | if rawURL == "" { | |||
| 166 | + | return "" | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // Remove protocol | |||
| 170 | + | domain := rawURL | |||
| 171 | + | if strings.HasPrefix(domain, "http://") { | |||
| 172 | + | domain = domain[7:] | |||
| 173 | + | } else if strings.HasPrefix(domain, "https://") { | |||
| 174 | + | domain = domain[8:] | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // Handle IPv6 addresses in brackets | |||
| 178 | + | if strings.HasPrefix(domain, "[") { | |||
| 179 | + | if idx := strings.Index(domain, "]"); idx != -1 { | |||
| 180 | + | return domain[1:idx] // Return IPv6 address without brackets | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // Remove path | |||
| 185 | + | if idx := strings.Index(domain, "/"); idx != -1 { | |||
| 186 | + | domain = domain[:idx] | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // Remove query and fragment | |||
| 190 | + | if idx := strings.Index(domain, "?"); idx != -1 { | |||
| 191 | + | domain = domain[:idx] | |||
| 192 | + | } | |||
| 193 | + | if idx := strings.Index(domain, "#"); idx != -1 { | |||
| 194 | + | domain = domain[:idx] | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // Remove port - be careful with IPv6 addresses | |||
| 198 | + | if !strings.Contains(domain, "::") { // Not IPv6 | |||
| 199 | + | if idx := strings.LastIndex(domain, ":"); idx != -1 { | |||
| 200 | + | // Check if this is actually a port (numeric) | |||
| 201 | + | portPart := domain[idx+1:] | |||
| 202 | + | if isNumeric(portPart) { | |||
| 203 | + | domain = domain[:idx] | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | return domain | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // isDomain checks if the input is a valid domain name. | |||
| 212 | + | func (v *URLValidator) isDomain(input string) bool { | |||
| 213 | + | if !domainRegex.MatchString(input) { | |||
| 214 | + | return false | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // Check for at least one dot (to distinguish from single words) | |||
| 218 | + | if !strings.Contains(input, ".") { | |||
| 219 | + | return false | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // Validate TLD | |||
| 223 | + | parts := strings.Split(input, ".") | |||
| 224 | + | if len(parts) < 2 { | |||
| 225 | + | return false | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | tld := strings.ToLower(parts[len(parts)-1]) | |||
| 229 | + | ||||
| 230 | + | // Check against common TLDs or validate length/format | |||
| 231 | + | return commonTLDs[tld] || (len(tld) >= 2 && len(tld) <= 6) | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | // isIPAddress checks if the input is a valid IP address. | |||
| 235 | + | func (v *URLValidator) isIPAddress(input string) bool { | |||
| 236 | + | // Try parsing as IP | |||
| 237 | + | ip := net.ParseIP(input) | |||
| 238 | + | return ip != nil | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // looksLikeDomain checks if the input looks like a domain (more lenient). | |||
| 242 | + | func (v *URLValidator) looksLikeDomain(input string) bool { | |||
| 243 | + | // Extract just the domain part if there's a path | |||
| 244 | + | domainPart := input | |||
| 245 | + | if slashIndex := strings.Index(input, "/"); slashIndex != -1 { | |||
| 246 | + | domainPart = input[:slashIndex] | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // Must contain at least one dot | |||
| 250 | + | if !strings.Contains(domainPart, ".") { | |||
| 251 | + | return false | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // Must not contain spaces | |||
| 255 | + | if strings.Contains(domainPart, " ") { | |||
| 256 | + | return false | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // Must not be too long | |||
| 260 | + | if len(domainPart) > 253 { | |||
| 261 | + | return false | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | // Check basic domain-like structure | |||
| 265 | + | parts := strings.Split(domainPart, ".") | |||
| 266 | + | if len(parts) < 2 { | |||
| 267 | + | return false | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | // Each part should be reasonable | |||
| 271 | + | for _, part := range parts { | |||
| 272 | + | if len(part) == 0 || len(part) > 63 { | |||
| 273 | + | return false | |||
| 274 | + | } | |||
| 275 | + | // Should start and end with alphanumeric | |||
| 276 | + | if len(part) > 0 { | |||
| 277 | + | if !isAlphaNumeric(part[0]) || !isAlphaNumeric(part[len(part)-1]) { | |||
| 278 | + | return false | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | return true | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | // isWellFormedURL checks if a URL with scheme is well-formed. | |||
| 287 | + | func (v *URLValidator) isWellFormedURL(rawURL string) bool { | |||
| 288 | + | parsedURL, err := url.Parse(rawURL) | |||
| 289 | + | if err != nil { | |||
| 290 | + | return false | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // Must have a scheme and host | |||
| 294 | + | if parsedURL.Scheme == "" || parsedURL.Host == "" { | |||
| 295 | + | return false | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // Validate scheme | |||
| 299 | + | validSchemes := map[string]bool{ | |||
| 300 | + | "http": true, "https": true, "ftp": true, "ftps": true, | |||
| 301 | + | "file": true, "mailto": true, "tel": true, "sms": true, | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | return validSchemes[strings.ToLower(parsedURL.Scheme)] | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | // HasFileExtension checks if the URL path has a file extension. | |||
| 308 | + | func (v *URLValidator) HasFileExtension(rawURL string) bool { | |||
| 309 | + | parsedURL, err := url.Parse(v.NormalizeURL(rawURL)) | |||
| 310 | + | if err != nil { | |||
| 311 | + | return false | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | path := parsedURL.Path | |||
| 315 | + | if path == "" || path == "/" { | |||
| 316 | + | return false | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // Check for file extension | |||
| 320 | + | lastSlash := strings.LastIndex(path, "/") | |||
| 321 | + | fileName := path | |||
| 322 | + | if lastSlash != -1 && lastSlash < len(path)-1 { | |||
| 323 | + | fileName = path[lastSlash+1:] | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | return strings.Contains(fileName, ".") && !strings.HasSuffix(fileName, ".") | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | // IsLocalhost checks if the URL is pointing to localhost. | |||
| 330 | + | func (v *URLValidator) IsLocalhost(rawURL string) bool { | |||
| 331 | + | // Handle direct localhost strings (no protocol) | |||
| 332 | + | rawURL = strings.ToLower(strings.TrimSpace(rawURL)) | |||
| 333 | + | if rawURL == "localhost" || rawURL == "127.0.0.1" || rawURL == "::1" { | |||
| 334 | + | return true | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | domain := v.ExtractDomain(rawURL) | |||
| 338 | + | domain = strings.ToLower(domain) | |||
| 339 | + | ||||
| 340 | + | // Remove port if present | |||
| 341 | + | if idx := strings.LastIndex(domain, ":"); idx != -1 { | |||
| 342 | + | portPart := domain[idx+1:] | |||
| 343 | + | if isNumeric(portPart) { | |||
| 344 | + | domain = domain[:idx] | |||
| 345 | + | } | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | return domain == "localhost" || | |||
| 349 | + | domain == "127.0.0.1" || | |||
| 350 | + | domain == "::1" || | |||
| 351 | + | strings.HasSuffix(domain, ".local") | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | // SanitizeInput sanitizes user input for URL parsing. | |||
| 355 | + | func (v *URLValidator) SanitizeInput(input string) string { | |||
| 356 | + | // Trim whitespace | |||
| 357 | + | input = strings.TrimSpace(input) | |||
| 358 | + | ||||
| 359 | + | // Remove control characters | |||
| 360 | + | result := make([]rune, 0, len(input)) | |||
| 361 | + | for _, r := range input { | |||
| 362 | + | if r >= 32 && r < 127 || r > 127 { // Keep printable ASCII and non-ASCII | |||
| 363 | + | result = append(result, r) | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | return string(result) | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | // Helper functions | |||
| 371 | + | ||||
| 372 | + | // isNumeric checks if a string contains only digits. | |||
| 373 | + | func isNumeric(s string) bool { | |||
| 374 | + | if s == "" { | |||
| 375 | + | return false | |||
| 376 | + | } | |||
| 377 | + | for _, r := range s { | |||
| 378 | + | if r < '0' || r > '9' { | |||
| 379 | + | return false | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | return true | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | // isAlphaNumeric checks if a character is alphanumeric. | |||
| 386 | + | func isAlphaNumeric(b byte) bool { | |||
| 387 | + | return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | // GetURLType determines the type of URL (direct, search, etc.). | |||
| 391 | + | func (v *URLValidator) GetURLType(input string) InputType { | |||
| 392 | + | input = v.SanitizeInput(input) | |||
| 393 | + | ||||
| 394 | + | // Check for direct URL first (this includes URLs with schemes) | |||
| 395 | + | if v.IsDirectURL(input) { | |||
| 396 | + | return InputTypeDirectURL | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | // Check for search shortcut (but exclude URL schemes) | |||
| 400 | + | if isShortcut, _, _ := v.IsSearchShortcut(input); isShortcut { | |||
| 401 | + | return InputTypeSearchShortcut | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | // Everything else is treated as search query initially | |||
| 405 | + | // The parser will determine if it should be history search or web search | |||
| 406 | + | return InputTypeHistorySearch | |||
| 407 | + | } | |||
A internal/parser/validation_test.go +526 -0
| @@ -0,0 +1,526 @@ | |||||
| 1 | + | package parser | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ) | |||
| 6 | + | ||||
| 7 | + | func TestURLValidator_IsValidURL(t *testing.T) { | |||
| 8 | + | validator := NewURLValidator() | |||
| 9 | + | ||||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | input string | |||
| 13 | + | expected bool | |||
| 14 | + | }{ | |||
| 15 | + | // Valid URLs with scheme | |||
| 16 | + | {"HTTPS URL", "https://example.com", true}, | |||
| 17 | + | {"HTTP URL", "http://example.com", true}, | |||
| 18 | + | {"URL with path", "https://example.com/path", true}, | |||
| 19 | + | {"URL with query", "https://example.com?q=test", true}, | |||
| 20 | + | ||||
| 21 | + | // Valid domains | |||
| 22 | + | {"Simple domain", "example.com", true}, | |||
| 23 | + | {"Subdomain", "www.example.com", true}, | |||
| 24 | + | {"Multiple subdomains", "api.v1.example.com", true}, | |||
| 25 | + | {"Domain with path", "example.com/path", true}, | |||
| 26 | + | ||||
| 27 | + | // Valid IP addresses | |||
| 28 | + | {"IPv4 address", "192.168.1.1", true}, | |||
| 29 | + | {"Localhost IP", "127.0.0.1", true}, | |||
| 30 | + | ||||
| 31 | + | // Invalid inputs | |||
| 32 | + | {"Empty string", "", false}, | |||
| 33 | + | {"Just text", "hello world", false}, | |||
| 34 | + | {"Single word", "test", false}, | |||
| 35 | + | {"Space in domain", "exam ple.com", false}, | |||
| 36 | + | {"Invalid TLD", "example.toolongtobevalid", false}, | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | for _, tt := range tests { | |||
| 40 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 41 | + | result := validator.IsValidURL(tt.input) | |||
| 42 | + | if result != tt.expected { | |||
| 43 | + | t.Errorf("IsValidURL(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 44 | + | } | |||
| 45 | + | }) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestURLValidator_IsDirectURL(t *testing.T) { | |||
| 50 | + | validator := NewURLValidator() | |||
| 51 | + | ||||
| 52 | + | tests := []struct { | |||
| 53 | + | name string | |||
| 54 | + | input string | |||
| 55 | + | expected bool | |||
| 56 | + | }{ | |||
| 57 | + | // Should be treated as direct URLs | |||
| 58 | + | {"Complete HTTPS URL", "https://github.com", true}, | |||
| 59 | + | {"Complete HTTP URL", "http://example.com", true}, | |||
| 60 | + | {"Domain only", "github.com", true}, | |||
| 61 | + | {"Subdomain", "api.github.com", true}, | |||
| 62 | + | {"Domain with path", "github.com/user/repo", true}, | |||
| 63 | + | {"IP address", "192.168.1.1", true}, | |||
| 64 | + | {"Localhost", "127.0.0.1", true}, | |||
| 65 | + | ||||
| 66 | + | // Should NOT be treated as direct URLs | |||
| 67 | + | {"Single word", "github", false}, | |||
| 68 | + | {"Search query", "golang tutorial", false}, | |||
| 69 | + | {"Shortcut format", "g: search query", false}, | |||
| 70 | + | {"Empty string", "", false}, | |||
| 71 | + | {"Just spaces", " ", false}, | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | for _, tt := range tests { | |||
| 75 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 76 | + | result := validator.IsDirectURL(tt.input) | |||
| 77 | + | if result != tt.expected { | |||
| 78 | + | t.Errorf("IsDirectURL(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 79 | + | } | |||
| 80 | + | }) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestURLValidator_IsSearchShortcut(t *testing.T) { | |||
| 85 | + | validator := NewURLValidator() | |||
| 86 | + | ||||
| 87 | + | tests := []struct { | |||
| 88 | + | name string | |||
| 89 | + | input string | |||
| 90 | + | expectShortcut bool | |||
| 91 | + | expectKey string | |||
| 92 | + | expectQuery string | |||
| 93 | + | }{ | |||
| 94 | + | { | |||
| 95 | + | name: "Google shortcut", | |||
| 96 | + | input: "g: golang tutorial", | |||
| 97 | + | expectShortcut: true, | |||
| 98 | + | expectKey: "g", | |||
| 99 | + | expectQuery: "golang tutorial", | |||
| 100 | + | }, | |||
| 101 | + | { | |||
| 102 | + | name: "GitHub shortcut", | |||
| 103 | + | input: "gh: cobra cli", | |||
| 104 | + | expectShortcut: true, | |||
| 105 | + | expectKey: "gh", | |||
| 106 | + | expectQuery: "cobra cli", | |||
| 107 | + | }, | |||
| 108 | + | { | |||
| 109 | + | name: "Stack Overflow shortcut", | |||
| 110 | + | input: "so: fuzzy search algorithm", | |||
| 111 | + | expectShortcut: true, | |||
| 112 | + | expectKey: "so", | |||
| 113 | + | expectQuery: "fuzzy search algorithm", | |||
| 114 | + | }, | |||
| 115 | + | { | |||
| 116 | + | name: "Empty query", | |||
| 117 | + | input: "g: ", | |||
| 118 | + | expectShortcut: true, | |||
| 119 | + | expectKey: "g", | |||
| 120 | + | expectQuery: "", | |||
| 121 | + | }, | |||
| 122 | + | { | |||
| 123 | + | name: "Numeric shortcut", | |||
| 124 | + | input: "123: test", | |||
| 125 | + | expectShortcut: true, | |||
| 126 | + | expectKey: "123", | |||
| 127 | + | expectQuery: "test", | |||
| 128 | + | }, | |||
| 129 | + | // Not shortcuts | |||
| 130 | + | { | |||
| 131 | + | name: "Just colon", | |||
| 132 | + | input: ":", | |||
| 133 | + | expectShortcut: false, | |||
| 134 | + | }, | |||
| 135 | + | { | |||
| 136 | + | name: "No colon", | |||
| 137 | + | input: "github search", | |||
| 138 | + | expectShortcut: false, | |||
| 139 | + | }, | |||
| 140 | + | { | |||
| 141 | + | name: "URL with colon", | |||
| 142 | + | input: "https://example.com", | |||
| 143 | + | expectShortcut: false, | |||
| 144 | + | }, | |||
| 145 | + | { | |||
| 146 | + | name: "Empty string", | |||
| 147 | + | input: "", | |||
| 148 | + | expectShortcut: false, | |||
| 149 | + | }, | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | for _, tt := range tests { | |||
| 153 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 154 | + | isShortcut, key, query := validator.IsSearchShortcut(tt.input) | |||
| 155 | + | ||||
| 156 | + | if isShortcut != tt.expectShortcut { | |||
| 157 | + | t.Errorf("IsSearchShortcut(%q) isShortcut = %v, want %v", tt.input, isShortcut, tt.expectShortcut) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | if tt.expectShortcut { | |||
| 161 | + | if key != tt.expectKey { | |||
| 162 | + | t.Errorf("IsSearchShortcut(%q) key = %q, want %q", tt.input, key, tt.expectKey) | |||
| 163 | + | } | |||
| 164 | + | if query != tt.expectQuery { | |||
| 165 | + | t.Errorf("IsSearchShortcut(%q) query = %q, want %q", tt.input, query, tt.expectQuery) | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | }) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestURLValidator_NormalizeURL(t *testing.T) { | |||
| 173 | + | validator := NewURLValidator() | |||
| 174 | + | ||||
| 175 | + | tests := []struct { | |||
| 176 | + | name string | |||
| 177 | + | input string | |||
| 178 | + | expected string | |||
| 179 | + | }{ | |||
| 180 | + | // Already have scheme | |||
| 181 | + | {"HTTPS URL", "https://example.com", "https://example.com"}, | |||
| 182 | + | {"HTTP URL", "http://example.com", "http://example.com"}, | |||
| 183 | + | ||||
| 184 | + | // Need scheme added | |||
| 185 | + | {"Domain only", "example.com", "https://example.com"}, | |||
| 186 | + | {"Subdomain", "www.example.com", "https://www.example.com"}, | |||
| 187 | + | {"Domain with path", "example.com/path", "https://example.com/path"}, | |||
| 188 | + | {"IP address", "192.168.1.1", "https://192.168.1.1"}, | |||
| 189 | + | ||||
| 190 | + | // Special cases | |||
| 191 | + | {"Empty string", "", ""}, | |||
| 192 | + | {"Just spaces", " ", ""}, | |||
| 193 | + | {"Non-URL text", "hello world", "hello world"}, // Should remain unchanged | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | for _, tt := range tests { | |||
| 197 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 198 | + | result := validator.NormalizeURL(tt.input) | |||
| 199 | + | if result != tt.expected { | |||
| 200 | + | t.Errorf("NormalizeURL(%q) = %q, want %q", tt.input, result, tt.expected) | |||
| 201 | + | } | |||
| 202 | + | }) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func TestURLValidator_ExtractDomain(t *testing.T) { | |||
| 207 | + | validator := NewURLValidator() | |||
| 208 | + | ||||
| 209 | + | tests := []struct { | |||
| 210 | + | name string | |||
| 211 | + | input string | |||
| 212 | + | expected string | |||
| 213 | + | }{ | |||
| 214 | + | // URLs with scheme | |||
| 215 | + | {"HTTPS URL", "https://github.com", "github.com"}, | |||
| 216 | + | {"HTTP URL", "http://example.com", "example.com"}, | |||
| 217 | + | {"URL with path", "https://github.com/user/repo", "github.com"}, | |||
| 218 | + | {"URL with query", "https://google.com/search?q=test", "google.com"}, | |||
| 219 | + | {"URL with port", "https://localhost:8080", "localhost"}, | |||
| 220 | + | ||||
| 221 | + | // Domains without scheme | |||
| 222 | + | {"Domain only", "github.com", "github.com"}, | |||
| 223 | + | {"Subdomain", "api.github.com", "api.github.com"}, | |||
| 224 | + | {"Domain with path", "github.com/user/repo", "github.com"}, | |||
| 225 | + | ||||
| 226 | + | // IP addresses | |||
| 227 | + | {"IPv4", "192.168.1.1", "192.168.1.1"}, | |||
| 228 | + | {"IPv4 with port", "192.168.1.1:8080", "192.168.1.1"}, | |||
| 229 | + | ||||
| 230 | + | // Edge cases | |||
| 231 | + | {"Empty string", "", ""}, | |||
| 232 | + | {"Just path", "/path/to/resource", ""}, // Invalid input returns empty | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | for _, tt := range tests { | |||
| 236 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 237 | + | result := validator.ExtractDomain(tt.input) | |||
| 238 | + | if result != tt.expected { | |||
| 239 | + | t.Errorf("ExtractDomain(%q) = %q, want %q", tt.input, result, tt.expected) | |||
| 240 | + | } | |||
| 241 | + | }) | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func TestURLValidator_HasFileExtension(t *testing.T) { | |||
| 246 | + | validator := NewURLValidator() | |||
| 247 | + | ||||
| 248 | + | tests := []struct { | |||
| 249 | + | name string | |||
| 250 | + | input string | |||
| 251 | + | expected bool | |||
| 252 | + | }{ | |||
| 253 | + | // URLs with file extensions | |||
| 254 | + | {"HTML file", "https://example.com/index.html", true}, | |||
| 255 | + | {"PDF file", "https://example.com/document.pdf", true}, | |||
| 256 | + | {"Image file", "https://example.com/image.jpg", true}, | |||
| 257 | + | {"File in subdirectory", "https://example.com/docs/readme.txt", true}, | |||
| 258 | + | ||||
| 259 | + | // URLs without file extensions | |||
| 260 | + | {"Root URL", "https://example.com", false}, | |||
| 261 | + | {"Root with slash", "https://example.com/", false}, | |||
| 262 | + | {"Directory path", "https://example.com/docs/", false}, | |||
| 263 | + | {"Path without extension", "https://example.com/about", false}, | |||
| 264 | + | ||||
| 265 | + | // Edge cases | |||
| 266 | + | {"Dot at end", "https://example.com/file.", false}, | |||
| 267 | + | {"Multiple dots", "https://example.com/file.name.ext", true}, | |||
| 268 | + | {"Query params", "https://example.com/file.html?q=test", true}, | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | for _, tt := range tests { | |||
| 272 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 273 | + | result := validator.HasFileExtension(tt.input) | |||
| 274 | + | if result != tt.expected { | |||
| 275 | + | t.Errorf("HasFileExtension(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 276 | + | } | |||
| 277 | + | }) | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | func TestURLValidator_IsLocalhost(t *testing.T) { | |||
| 282 | + | validator := NewURLValidator() | |||
| 283 | + | ||||
| 284 | + | tests := []struct { | |||
| 285 | + | name string | |||
| 286 | + | input string | |||
| 287 | + | expected bool | |||
| 288 | + | }{ | |||
| 289 | + | // Localhost variants | |||
| 290 | + | {"localhost", "http://localhost", true}, | |||
| 291 | + | {"localhost with port", "http://localhost:3000", true}, | |||
| 292 | + | {"localhost HTTPS", "https://localhost:8080", true}, | |||
| 293 | + | {"127.0.0.1", "http://127.0.0.1", true}, | |||
| 294 | + | {"127.0.0.1 with port", "http://127.0.0.1:8080", true}, | |||
| 295 | + | {"IPv6 localhost", "http://[::1]", true}, | |||
| 296 | + | {"Local domain", "http://app.local", true}, | |||
| 297 | + | ||||
| 298 | + | // Non-localhost | |||
| 299 | + | {"example.com", "https://example.com", false}, | |||
| 300 | + | {"github.com", "https://github.com", false}, | |||
| 301 | + | {"127.0.0.2", "http://127.0.0.2", false}, // Different local IP | |||
| 302 | + | {"192.168.1.1", "http://192.168.1.1", false}, // Private network but not localhost | |||
| 303 | + | ||||
| 304 | + | // Edge cases | |||
| 305 | + | {"Empty string", "", false}, | |||
| 306 | + | {"Just localhost", "localhost", true}, | |||
| 307 | + | {"Just IP", "127.0.0.1", true}, | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | for _, tt := range tests { | |||
| 311 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 312 | + | result := validator.IsLocalhost(tt.input) | |||
| 313 | + | if result != tt.expected { | |||
| 314 | + | t.Errorf("IsLocalhost(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 315 | + | } | |||
| 316 | + | }) | |||
| 317 | + | } | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | func TestURLValidator_SanitizeInput(t *testing.T) { | |||
| 321 | + | validator := NewURLValidator() | |||
| 322 | + | ||||
| 323 | + | tests := []struct { | |||
| 324 | + | name string | |||
| 325 | + | input string | |||
| 326 | + | expected string | |||
| 327 | + | }{ | |||
| 328 | + | {"Normal text", "github.com", "github.com"}, | |||
| 329 | + | {"Text with spaces", " github.com ", "github.com"}, | |||
| 330 | + | {"Text with tabs", "\tgithub.com\t", "github.com"}, | |||
| 331 | + | {"Text with newlines", "\ngithub.com\n", "github.com"}, | |||
| 332 | + | {"Unicode text", "github.com/ñoño", "github.com/ñoño"}, | |||
| 333 | + | {"Empty string", "", ""}, | |||
| 334 | + | {"Only whitespace", " \t\n ", ""}, | |||
| 335 | + | {"Mixed whitespace and text", " \t github.com \n ", "github.com"}, | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | for _, tt := range tests { | |||
| 339 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 340 | + | result := validator.SanitizeInput(tt.input) | |||
| 341 | + | if result != tt.expected { | |||
| 342 | + | t.Errorf("SanitizeInput(%q) = %q, want %q", tt.input, result, tt.expected) | |||
| 343 | + | } | |||
| 344 | + | }) | |||
| 345 | + | } | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | func TestURLValidator_GetURLType(t *testing.T) { | |||
| 349 | + | validator := NewURLValidator() | |||
| 350 | + | ||||
| 351 | + | tests := []struct { | |||
| 352 | + | name string | |||
| 353 | + | input string | |||
| 354 | + | expected InputType | |||
| 355 | + | }{ | |||
| 356 | + | // Direct URLs | |||
| 357 | + | {"HTTPS URL", "https://github.com", InputTypeDirectURL}, | |||
| 358 | + | {"Domain", "github.com", InputTypeDirectURL}, | |||
| 359 | + | {"IP address", "192.168.1.1", InputTypeDirectURL}, | |||
| 360 | + | ||||
| 361 | + | // Search shortcuts | |||
| 362 | + | {"Google shortcut", "g: golang tutorial", InputTypeSearchShortcut}, | |||
| 363 | + | {"GitHub shortcut", "gh: cobra cli", InputTypeSearchShortcut}, | |||
| 364 | + | ||||
| 365 | + | // History search (everything else initially) | |||
| 366 | + | {"Single word", "github", InputTypeHistorySearch}, | |||
| 367 | + | {"Search phrase", "golang tutorial", InputTypeHistorySearch}, | |||
| 368 | + | {"Question", "how to use fuzzy search", InputTypeHistorySearch}, | |||
| 369 | + | {"Empty string", "", InputTypeHistorySearch}, | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | for _, tt := range tests { | |||
| 373 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 374 | + | result := validator.GetURLType(tt.input) | |||
| 375 | + | if result != tt.expected { | |||
| 376 | + | t.Errorf("GetURLType(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 377 | + | } | |||
| 378 | + | }) | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | func TestURLValidator_IsDomain(t *testing.T) { | |||
| 383 | + | validator := NewURLValidator() | |||
| 384 | + | ||||
| 385 | + | tests := []struct { | |||
| 386 | + | name string | |||
| 387 | + | input string | |||
| 388 | + | expected bool | |||
| 389 | + | }{ | |||
| 390 | + | // Valid domains | |||
| 391 | + | {"Simple domain", "example.com", true}, | |||
| 392 | + | {"Subdomain", "www.example.com", true}, | |||
| 393 | + | {"Multiple subdomains", "api.v1.example.com", true}, | |||
| 394 | + | {"Country TLD", "example.co.uk", true}, | |||
| 395 | + | {"New TLD", "example.dev", true}, | |||
| 396 | + | ||||
| 397 | + | // Invalid domains | |||
| 398 | + | {"Single word", "example", false}, | |||
| 399 | + | {"No TLD", "www.example", false}, | |||
| 400 | + | {"Starts with dot", ".example.com", false}, | |||
| 401 | + | {"Ends with dot", "example.com.", false}, | |||
| 402 | + | {"Contains spaces", "exam ple.com", false}, | |||
| 403 | + | {"Too long segment", "verylongsubdomainthatexceedssixtyThreecharacterslimitfordomainsegments.com", false}, | |||
| 404 | + | {"Empty string", "", false}, | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | for _, tt := range tests { | |||
| 408 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 409 | + | result := validator.isDomain(tt.input) | |||
| 410 | + | if result != tt.expected { | |||
| 411 | + | t.Errorf("isDomain(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 412 | + | } | |||
| 413 | + | }) | |||
| 414 | + | } | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | func TestURLValidator_IsIPAddress(t *testing.T) { | |||
| 418 | + | validator := NewURLValidator() | |||
| 419 | + | ||||
| 420 | + | tests := []struct { | |||
| 421 | + | name string | |||
| 422 | + | input string | |||
| 423 | + | expected bool | |||
| 424 | + | }{ | |||
| 425 | + | // Valid IPv4 | |||
| 426 | + | {"Standard IPv4", "192.168.1.1", true}, | |||
| 427 | + | {"Localhost IPv4", "127.0.0.1", true}, | |||
| 428 | + | {"Zero IPv4", "0.0.0.0", true}, | |||
| 429 | + | {"Max IPv4", "255.255.255.255", true}, | |||
| 430 | + | ||||
| 431 | + | // Valid IPv6 | |||
| 432 | + | {"Standard IPv6", "2001:db8::1", true}, | |||
| 433 | + | {"Localhost IPv6", "::1", true}, | |||
| 434 | + | ||||
| 435 | + | // Invalid IPs | |||
| 436 | + | {"Invalid IPv4 (out of range)", "256.256.256.256", false}, | |||
| 437 | + | {"Invalid IPv4 (too many octets)", "192.168.1.1.1", false}, | |||
| 438 | + | {"Invalid IPv4 (too few octets)", "192.168.1", false}, | |||
| 439 | + | {"Not an IP", "example.com", false}, | |||
| 440 | + | {"Empty string", "", false}, | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | for _, tt := range tests { | |||
| 444 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 445 | + | result := validator.isIPAddress(tt.input) | |||
| 446 | + | if result != tt.expected { | |||
| 447 | + | t.Errorf("isIPAddress(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 448 | + | } | |||
| 449 | + | }) | |||
| 450 | + | } | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | func TestURLValidator_LooksLikeDomain(t *testing.T) { | |||
| 454 | + | validator := NewURLValidator() | |||
| 455 | + | ||||
| 456 | + | tests := []struct { | |||
| 457 | + | name string | |||
| 458 | + | input string | |||
| 459 | + | expected bool | |||
| 460 | + | }{ | |||
| 461 | + | // Should look like domains | |||
| 462 | + | {"Standard domain", "example.com", true}, | |||
| 463 | + | {"Subdomain", "www.example.com", true}, | |||
| 464 | + | {"Uncommon TLD", "example.xyz", true}, | |||
| 465 | + | {"Multiple levels", "a.b.c.example.com", true}, | |||
| 466 | + | ||||
| 467 | + | // Should not look like domains | |||
| 468 | + | {"No dot", "example", false}, | |||
| 469 | + | {"Has space", "exam ple.com", false}, | |||
| 470 | + | {"Too long", string(make([]rune, 300)), false}, // Very long string | |||
| 471 | + | {"Empty segment", "example..com", false}, | |||
| 472 | + | {"Starts with non-alphanumeric", "-example.com", false}, | |||
| 473 | + | {"Ends with non-alphanumeric", "example-.com", false}, | |||
| 474 | + | {"Empty string", "", false}, | |||
| 475 | + | } | |||
| 476 | + | ||||
| 477 | + | for _, tt := range tests { | |||
| 478 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 479 | + | result := validator.looksLikeDomain(tt.input) | |||
| 480 | + | if result != tt.expected { | |||
| 481 | + | t.Errorf("looksLikeDomain(%q) = %v, want %v", tt.input, result, tt.expected) | |||
| 482 | + | } | |||
| 483 | + | }) | |||
| 484 | + | } | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | func BenchmarkURLValidator_IsValidURL(b *testing.B) { | |||
| 488 | + | validator := NewURLValidator() | |||
| 489 | + | testInputs := []string{ | |||
| 490 | + | "https://github.com", | |||
| 491 | + | "github.com", | |||
| 492 | + | "192.168.1.1", | |||
| 493 | + | "g: search query", | |||
| 494 | + | "just some text", | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | b.ResetTimer() | |||
| 498 | + | for i := 0; i < b.N; i++ { | |||
| 499 | + | validator.IsValidURL(testInputs[i%len(testInputs)]) | |||
| 500 | + | } | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | func BenchmarkURLValidator_IsDirectURL(b *testing.B) { | |||
| 504 | + | validator := NewURLValidator() | |||
| 505 | + | testInputs := []string{ | |||
| 506 | + | "https://github.com", | |||
| 507 | + | "github.com", | |||
| 508 | + | "search query", | |||
| 509 | + | "g: shortcut", | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | b.ResetTimer() | |||
| 513 | + | for i := 0; i < b.N; i++ { | |||
| 514 | + | validator.IsDirectURL(testInputs[i%len(testInputs)]) | |||
| 515 | + | } | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | func BenchmarkURLValidator_ExtractDomain(b *testing.B) { | |||
| 519 | + | validator := NewURLValidator() | |||
| 520 | + | testURL := "https://api.github.com/users/octocat/repos" | |||
| 521 | + | ||||
| 522 | + | b.ResetTimer() | |||
| 523 | + | for i := 0; i < b.N; i++ { | |||
| 524 | + | validator.ExtractDomain(testURL) | |||
| 525 | + | } | |||
| 526 | + | } | |||
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 | + | }⏎̸ | |||
M memory/constitution.md +77 -35
| @@ -1,50 +1,92 @@ | |||||
| 1 | - | # [PROJECT_NAME] Constitution | 1 | + | # Dumb Browser Constitution |
| 2 | - | <!-- Example: Spec Constitution, TaskFlow Constitution, etc. --> | |||
| 3 | 2 | ||||
| 4 | ## Core Principles | 3 | ## Core Principles | ||
| 5 | 4 | ||||
| 6 | - | ### [PRINCIPLE_1_NAME] | 5 | + | ### I. Speed First (NON-NEGOTIABLE) |
| 7 | - | <!-- Example: I. Library-First --> | 6 | + | - Fast startup time is paramount - no feature worth compromising speed |
| 8 | - | [PRINCIPLE_1_DESCRIPTION] | 7 | + | - Single binary with embedded assets - no external dependencies at runtime |
| 9 | - | <!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries --> | 8 | + | - WebKit2GTK for rendering - full compliance without reinventing wheels |
| 9 | + | - Minimal UI overhead - focus on web content, not chrome | |||
| 10 | 10 | ||||
| 11 | - | ### [PRINCIPLE_2_NAME] | 11 | + | ### II. Universal Launcher Compatibility |
| 12 | - | <!-- Example: II. CLI Interface --> | 12 | + | - Support any dmenu-compatible launcher (fuzzel, rofi, tofi, wofi, bemenu) |
| 13 | - | [PRINCIPLE_2_DESCRIPTION] | 13 | + | - Standard stdin/stdout protocol - no launcher-specific integrations |
| 14 | - | <!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats --> | 14 | + | - Clean text-based format for history/suggestions |
| 15 | + | - Pipeline-friendly: `dumber --dmenu | launcher | dumber --dmenu` | |||
| 15 | 16 | ||||
| 16 | - | ### [PRINCIPLE_3_NAME] | 17 | + | ### III. Smart URL Handling |
| 17 | - | <!-- Example: III. Test-First (NON-NEGOTIABLE) --> | 18 | + | - Direct URL detection: `reddit.com` → navigate directly |
| 18 | - | [PRINCIPLE_3_DESCRIPTION] | 19 | + | - Search shortcuts: `g: golang tutorial` → Google search |
| 19 | - | <!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced --> | 20 | + | - History-first search: `reddit` → check history before web search |
| 21 | + | - No ambiguity - clear rules for input interpretation | |||
| 20 | 22 | ||||
| 21 | - | ### [PRINCIPLE_4_NAME] | 23 | + | ### IV. Privacy-Conscious History |
| 22 | - | <!-- Example: IV. Integration Testing --> | 24 | + | - Local SQLite database only - no cloud sync |
| 23 | - | [PRINCIPLE_4_DESCRIPTION] | 25 | + | - Configurable retention periods and cleanup |
| 24 | - | <!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas --> | 26 | + | - Visit counting for intelligent suggestions |
| 27 | + | - User controls data - easy to clear/export | |||
| 25 | 28 | ||||
| 26 | - | ### [PRINCIPLE_5_NAME] | 29 | + | ### V. Wayland Native |
| 27 | - | <!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity --> | 30 | + | - Built for modern Linux desktop - sway, hyprland, etc. |
| 28 | - | [PRINCIPLE_5_DESCRIPTION] | 31 | + | - WebKit2GTK provides native Wayland integration |
| 29 | - | <!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles --> | 32 | + | - Single instance handling for launcher integration |
| 33 | + | - Minimal resource usage - no background services | |||
| 30 | 34 | ||||
| 31 | - | ## [SECTION_2_NAME] | 35 | + | ## Technical Standards |
| 32 | - | <!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. --> | |||
| 33 | 36 | ||||
| 34 | - | [SECTION_2_CONTENT] | 37 | + | ### Stack Requirements |
| 35 | - | <!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. --> | 38 | + | - **Backend**: Go + Wails([v3-alpha](https://github.com/wailsapp/wails/tree/v3.0.0-alpha.28)) + WebKit2GTK + SQLite |
| 39 | + | - **CLI**: Cobra + Viper for configuration | |||
| 40 | + | - **Database**: SQLC for type-safe SQL generation | |||
| 41 | + | - **Validation**: Input sanitization libraries ([go-playground/validator](https://github.com/go-playground/validator)) | |||
| 42 | + | - **Build**: Single static binary with embedded frontend | |||
| 43 | + | - **Dependencies**: WebKit2GTK + GTK3 only (system packages) | |||
| 36 | 44 | ||||
| 37 | - | ## [SECTION_3_NAME] | 45 | + | ### Performance Targets |
| 38 | - | <!-- Example: Development Workflow, Review Process, Quality Gates, etc. --> | 46 | + | - Startup time: < 500ms from cold start |
| 47 | + | - Memory usage: < 100MB baseline + WebKit overhead | |||
| 48 | + | - History search: < 50ms for 1000+ entries | |||
| 49 | + | - Single instance lock: < 10ms response time | |||
| 39 | 50 | ||||
| 40 | - | [SECTION_3_CONTENT] | 51 | + | ### Quality Gates |
| 41 | - | <!-- Example: Code review requirements, testing gates, deployment approval process, etc. --> | 52 | + | - All database queries must use SQLC generated code |
| 53 | + | - All user input must be validated and sanitized | |||
| 54 | + | - Error handling: proper Go error chains, no panics in user paths | |||
| 55 | + | - Logging: structured logging for debugging, minimal in production (Slog for logging in text mode) | |||
| 56 | + | ||||
| 57 | + | ## Development Workflow | |||
| 58 | + | ||||
| 59 | + | ### Implementation Order | |||
| 60 | + | 1. Core CLI structure (Cobra + Viper) | |||
| 61 | + | 2. Database schema + SQLC integration | |||
| 62 | + | 3. URL parser + search engine logic with fuzzy finder algorithm | |||
| 63 | + | 4. History management (CRUD operations) | |||
| 64 | + | 5. Wails browser integration | |||
| 65 | + | 6. Dmenu compatibility layer | |||
| 66 | + | 7. Configuration system | |||
| 67 | + | ||||
| 68 | + | ### Testing Requirements | |||
| 69 | + | - Unit tests for URL parsing logic | |||
| 70 | + | - Integration tests for database operations | |||
| 71 | + | - Manual testing with fuzzel integration | |||
| 72 | + | - Cross-launcher compatibility verification | |||
| 73 | + | ||||
| 74 | + | ## Constraints | |||
| 75 | + | ||||
| 76 | + | ### Simplicity Mandate | |||
| 77 | + | - KISS principle - no over-engineering | |||
| 78 | + | - Clean Code philosophy throughout | |||
| 79 | + | - Single responsibility per module | |||
| 80 | + | - Minimal configuration surface | |||
| 81 | + | ||||
| 82 | + | ### Security Requirements | |||
| 83 | + | - No credential storage or session management | |||
| 84 | + | - Input sanitization for all user data | |||
| 85 | + | - SQL injection prevention via SQLC | |||
| 86 | + | - No network requests except web browsing | |||
| 42 | 87 | ||||
| 43 | ## Governance | 88 | ## Governance | ||
| 44 | - | <!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan --> | |||
| 45 | 89 | ||||
| 46 | - | [GOVERNANCE_RULES] | 90 | + | This constitution defines the project's core identity and technical direction. All implementation decisions must align with these principles, prioritizing speed and simplicity above feature richness. |
| 47 | - | <!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance --> | |||
| 48 | 91 | ||||
| 49 | - | **Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] | 92 | + | **Version**: 1.0.0 | **Ratified**: 2025-01-09 | **Last Amended**: 2025-01-09⏎̸ |
| 50 | - | <!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->⏎̸ | |||
A migrations/001_initial.sql +29 -0
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | -- Initial database schema for dumber | |||
| 2 | + | -- History tracking for visited URLs | |||
| 3 | + | ||||
| 4 | + | CREATE TABLE IF NOT EXISTS history ( | |||
| 5 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| 6 | + | url TEXT NOT NULL UNIQUE, | |||
| 7 | + | title TEXT, | |||
| 8 | + | visit_count INTEGER DEFAULT 1, | |||
| 9 | + | last_visited DATETIME DEFAULT CURRENT_TIMESTAMP, | |||
| 10 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 11 | + | ); | |||
| 12 | + | ||||
| 13 | + | CREATE INDEX idx_history_url ON history(url); | |||
| 14 | + | CREATE INDEX idx_history_last_visited ON history(last_visited); | |||
| 15 | + | ||||
| 16 | + | -- URL shortcuts configuration | |||
| 17 | + | CREATE TABLE IF NOT EXISTS shortcuts ( | |||
| 18 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| 19 | + | shortcut TEXT NOT NULL UNIQUE, | |||
| 20 | + | url_template TEXT NOT NULL, | |||
| 21 | + | description TEXT, | |||
| 22 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 23 | + | ); | |||
| 24 | + | ||||
| 25 | + | INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES | |||
| 26 | + | ('g', 'https://www.google.com/search?q=%s', 'Google search'), | |||
| 27 | + | ('gh', 'https://github.com/search?q=%s', 'GitHub search'), | |||
| 28 | + | ('yt', 'https://www.youtube.com/results?search_query=%s', 'YouTube search'), | |||
| 29 | + | ('w', 'https://en.wikipedia.org/wiki/%s', 'Wikipedia search');⏎̸ | |||
M scripts/check-task-prerequisites.sh +0 -0 mode 100644 → 100755
mode 100644 → 100755
M scripts/common.sh +0 -0 mode 100644 → 100755
mode 100644 → 100755
M scripts/create-new-feature.sh +0 -0 mode 100644 → 100755
mode 100644 → 100755
M scripts/get-feature-paths.sh +0 -0 mode 100644 → 100755
mode 100644 → 100755
M scripts/setup-plan.sh +0 -0 mode 100644 → 100755
mode 100644 → 100755
M scripts/update-agent-context.sh +0 -0 mode 100644 → 100755
mode 100644 → 100755
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 | + | }⏎̸ | |||
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 | + | }⏎̸ | |||
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 | + | }⏎̸ | |||
A specs/001-init-project/contracts/cli-commands.md +245 -0
| @@ -0,0 +1,245 @@ | |||||
| 1 | + | # CLI Commands Contract: Project Initialization | |||
| 2 | + | ||||
| 3 | + | **Date**: 2025-01-09 | |||
| 4 | + | **Context**: Command-line interface specification for project initialization | |||
| 5 | + | ||||
| 6 | + | ## Root Command | |||
| 7 | + | ||||
| 8 | + | ### `dumber init` | |||
| 9 | + | **Purpose**: Initialize the project with all dependencies | |||
| 10 | + | ||||
| 11 | + | **Syntax**: | |||
| 12 | + | ```bash | |||
| 13 | + | dumber init [OPTIONS] | |||
| 14 | + | ``` | |||
| 15 | + | ||||
| 16 | + | **Options**: | |||
| 17 | + | - `--dry-run`: Show what would be done without executing | |||
| 18 | + | - `--force`: Overwrite existing files if present | |||
| 19 | + | - `--verbose, -v`: Show detailed progress information | |||
| 20 | + | - `--go-version`: Specify Go version requirement (default: auto-detect) | |||
| 21 | + | ||||
| 22 | + | **Examples**: | |||
| 23 | + | ```bash | |||
| 24 | + | # Basic initialization | |||
| 25 | + | dumber init | |||
| 26 | + | ||||
| 27 | + | # Preview mode | |||
| 28 | + | dumber init --dry-run | |||
| 29 | + | ||||
| 30 | + | # Force reinitialize existing project | |||
| 31 | + | dumber init --force --verbose | |||
| 32 | + | ||||
| 33 | + | # Specify Go version | |||
| 34 | + | dumber init --go-version=1.21 | |||
| 35 | + | ``` | |||
| 36 | + | ||||
| 37 | + | **Exit Codes**: | |||
| 38 | + | - `0`: Success | |||
| 39 | + | - `1`: General error (invalid arguments, etc.) | |||
| 40 | + | - `2`: Dependency installation failed | |||
| 41 | + | - `3`: Tool installation failed | |||
| 42 | + | - `4`: Project already initialized (without --force) | |||
| 43 | + | ||||
| 44 | + | **Output Format**: | |||
| 45 | + | ``` | |||
| 46 | + | ✓ Initializing Go module: dumber | |||
| 47 | + | ✓ Installing CLI dependencies: cobra, viper | |||
| 48 | + | ✓ Installing database dependencies: sqlite3, sqlc | |||
| 49 | + | ✓ Installing validation: go-playground/validator | |||
| 50 | + | ✓ Installing testing: testify | |||
| 51 | + | ✓ Installing Wails v3-alpha... | |||
| 52 | + | ✓ Creating project structure... | |||
| 53 | + | ✓ Generating configuration files... | |||
| 54 | + | ✓ Verifying installation... | |||
| 55 | + | ||||
| 56 | + | Project initialized successfully! | |||
| 57 | + | ||||
| 58 | + | Next steps: | |||
| 59 | + | 1. Review generated configs/ | |||
| 60 | + | 2. Run: go build ./cmd/dumber | |||
| 61 | + | 3. Start development: dumber --help | |||
| 62 | + | ``` | |||
| 63 | + | ||||
| 64 | + | ## Subcommands | |||
| 65 | + | ||||
| 66 | + | ### `dumber init deps` | |||
| 67 | + | **Purpose**: Install only dependencies without project structure | |||
| 68 | + | ||||
| 69 | + | **Syntax**: | |||
| 70 | + | ```bash | |||
| 71 | + | dumber init deps [OPTIONS] | |||
| 72 | + | ``` | |||
| 73 | + | ||||
| 74 | + | **Options**: | |||
| 75 | + | - `--only`: Install specific dependency category (cli|db|validation|testing|wails) | |||
| 76 | + | - `--skip`: Skip specific dependency category | |||
| 77 | + | - `--latest`: Use @latest for all dependencies (default) | |||
| 78 | + | - `--check`: Verify dependencies without installing | |||
| 79 | + | ||||
| 80 | + | **Examples**: | |||
| 81 | + | ```bash | |||
| 82 | + | # Install only CLI dependencies | |||
| 83 | + | dumber init deps --only=cli | |||
| 84 | + | ||||
| 85 | + | # Install everything except Wails | |||
| 86 | + | dumber init deps --skip=wails | |||
| 87 | + | ||||
| 88 | + | # Check current dependency status | |||
| 89 | + | dumber init deps --check | |||
| 90 | + | ``` | |||
| 91 | + | ||||
| 92 | + | ### `dumber init structure` | |||
| 93 | + | **Purpose**: Create project directory structure only | |||
| 94 | + | ||||
| 95 | + | **Syntax**: | |||
| 96 | + | ```bash | |||
| 97 | + | dumber init structure [OPTIONS] | |||
| 98 | + | ``` | |||
| 99 | + | ||||
| 100 | + | **Options**: | |||
| 101 | + | - `--template`: Use specific structure template (default|minimal) | |||
| 102 | + | - `--preview`: Show directory tree without creating | |||
| 103 | + | ||||
| 104 | + | **Examples**: | |||
| 105 | + | ```bash | |||
| 106 | + | # Create full project structure | |||
| 107 | + | dumber init structure | |||
| 108 | + | ||||
| 109 | + | # Preview directory layout | |||
| 110 | + | dumber init structure --preview | |||
| 111 | + | ||||
| 112 | + | # Minimal structure (no example files) | |||
| 113 | + | dumber init structure --template=minimal | |||
| 114 | + | ``` | |||
| 115 | + | ||||
| 116 | + | ### `dumber init config` | |||
| 117 | + | **Purpose**: Generate configuration files only | |||
| 118 | + | ||||
| 119 | + | **Syntax**: | |||
| 120 | + | ```bash | |||
| 121 | + | dumber init config [OPTIONS] | |||
| 122 | + | ``` | |||
| 123 | + | ||||
| 124 | + | **Options**: | |||
| 125 | + | - `--type`: Generate specific config (sqlc|wails|all) | |||
| 126 | + | - `--overwrite`: Replace existing config files | |||
| 127 | + | ||||
| 128 | + | **Examples**: | |||
| 129 | + | ```bash | |||
| 130 | + | # Generate all config files | |||
| 131 | + | dumber init config | |||
| 132 | + | ||||
| 133 | + | # Only SQLC configuration | |||
| 134 | + | dumber init config --type=sqlc | |||
| 135 | + | ||||
| 136 | + | # Replace existing configs | |||
| 137 | + | dumber init config --overwrite | |||
| 138 | + | ``` | |||
| 139 | + | ||||
| 140 | + | ## Status and Information Commands | |||
| 141 | + | ||||
| 142 | + | ### `dumber init status` | |||
| 143 | + | **Purpose**: Show initialization status | |||
| 144 | + | ||||
| 145 | + | **Syntax**: | |||
| 146 | + | ```bash | |||
| 147 | + | dumber init status [OPTIONS] | |||
| 148 | + | ``` | |||
| 149 | + | ||||
| 150 | + | **Options**: | |||
| 151 | + | - `--format`: Output format (text|json|yaml) | |||
| 152 | + | - `--detailed`: Show dependency versions and paths | |||
| 153 | + | ||||
| 154 | + | **Output** (text format): | |||
| 155 | + | ``` | |||
| 156 | + | Project Status: ✓ Initialized | |||
| 157 | + | ||||
| 158 | + | Dependencies: | |||
| 159 | + | ✓ CLI (cobra v1.8.0, viper v1.18.2) | |||
| 160 | + | ✓ Database (sqlite3 v1.14.19, sqlc v1.25.0) | |||
| 161 | + | ✓ Validation (validator v10.16.0) | |||
| 162 | + | ✓ Testing (testify v1.8.4) | |||
| 163 | + | ✗ Wails (not installed) | |||
| 164 | + | ||||
| 165 | + | Structure: | |||
| 166 | + | ✓ cmd/, internal/, migrations/, configs/ | |||
| 167 | + | ✗ Missing: tests/integration/ | |||
| 168 | + | ||||
| 169 | + | Build Status: ✓ Ready (go build succeeds) | |||
| 170 | + | ``` | |||
| 171 | + | ||||
| 172 | + | ### `dumber init verify` | |||
| 173 | + | **Purpose**: Verify complete installation | |||
| 174 | + | ||||
| 175 | + | **Syntax**: | |||
| 176 | + | ```bash | |||
| 177 | + | dumber init verify [OPTIONS] | |||
| 178 | + | ``` | |||
| 179 | + | ||||
| 180 | + | **Options**: | |||
| 181 | + | - `--fix`: Attempt to fix detected issues | |||
| 182 | + | - `--report`: Generate detailed verification report | |||
| 183 | + | ||||
| 184 | + | **Exit Codes**: | |||
| 185 | + | - `0`: All verifications passed | |||
| 186 | + | - `1`: Some issues detected but not critical | |||
| 187 | + | - `2`: Critical issues prevent development | |||
| 188 | + | ||||
| 189 | + | ## Error Handling | |||
| 190 | + | ||||
| 191 | + | ### Common Error Scenarios | |||
| 192 | + | ||||
| 193 | + | **Dependency Installation Failures**: | |||
| 194 | + | ```bash | |||
| 195 | + | $ dumber init | |||
| 196 | + | ✗ Installing database dependencies: sqlite3 | |||
| 197 | + | Error: CGO_ENABLED=1 required for sqlite3 | |||
| 198 | + | Suggestion: Run 'export CGO_ENABLED=1' and try again | |||
| 199 | + | Exit Code: 2 | |||
| 200 | + | ``` | |||
| 201 | + | ||||
| 202 | + | **System Dependency Missing**: | |||
| 203 | + | ```bash | |||
| 204 | + | $ dumber init | |||
| 205 | + | ✗ Installing Wails v3-alpha | |||
| 206 | + | Error: libwebkit2gtk-4.0-dev not found | |||
| 207 | + | Suggestion: Install with 'sudo apt install libwebkit2gtk-4.0-dev' | |||
| 208 | + | Exit Code: 3 | |||
| 209 | + | ``` | |||
| 210 | + | ||||
| 211 | + | **Project Already Exists**: | |||
| 212 | + | ```bash | |||
| 213 | + | $ dumber init | |||
| 214 | + | ✗ Project already initialized | |||
| 215 | + | Use --force to reinitialize or run 'dumber init status' | |||
| 216 | + | Exit Code: 4 | |||
| 217 | + | ``` | |||
| 218 | + | ||||
| 219 | + | ## Integration with Other Systems | |||
| 220 | + | ||||
| 221 | + | ### CI/CD Integration | |||
| 222 | + | Commands designed for automated environments: | |||
| 223 | + | ```bash | |||
| 224 | + | # Non-interactive initialization | |||
| 225 | + | dumber init --verbose > init.log 2>&1 | |||
| 226 | + | ||||
| 227 | + | # Verification in CI | |||
| 228 | + | dumber init verify --report=ci-report.json | |||
| 229 | + | ``` | |||
| 230 | + | ||||
| 231 | + | ### Development Workflow Integration | |||
| 232 | + | ```bash | |||
| 233 | + | # Quick setup for new contributors | |||
| 234 | + | git clone <repo> && cd <repo> | |||
| 235 | + | dumber init | |||
| 236 | + | go test ./... | |||
| 237 | + | ``` | |||
| 238 | + | ||||
| 239 | + | ### IDE Integration | |||
| 240 | + | Status command provides machine-readable output: | |||
| 241 | + | ```bash | |||
| 242 | + | dumber init status --format=json | jq '.dependencies.wails.status' | |||
| 243 | + | ``` | |||
| 244 | + | ||||
| 245 | + | This CLI contract supports the initialization phase while providing flexibility for different development scenarios and debugging needs.⏎̸ | |||
A specs/001-init-project/contracts/go-module.yaml +152 -0
| @@ -0,0 +1,152 @@ | |||||
| 1 | + | # Go Module Contract: Project Dependencies | |||
| 2 | + | ||||
| 3 | + | # Expected go.mod file structure after initialization | |||
| 4 | + | module: dumber | |||
| 5 | + | go_version: "1.25" | |||
| 6 | + | ||||
| 7 | + | # Runtime dependencies (will be installed via go get @latest) | |||
| 8 | + | dependencies: | |||
| 9 | + | cli: | |||
| 10 | + | - import_path: "github.com/spf13/cobra" | |||
| 11 | + | purpose: "CLI framework and command handling" | |||
| 12 | + | min_version: "v1.8.0" | |||
| 13 | + | ||||
| 14 | + | - import_path: "github.com/spf13/viper" | |||
| 15 | + | purpose: "Configuration management" | |||
| 16 | + | min_version: "v1.18.0" | |||
| 17 | + | ||||
| 18 | + | database: | |||
| 19 | + | - import_path: "github.com/ncruces/go-sqlite3" | |||
| 20 | + | purpose: "CGO-free SQLite database driver" | |||
| 21 | + | min_version: "v0.28.0" | |||
| 22 | + | build_tags: [] | |||
| 23 | + | requirements: [] | |||
| 24 | + | benefits: ["no CGO required", "better performance", "cleaner builds"] | |||
| 25 | + | ||||
| 26 | + | validation: | |||
| 27 | + | - import_path: "github.com/go-playground/validator/v10" | |||
| 28 | + | purpose: "Struct validation and input sanitization" | |||
| 29 | + | min_version: "v10.16.0" | |||
| 30 | + | ||||
| 31 | + | testing: | |||
| 32 | + | - import_path: "github.com/stretchr/testify" | |||
| 33 | + | purpose: "Enhanced testing assertions and mocks" | |||
| 34 | + | min_version: "v1.8.4" | |||
| 35 | + | scope: "test" | |||
| 36 | + | ||||
| 37 | + | # Development tools (installed via go install) | |||
| 38 | + | tools: | |||
| 39 | + | - name: "sqlc" | |||
| 40 | + | install_command: "go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest" | |||
| 41 | + | purpose: "Generate type-safe Go code from SQL" | |||
| 42 | + | version_check: "sqlc version" | |||
| 43 | + | ||||
| 44 | + | - name: "wails" | |||
| 45 | + | install_command: "go install github.com/wailsapp/wails/v3/cmd/wails@v3.0.0-alpha.28" | |||
| 46 | + | purpose: "Desktop application framework" | |||
| 47 | + | version_check: "wails version" | |||
| 48 | + | system_deps: ["libwebkit2gtk-4.0-dev", "libgtk-3-dev"] | |||
| 49 | + | ||||
| 50 | + | # Expected go.mod content template | |||
| 51 | + | go_mod_template: | | |||
| 52 | + | module dumber | |||
| 53 | + | ||||
| 54 | + | go 1.21 | |||
| 55 | + | ||||
| 56 | + | require ( | |||
| 57 | + | github.com/spf13/cobra v1.8.0 | |||
| 58 | + | github.com/spf13/viper v1.18.2 | |||
| 59 | + | github.com/ncruces/go-sqlite3 v0.28.0 | |||
| 60 | + | github.com/go-playground/validator/v10 v10.16.0 | |||
| 61 | + | github.com/stretchr/testify v1.8.4 | |||
| 62 | + | ) | |||
| 63 | + | ||||
| 64 | + | # Compatibility matrix | |||
| 65 | + | compatibility: | |||
| 66 | + | go_version: | |||
| 67 | + | minimum: "1.18" # Required for generics | |||
| 68 | + | recommended: "1.21" | |||
| 69 | + | maximum: "1.22" | |||
| 70 | + | ||||
| 71 | + | operating_systems: | |||
| 72 | + | primary: "linux" | |||
| 73 | + | supported: ["linux", "darwin"] | |||
| 74 | + | tested: ["archlinux", "ubuntu-22.04"] | |||
| 75 | + | ||||
| 76 | + | architectures: | |||
| 77 | + | primary: "amd64" | |||
| 78 | + | supported: ["amd64", "arm64"] | |||
| 79 | + | ||||
| 80 | + | # Build configuration | |||
| 81 | + | build: | |||
| 82 | + | tags: | |||
| 83 | + | development: ["dev", "debug"] | |||
| 84 | + | production: [] | |||
| 85 | + | testing: ["test", "integration"] | |||
| 86 | + | ||||
| 87 | + | environment: | |||
| 88 | + | wails_build: | |||
| 89 | + | - name: "CGO_ENABLED" | |||
| 90 | + | value: "1" | |||
| 91 | + | reason: "Required for Wails WebKit2GTK bindings" | |||
| 92 | + | cgo_free_development: | |||
| 93 | + | - name: "CGO_ENABLED" | |||
| 94 | + | value: "0" | |||
| 95 | + | reason: "Most development can happen CGO-free (CLI, database, parser)" | |||
| 96 | + | ||||
| 97 | + | flags: | |||
| 98 | + | development: "-race -v" | |||
| 99 | + | production: "-ldflags='-s -w'" | |||
| 100 | + | ||||
| 101 | + | # Verification commands | |||
| 102 | + | verification: | |||
| 103 | + | module_valid: "go mod verify" | |||
| 104 | + | dependencies_resolved: "go mod download" | |||
| 105 | + | build_succeeds: "go build ./cmd/dumber" | |||
| 106 | + | tests_pass: "go test ./..." | |||
| 107 | + | tools_available: | |||
| 108 | + | - "sqlc version" | |||
| 109 | + | - "wails version" | |||
| 110 | + | ||||
| 111 | + | # Installation success criteria | |||
| 112 | + | success_criteria: | |||
| 113 | + | - name: "go.mod exists" | |||
| 114 | + | command: "test -f go.mod" | |||
| 115 | + | ||||
| 116 | + | - name: "module name correct" | |||
| 117 | + | command: "grep -q 'module dumber' go.mod" | |||
| 118 | + | ||||
| 119 | + | - name: "all dependencies resolved" | |||
| 120 | + | command: "go mod download && echo 'OK'" | |||
| 121 | + | ||||
| 122 | + | - name: "build succeeds" | |||
| 123 | + | command: "go build -o /tmp/test-build ./cmd/dumber" | |||
| 124 | + | cleanup: "rm -f /tmp/test-build" | |||
| 125 | + | ||||
| 126 | + | - name: "sqlc available" | |||
| 127 | + | command: "which sqlc && sqlc version" | |||
| 128 | + | ||||
| 129 | + | - name: "wails available" | |||
| 130 | + | command: "which wails && wails version" | |||
| 131 | + | ||||
| 132 | + | # Error conditions and solutions | |||
| 133 | + | error_handling: | |||
| 134 | + | cgo_disabled_for_wails: | |||
| 135 | + | error_pattern: "C compiler|CGO_ENABLED.*wails" | |||
| 136 | + | solution: "export CGO_ENABLED=1 # Only needed for Wails builds" | |||
| 137 | + | documentation: "Wails requires CGO for WebKit2GTK bindings (database layer is CGO-free)" | |||
| 138 | + | ||||
| 139 | + | missing_webkit: | |||
| 140 | + | error_pattern: "webkit2gtk|libwebkit2gtk" | |||
| 141 | + | solution: "sudo apt install libwebkit2gtk-4.0-dev libgtk-3-dev" | |||
| 142 | + | documentation: "Wails requires WebKit2GTK development headers" | |||
| 143 | + | ||||
| 144 | + | network_failure: | |||
| 145 | + | error_pattern: "dial tcp|connection refused|timeout" | |||
| 146 | + | solution: "Check internet connection and proxy settings" | |||
| 147 | + | documentation: "Dependency installation requires network access" | |||
| 148 | + | ||||
| 149 | + | insufficient_space: | |||
| 150 | + | error_pattern: "no space left|disk full" | |||
| 151 | + | solution: "Free up disk space and try again" | |||
| 152 | + | documentation: "Go module cache and tools require ~500MB space"⏎̸ | |||
A specs/001-init-project/data-model.md +172 -0
| @@ -0,0 +1,172 @@ | |||||
| 1 | + | # Data Model: Project Initialization | |||
| 2 | + | ||||
| 3 | + | **Date**: 2025-01-09 | |||
| 4 | + | **Context**: Data structures for dumber project initialization | |||
| 5 | + | ||||
| 6 | + | ## Core Entities | |||
| 7 | + | ||||
| 8 | + | ### 1. Go Module Configuration | |||
| 9 | + | **Purpose**: Represents the initialized Go module and its metadata | |||
| 10 | + | ||||
| 11 | + | **Structure**: | |||
| 12 | + | ```go | |||
| 13 | + | type ModuleConfig struct { | |||
| 14 | + | Name string `validate:"required"` // "dumber" | |||
| 15 | + | Version string `validate:"required,semver"` // "v0.1.0" | |||
| 16 | + | GoVersion string `validate:"required"` // Go version requirement | |||
| 17 | + | Dependencies []Dependency `validate:"dive"` | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | type Dependency struct { | |||
| 21 | + | ImportPath string `validate:"required,uri"` // e.g. "github.com/spf13/cobra" | |||
| 22 | + | Version string `validate:"required"` // "@latest" or specific version | |||
| 23 | + | Type DependencyType `validate:"required"` // runtime, dev, tool | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | type DependencyType int | |||
| 27 | + | const ( | |||
| 28 | + | RuntimeDep DependencyType = iota // Core application dependencies | |||
| 29 | + | DevDep // Development/testing tools | |||
| 30 | + | ToolDep // CLI tools (sqlc, wails) | |||
| 31 | + | ) | |||
| 32 | + | ``` | |||
| 33 | + | ||||
| 34 | + | **Validation Rules**: | |||
| 35 | + | - Module name must be valid Go identifier | |||
| 36 | + | - Version must follow semantic versioning | |||
| 37 | + | - All dependency paths must be valid import paths | |||
| 38 | + | - No circular dependencies allowed | |||
| 39 | + | ||||
| 40 | + | ### 2. Project Structure | |||
| 41 | + | **Purpose**: Defines the directory layout and file organization | |||
| 42 | + | ||||
| 43 | + | **Structure**: | |||
| 44 | + | ```go | |||
| 45 | + | type ProjectStructure struct { | |||
| 46 | + | RootPath string `validate:"required,dir"` | |||
| 47 | + | Directories []Directory `validate:"dive"` | |||
| 48 | + | ConfigFiles []ConfigFile `validate:"dive"` | |||
| 49 | + | InitialFiles []InitialFile `validate:"dive"` | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | type Directory struct { | |||
| 53 | + | Path string `validate:"required"` // relative to root | |||
| 54 | + | Purpose string `validate:"required"` // description | |||
| 55 | + | Required bool // must be created | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | type ConfigFile struct { | |||
| 59 | + | Path string `validate:"required"` // relative to root | |||
| 60 | + | Template string `validate:"required"` // template name | |||
| 61 | + | Variables map[string]string // template variables | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | type InitialFile struct { | |||
| 65 | + | Path string `validate:"required"` // relative to root | |||
| 66 | + | Content string // file content | |||
| 67 | + | Type FileType `validate:"required"` // go, sql, yaml, etc. | |||
| 68 | + | } | |||
| 69 | + | ``` | |||
| 70 | + | ||||
| 71 | + | ### 3. Dependency Installation Status | |||
| 72 | + | **Purpose**: Tracks installation progress and success/failure states | |||
| 73 | + | ||||
| 74 | + | **Structure**: | |||
| 75 | + | ```go | |||
| 76 | + | type InstallationStatus struct { | |||
| 77 | + | ModuleName string `validate:"required"` | |||
| 78 | + | Dependencies []DependencyStatus `validate:"dive"` | |||
| 79 | + | Tools []ToolStatus `validate:"dive"` | |||
| 80 | + | Overall StatusType `validate:"required"` | |||
| 81 | + | Timestamp time.Time `validate:"required"` | |||
| 82 | + | Errors []InstallError | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | type DependencyStatus struct { | |||
| 86 | + | ImportPath string `validate:"required"` | |||
| 87 | + | Version string | |||
| 88 | + | Status StatusType `validate:"required"` | |||
| 89 | + | InstallTime time.Duration // how long to install | |||
| 90 | + | Error *InstallError | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | type ToolStatus struct { | |||
| 94 | + | Name string `validate:"required"` // "sqlc", "wails" | |||
| 95 | + | Command string `validate:"required"` // installation command | |||
| 96 | + | Status StatusType `validate:"required"` | |||
| 97 | + | Version string // installed version | |||
| 98 | + | Error *InstallError | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | type StatusType int | |||
| 102 | + | const ( | |||
| 103 | + | NotStarted StatusType = iota | |||
| 104 | + | InProgress | |||
| 105 | + | Success | |||
| 106 | + | Failed | |||
| 107 | + | Skipped | |||
| 108 | + | ) | |||
| 109 | + | ||||
| 110 | + | type InstallError struct { | |||
| 111 | + | Message string `validate:"required"` | |||
| 112 | + | Code int | |||
| 113 | + | Suggestion string // helpful hint for user | |||
| 114 | + | } | |||
| 115 | + | ``` | |||
| 116 | + | ||||
| 117 | + | ## State Transitions | |||
| 118 | + | ||||
| 119 | + | ### Module Initialization Flow | |||
| 120 | + | ``` | |||
| 121 | + | Empty Directory → Module Init → Dependency Resolution → Tool Installation → Verification → Complete | |||
| 122 | + | ↓ ↓ ↓ ↓ | |||
| 123 | + | [go.mod] [go.mod updated] [tools available] [build succeeds] | |||
| 124 | + | ``` | |||
| 125 | + | ||||
| 126 | + | ### Dependency Installation States | |||
| 127 | + | ``` | |||
| 128 | + | NotStarted → InProgress → Success | |||
| 129 | + | ↓ | |||
| 130 | + | Failed (with retry option) | |||
| 131 | + | ``` | |||
| 132 | + | ||||
| 133 | + | ## Relationships | |||
| 134 | + | ||||
| 135 | + | 1. **ModuleConfig** contains multiple **Dependencies** | |||
| 136 | + | 2. **ProjectStructure** defines where **ConfigFiles** are placed | |||
| 137 | + | 3. **InstallationStatus** tracks the progress of **ModuleConfig** setup | |||
| 138 | + | 4. Each **Directory** in **ProjectStructure** serves specific **Dependencies** | |||
| 139 | + | ||||
| 140 | + | ## Validation Constraints | |||
| 141 | + | ||||
| 142 | + | ### Business Rules | |||
| 143 | + | 1. **Module name uniqueness**: No conflicts with existing Go modules in workspace | |||
| 144 | + | 2. **Dependency compatibility**: All dependencies must support the same Go version | |||
| 145 | + | 3. **Tool availability**: Required tools (sqlc, wails) must be installable | |||
| 146 | + | 4. **Directory permissions**: All target directories must be writable | |||
| 147 | + | 5. **Disk space**: Adequate space for dependencies and generated files | |||
| 148 | + | ||||
| 149 | + | ### Technical Constraints | |||
| 150 | + | 1. **Go version**: Must support generics (Go 1.18+) | |||
| 151 | + | 2. **CGO requirement**: SQLite driver requires CGO_ENABLED=1 | |||
| 152 | + | 3. **System dependencies**: Wails requires WebKit2GTK development headers | |||
| 153 | + | 4. **Network access**: Dependency download requires internet connectivity | |||
| 154 | + | ||||
| 155 | + | ## Integration Points | |||
| 156 | + | ||||
| 157 | + | ### With CLI Commands | |||
| 158 | + | - `dumber init` → Creates **ModuleConfig** and **ProjectStructure** | |||
| 159 | + | - `dumber deps install` → Updates **InstallationStatus** | |||
| 160 | + | - `dumber deps verify` → Validates all **Dependencies** | |||
| 161 | + | ||||
| 162 | + | ### With Configuration Files | |||
| 163 | + | - **go.mod**: Generated from **ModuleConfig** | |||
| 164 | + | - **sqlc.yaml**: Generated from database **Dependencies** | |||
| 165 | + | - **wails.json**: Generated from Wails **Dependencies** | |||
| 166 | + | ||||
| 167 | + | ### With Testing Framework | |||
| 168 | + | - Integration tests validate each **StatusType** transition | |||
| 169 | + | - Contract tests verify **ConfigFile** template generation | |||
| 170 | + | - Unit tests cover **Dependency** validation logic | |||
| 171 | + | ||||
| 172 | + | This data model supports the initialization phase while preparing for the next development phases (CLI implementation, database setup, browser integration).⏎̸ | |||
A specs/001-init-project/plan.md +252 -0
| @@ -0,0 +1,252 @@ | |||||
| 1 | + | # Implementation Plan: Project Initialization with Dependencies | |||
| 2 | + | ||||
| 3 | + | **Branch**: `001-init-project` | **Date**: 2025-01-09 | **Spec**: [spec.md](./spec.md) | |||
| 4 | + | **Input**: Feature specification from `/home/brice/dev/projects/dumber/specs/001-init-project/spec.md` | |||
| 5 | + | ||||
| 6 | + | ## Execution Flow (/plan command scope) | |||
| 7 | + | ``` | |||
| 8 | + | 1. Load feature spec from Input path | |||
| 9 | + | → If not found: ERROR "No feature spec at {path}" | |||
| 10 | + | 2. Fill Technical Context (scan for NEEDS CLARIFICATION) | |||
| 11 | + | → Detect Project Type from context (web=frontend+backend, mobile=app+api) | |||
| 12 | + | → Set Structure Decision based on project type | |||
| 13 | + | 3. Evaluate Constitution Check section below | |||
| 14 | + | → If violations exist: Document in Complexity Tracking | |||
| 15 | + | → If no justification possible: ERROR "Simplify approach first" | |||
| 16 | + | → Update Progress Tracking: Initial Constitution Check | |||
| 17 | + | 4. Execute Phase 0 → research.md | |||
| 18 | + | → If NEEDS CLARIFICATION remain: ERROR "Resolve unknowns" | |||
| 19 | + | 5. Execute Phase 1 → contracts, data-model.md, quickstart.md, agent-specific template file (e.g., `CLAUDE.md` for Claude Code, `.github/copilot-instructions.md` for GitHub Copilot, or `GEMINI.md` for Gemini CLI). | |||
| 20 | + | 6. Re-evaluate Constitution Check section | |||
| 21 | + | → If new violations: Refactor design, return to Phase 1 | |||
| 22 | + | → Update Progress Tracking: Post-Design Constitution Check | |||
| 23 | + | 7. Plan Phase 2 → Describe task generation approach (DO NOT create tasks.md) | |||
| 24 | + | 8. STOP - Ready for /tasks command | |||
| 25 | + | ``` | |||
| 26 | + | ||||
| 27 | + | **IMPORTANT**: The /plan command STOPS at step 7. Phases 2-4 are executed by other commands: | |||
| 28 | + | - Phase 2: /tasks command creates tasks.md | |||
| 29 | + | - Phase 3-4: Implementation execution (manual or via tools) | |||
| 30 | + | ||||
| 31 | + | ## Summary | |||
| 32 | + | Initialize a Go project with all required dependencies for the dumber application, following the constitution's technical stack: Go + Wails v3-alpha + WebKit2GTK + SQLite with CLI (Cobra + Viper), database (SQLC), and validation (go-playground/validator) components. Use `go get @latest` for all dependencies. | |||
| 33 | + | ||||
| 34 | + | ## Technical Context | |||
| 35 | + | **User Details**: We need to add all deps using go get @latest | |||
| 36 | + | **Language/Version**: Go (latest compatible with Wails v3-alpha) | |||
| 37 | + | **Primary Dependencies**: Wails v3-alpha, Cobra, Viper, SQLC, go-playground/validator, SQLite driver | |||
| 38 | + | **Storage**: SQLite (local embedded database) | |||
| 39 | + | **Testing**: Go standard testing + testify for enhanced assertions | |||
| 40 | + | **Target Platform**: Linux Wayland (sway, hyprland) - primary focus | |||
| 41 | + | **Project Type**: single (desktop application with CLI + GUI components) | |||
| 42 | + | **Performance Goals**: < 500ms startup time, < 100MB baseline memory | |||
| 43 | + | **Constraints**: Single binary, WebKit2GTK system dependency only | |||
| 44 | + | **Scale/Scope**: Personal productivity tool, local history management (1000+ entries) | |||
| 45 | + | ||||
| 46 | + | ## Constitution Check | |||
| 47 | + | *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* | |||
| 48 | + | ||||
| 49 | + | **Simplicity**: | |||
| 50 | + | - Projects: 1 (single desktop app - dumber) | |||
| 51 | + | - Using framework directly? YES (Wails, Cobra, Viper directly) | |||
| 52 | + | - Single data model? YES (SQLite schema matches Go structs via SQLC) | |||
| 53 | + | - Avoiding patterns? YES (no unnecessary abstractions for initialization) | |||
| 54 | + | ||||
| 55 | + | **Architecture**: | |||
| 56 | + | - EVERY feature as library? YES (CLI, parser, database, browser as separate packages) | |||
| 57 | + | - Libraries planned: cli (cobra commands), db (sqlc queries), parser (URL handling), browser (Wails integration) | |||
| 58 | + | - CLI per library: main CLI with subcommands (--dmenu, history, config) | |||
| 59 | + | - Library docs: llms.txt format will be generated post-implementation | |||
| 60 | + | ||||
| 61 | + | **Testing (NON-NEGOTIABLE)**: | |||
| 62 | + | - RED-GREEN-Refactor cycle enforced? YES (tests for module initialization first) | |||
| 63 | + | - Git commits show tests before implementation? YES (test dependency resolution before implementation) | |||
| 64 | + | - Order: Contract→Integration→E2E→Unit strictly followed? YES | |||
| 65 | + | - Real dependencies used? YES (actual Go modules, SQLite DB) | |||
| 66 | + | - Integration tests for: module loading, dependency compatibility, build success | |||
| 67 | + | - FORBIDDEN: Implementation before test, skipping RED phase | |||
| 68 | + | ||||
| 69 | + | **Observability**: | |||
| 70 | + | - Structured logging included? YES (slog for debugging initialization) | |||
| 71 | + | - Frontend logs → backend? N/A for initialization phase | |||
| 72 | + | - Error context sufficient? YES (proper error chains for dependency failures) | |||
| 73 | + | ||||
| 74 | + | **Versioning**: | |||
| 75 | + | - Version number assigned? YES (v0.1.0 - initial development) | |||
| 76 | + | - BUILD increments on every change? YES (following semantic versioning) | |||
| 77 | + | - Breaking changes handled? N/A for initial setup | |||
| 78 | + | ||||
| 79 | + | ## Project Structure | |||
| 80 | + | ||||
| 81 | + | ### Documentation (this feature) | |||
| 82 | + | ``` | |||
| 83 | + | specs/[###-feature]/ | |||
| 84 | + | ├── plan.md # This file (/plan command output) | |||
| 85 | + | ├── research.md # Phase 0 output (/plan command) | |||
| 86 | + | ├── data-model.md # Phase 1 output (/plan command) | |||
| 87 | + | ├── quickstart.md # Phase 1 output (/plan command) | |||
| 88 | + | ├── contracts/ # Phase 1 output (/plan command) | |||
| 89 | + | └── tasks.md # Phase 2 output (/tasks command - NOT created by /plan) | |||
| 90 | + | ``` | |||
| 91 | + | ||||
| 92 | + | ### Source Code (repository root) | |||
| 93 | + | ``` | |||
| 94 | + | # Option 1: Single project (DEFAULT) | |||
| 95 | + | src/ | |||
| 96 | + | ├── models/ | |||
| 97 | + | ├── services/ | |||
| 98 | + | ├── cli/ | |||
| 99 | + | └── lib/ | |||
| 100 | + | ||||
| 101 | + | tests/ | |||
| 102 | + | ├── contract/ | |||
| 103 | + | ├── integration/ | |||
| 104 | + | └── unit/ | |||
| 105 | + | ||||
| 106 | + | # Option 2: Web application (when "frontend" + "backend" detected) | |||
| 107 | + | backend/ | |||
| 108 | + | ├── src/ | |||
| 109 | + | │ ├── models/ | |||
| 110 | + | │ ├── services/ | |||
| 111 | + | │ └── api/ | |||
| 112 | + | └── tests/ | |||
| 113 | + | ||||
| 114 | + | frontend/ | |||
| 115 | + | ├── src/ | |||
| 116 | + | │ ├── components/ | |||
| 117 | + | │ ├── pages/ | |||
| 118 | + | │ └── services/ | |||
| 119 | + | └── tests/ | |||
| 120 | + | ||||
| 121 | + | # Option 3: Mobile + API (when "iOS/Android" detected) | |||
| 122 | + | api/ | |||
| 123 | + | └── [same as backend above] | |||
| 124 | + | ||||
| 125 | + | ios/ or android/ | |||
| 126 | + | └── [platform-specific structure] | |||
| 127 | + | ``` | |||
| 128 | + | ||||
| 129 | + | **Structure Decision**: Option 1 - Single project (desktop application with CLI and GUI components) | |||
| 130 | + | ||||
| 131 | + | ## Phase 0: Outline & Research | |||
| 132 | + | 1. **Extract unknowns from Technical Context** above: | |||
| 133 | + | - For each NEEDS CLARIFICATION → research task | |||
| 134 | + | - For each dependency → best practices task | |||
| 135 | + | - For each integration → patterns task | |||
| 136 | + | ||||
| 137 | + | 2. **Generate and dispatch research agents**: | |||
| 138 | + | ``` | |||
| 139 | + | For each unknown in Technical Context: | |||
| 140 | + | Task: "Research {unknown} for {feature context}" | |||
| 141 | + | For each technology choice: | |||
| 142 | + | Task: "Find best practices for {tech} in {domain}" | |||
| 143 | + | ``` | |||
| 144 | + | ||||
| 145 | + | 3. **Consolidate findings** in `research.md` using format: | |||
| 146 | + | - Decision: [what was chosen] | |||
| 147 | + | - Rationale: [why chosen] | |||
| 148 | + | - Alternatives considered: [what else evaluated] | |||
| 149 | + | ||||
| 150 | + | **Output**: research.md with all NEEDS CLARIFICATION resolved | |||
| 151 | + | ||||
| 152 | + | ## Phase 1: Design & Contracts | |||
| 153 | + | *Prerequisites: research.md complete* | |||
| 154 | + | ||||
| 155 | + | 1. **Extract entities from feature spec** → `data-model.md`: | |||
| 156 | + | - Entity name, fields, relationships | |||
| 157 | + | - Validation rules from requirements | |||
| 158 | + | - State transitions if applicable | |||
| 159 | + | ||||
| 160 | + | 2. **Generate API contracts** from functional requirements: | |||
| 161 | + | - For each user action → endpoint | |||
| 162 | + | - Use standard REST/GraphQL patterns | |||
| 163 | + | - Output OpenAPI/GraphQL schema to `/contracts/` | |||
| 164 | + | ||||
| 165 | + | 3. **Generate contract tests** from contracts: | |||
| 166 | + | - One test file per endpoint | |||
| 167 | + | - Assert request/response schemas | |||
| 168 | + | - Tests must fail (no implementation yet) | |||
| 169 | + | ||||
| 170 | + | 4. **Extract test scenarios** from user stories: | |||
| 171 | + | - Each story → integration test scenario | |||
| 172 | + | - Quickstart test = story validation steps | |||
| 173 | + | ||||
| 174 | + | 5. **Update agent file incrementally** (O(1) operation): | |||
| 175 | + | - Run `/scripts/update-agent-context.sh [claude|gemini|copilot]` for your AI assistant | |||
| 176 | + | - If exists: Add only NEW tech from current plan | |||
| 177 | + | - Preserve manual additions between markers | |||
| 178 | + | - Update recent changes (keep last 3) | |||
| 179 | + | - Keep under 150 lines for token efficiency | |||
| 180 | + | - Output to repository root | |||
| 181 | + | ||||
| 182 | + | **Output**: data-model.md, /contracts/*, failing tests, quickstart.md, agent-specific file | |||
| 183 | + | ||||
| 184 | + | ## Phase 2: Task Planning Approach | |||
| 185 | + | *This section describes what the /tasks command will do - DO NOT execute during /plan* | |||
| 186 | + | ||||
| 187 | + | **Task Generation Strategy**: | |||
| 188 | + | - Load `/templates/tasks-template.md` as base | |||
| 189 | + | - Generate tasks from Phase 1 design docs (contracts, data model, quickstart) | |||
| 190 | + | - Prioritize CGO-free components first (faster development cycle) | |||
| 191 | + | - Each CLI command contract → contract test + implementation task | |||
| 192 | + | - Module initialization → dependency installation tasks | |||
| 193 | + | - Project structure → directory creation + config generation tasks | |||
| 194 | + | - Integration tests for each success criterion in quickstart | |||
| 195 | + | ||||
| 196 | + | **Ordering Strategy**: | |||
| 197 | + | - TDD order: Tests before implementation | |||
| 198 | + | - CGO-free first: CLI, parser, database (can develop with CGO_ENABLED=0) | |||
| 199 | + | - Wails integration last: Full application build (requires CGO_ENABLED=1) | |||
| 200 | + | - Dependency order: Go module → dependencies → structure → config → verification | |||
| 201 | + | - Mark [P] for parallel execution (independent dependency installations) | |||
| 202 | + | ||||
| 203 | + | **Key Task Categories**: | |||
| 204 | + | 1. **Module Setup Tasks**: go mod init, dependency installation | |||
| 205 | + | 2. **Structure Tasks**: Directory creation, config file generation | |||
| 206 | + | 3. **Verification Tasks**: Build tests, tool availability checks | |||
| 207 | + | 4. **Integration Tasks**: Full quickstart validation | |||
| 208 | + | ||||
| 209 | + | **CGO Optimization**: | |||
| 210 | + | - Most tasks can run with CGO_ENABLED=0 for faster iteration | |||
| 211 | + | - Only final integration tests require CGO_ENABLED=1 | |||
| 212 | + | - Clear separation allows parallel development of CGO-free components | |||
| 213 | + | ||||
| 214 | + | **Estimated Output**: 15-20 numbered, ordered tasks in tasks.md (streamlined for initialization focus) | |||
| 215 | + | ||||
| 216 | + | **IMPORTANT**: This phase is executed by the /tasks command, NOT by /plan | |||
| 217 | + | ||||
| 218 | + | ## Phase 3+: Future Implementation | |||
| 219 | + | *These phases are beyond the scope of the /plan command* | |||
| 220 | + | ||||
| 221 | + | **Phase 3**: Task execution (/tasks command creates tasks.md) | |||
| 222 | + | **Phase 4**: Implementation (execute tasks.md following constitutional principles) | |||
| 223 | + | **Phase 5**: Validation (run tests, execute quickstart.md, performance validation) | |||
| 224 | + | ||||
| 225 | + | ## Complexity Tracking | |||
| 226 | + | *Fill ONLY if Constitution Check has violations that must be justified* | |||
| 227 | + | ||||
| 228 | + | | Violation | Why Needed | Simpler Alternative Rejected Because | | |||
| 229 | + | |-----------|------------|-------------------------------------| | |||
| 230 | + | | [e.g., 4th project] | [current need] | [why 3 projects insufficient] | | |||
| 231 | + | | [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | | |||
| 232 | + | ||||
| 233 | + | ||||
| 234 | + | ## Progress Tracking | |||
| 235 | + | *This checklist is updated during execution flow* | |||
| 236 | + | ||||
| 237 | + | **Phase Status**: | |||
| 238 | + | - [x] Phase 0: Research complete (/plan command) | |||
| 239 | + | - [x] Phase 1: Design complete (/plan command) | |||
| 240 | + | - [x] Phase 2: Task planning complete (/plan command - describe approach only) | |||
| 241 | + | - [ ] Phase 3: Tasks generated (/tasks command) | |||
| 242 | + | - [ ] Phase 4: Implementation complete | |||
| 243 | + | - [ ] Phase 5: Validation passed | |||
| 244 | + | ||||
| 245 | + | **Gate Status**: | |||
| 246 | + | - [x] Initial Constitution Check: PASS | |||
| 247 | + | - [x] Post-Design Constitution Check: PASS | |||
| 248 | + | - [x] All NEEDS CLARIFICATION resolved | |||
| 249 | + | - [x] Complexity deviations documented (none - simplicity maintained) | |||
| 250 | + | ||||
| 251 | + | --- | |||
| 252 | + | *Based on Constitution v2.1.1 - See `/memory/constitution.md`*⏎̸ | |||
A specs/001-init-project/quickstart.md +247 -0
| @@ -0,0 +1,247 @@ | |||||
| 1 | + | # Quickstart: Project Initialization | |||
| 2 | + | ||||
| 3 | + | **Goal**: Validate that project initialization works correctly and all dependencies are properly installed. | |||
| 4 | + | ||||
| 5 | + | ## Prerequisites Verification | |||
| 6 | + | ||||
| 7 | + | Before running quickstart, verify system requirements: | |||
| 8 | + | ||||
| 9 | + | ```bash | |||
| 10 | + | # Check Go version (1.25+ required) | |||
| 11 | + | go version | |||
| 12 | + | # Expected: go version go1.25.x linux/amd64 | |||
| 13 | + | ||||
| 14 | + | # Check system packages for Wails (Arch Linux) | |||
| 15 | + | pacman -Q webkit2gtk gtk3 | |||
| 16 | + | # Expected: webkit2gtk-4.0 and gtk3 packages listed | |||
| 17 | + | ||||
| 18 | + | # For Ubuntu/Debian: | |||
| 19 | + | # dpkg -l | grep -E "(webkit2gtk|libgtk-3)" | |||
| 20 | + | ||||
| 21 | + | # Note: CGO only needed for Wails, not for database layer | |||
| 22 | + | ``` | |||
| 23 | + | ||||
| 24 | + | ## Step 1: Project Initialization | |||
| 25 | + | ||||
| 26 | + | Navigate to project root and initialize: | |||
| 27 | + | ||||
| 28 | + | ```bash | |||
| 29 | + | cd /home/brice/dev/projects/dumber | |||
| 30 | + | ||||
| 31 | + | # Initialize with verbose output to see progress | |||
| 32 | + | dumber init --verbose | |||
| 33 | + | ``` | |||
| 34 | + | ||||
| 35 | + | **Expected Output**: | |||
| 36 | + | ``` | |||
| 37 | + | ✓ Initializing Go module: dumber | |||
| 38 | + | ✓ Installing CLI dependencies: cobra, viper | |||
| 39 | + | ✓ Installing database dependencies: ncruces/go-sqlite3, sqlc | |||
| 40 | + | ✓ Installing validation: go-playground/validator | |||
| 41 | + | ✓ Installing testing: testify | |||
| 42 | + | ✓ Installing Wails v3-alpha... (requires CGO) | |||
| 43 | + | ✓ Creating project structure... | |||
| 44 | + | ✓ Generating configuration files... | |||
| 45 | + | ✓ Verifying installation... | |||
| 46 | + | ||||
| 47 | + | Project initialized successfully! | |||
| 48 | + | ||||
| 49 | + | Next steps: | |||
| 50 | + | 1. Review generated configs/ | |||
| 51 | + | 2. Run: go build ./cmd/dumber | |||
| 52 | + | 3. Start development: dumber --help | |||
| 53 | + | ``` | |||
| 54 | + | ||||
| 55 | + | ## Step 2: Verify Installation | |||
| 56 | + | ||||
| 57 | + | Check that all components are correctly installed: | |||
| 58 | + | ||||
| 59 | + | ```bash | |||
| 60 | + | # Verify project status | |||
| 61 | + | dumber init status | |||
| 62 | + | ||||
| 63 | + | # Expected output: | |||
| 64 | + | # Project Status: ✓ Initialized | |||
| 65 | + | # Dependencies: ✓ All installed | |||
| 66 | + | # Structure: ✓ Complete | |||
| 67 | + | # Build Status: ✓ Ready | |||
| 68 | + | ``` | |||
| 69 | + | ||||
| 70 | + | Detailed verification: | |||
| 71 | + | ||||
| 72 | + | ```bash | |||
| 73 | + | # Check go.mod exists and is valid | |||
| 74 | + | test -f go.mod && echo "✓ go.mod exists" | |||
| 75 | + | grep -q "module dumber" go.mod && echo "✓ Module name correct" | |||
| 76 | + | ||||
| 77 | + | # Verify dependencies are downloadable | |||
| 78 | + | go mod download && echo "✓ Dependencies resolved" | |||
| 79 | + | ||||
| 80 | + | # Check tools are available | |||
| 81 | + | sqlc version && echo "✓ SQLC available" | |||
| 82 | + | wails version && echo "✓ Wails available" | |||
| 83 | + | ``` | |||
| 84 | + | ||||
| 85 | + | ## Step 3: Test CGO-Free Components | |||
| 86 | + | ||||
| 87 | + | Most components can be tested without CGO enabled: | |||
| 88 | + | ||||
| 89 | + | ```bash | |||
| 90 | + | # Test CGO-free database layer | |||
| 91 | + | export CGO_ENABLED=0 | |||
| 92 | + | go build ./internal/db && echo "✓ Database layer builds CGO-free" | |||
| 93 | + | go test ./internal/db && echo "✓ Database tests pass CGO-free" | |||
| 94 | + | ||||
| 95 | + | # Test CLI components | |||
| 96 | + | go build ./internal/cli && echo "✓ CLI builds CGO-free" | |||
| 97 | + | go test ./internal/cli && echo "✓ CLI tests pass CGO-free" | |||
| 98 | + | ||||
| 99 | + | # Test URL parser | |||
| 100 | + | go build ./internal/parser && echo "✓ Parser builds CGO-free" | |||
| 101 | + | go test ./internal/parser && echo "✓ Parser tests pass CGO-free" | |||
| 102 | + | ``` | |||
| 103 | + | ||||
| 104 | + | ## Step 4: Test Wails Integration (CGO Required) | |||
| 105 | + | ||||
| 106 | + | Test the full application with Wails: | |||
| 107 | + | ||||
| 108 | + | ```bash | |||
| 109 | + | # Enable CGO for Wails | |||
| 110 | + | export CGO_ENABLED=1 | |||
| 111 | + | ||||
| 112 | + | # Test full application build | |||
| 113 | + | go build ./cmd/dumber && echo "✓ Full build successful" | |||
| 114 | + | ||||
| 115 | + | # Clean up test binary | |||
| 116 | + | rm -f dumber | |||
| 117 | + | ``` | |||
| 118 | + | ||||
| 119 | + | ## Step 5: Test Project Structure | |||
| 120 | + | ||||
| 121 | + | Verify the correct directory structure was created: | |||
| 122 | + | ||||
| 123 | + | ```bash | |||
| 124 | + | # Check main directories exist | |||
| 125 | + | test -d cmd && echo "✓ cmd/ directory" | |||
| 126 | + | test -d internal && echo "✓ internal/ directory" | |||
| 127 | + | test -d migrations && echo "✓ migrations/ directory" | |||
| 128 | + | test -d configs && echo "✓ configs/ directory" | |||
| 129 | + | test -d tests && echo "✓ tests/ directory" | |||
| 130 | + | ||||
| 131 | + | # Check internal structure | |||
| 132 | + | test -d internal/cli && echo "✓ internal/cli/" | |||
| 133 | + | test -d internal/db && echo "✓ internal/db/" | |||
| 134 | + | test -d internal/parser && echo "✓ internal/parser/" | |||
| 135 | + | test -d internal/browser && echo "✓ internal/browser/" | |||
| 136 | + | ``` | |||
| 137 | + | ||||
| 138 | + | ## Step 6: Test Configuration Generation | |||
| 139 | + | ||||
| 140 | + | Verify that configuration files were generated correctly: | |||
| 141 | + | ||||
| 142 | + | ```bash | |||
| 143 | + | # Check SQLC configuration | |||
| 144 | + | test -f configs/sqlc.yaml && echo "✓ SQLC config exists" | |||
| 145 | + | sqlc compile -f configs/sqlc.yaml && echo "✓ SQLC config valid" | |||
| 146 | + | ||||
| 147 | + | # Check basic project files exist (if generated) | |||
| 148 | + | test -f cmd/dumber/main.go && echo "✓ Main entry point exists" | |||
| 149 | + | ``` | |||
| 150 | + | ||||
| 151 | + | ## Step 7: Test Module Dependencies | |||
| 152 | + | ||||
| 153 | + | Verify the updated SQLite dependency: | |||
| 154 | + | ||||
| 155 | + | ```bash | |||
| 156 | + | # Check ncruces/go-sqlite3 is in go.mod (not mattn) | |||
| 157 | + | grep -q "github.com/ncruces/go-sqlite3" go.mod && echo "✓ Using CGO-free SQLite driver" | |||
| 158 | + | ! grep -q "github.com/mattn/go-sqlite3" go.mod && echo "✓ Not using CGO SQLite driver" | |||
| 159 | + | ||||
| 160 | + | # Test that database works CGO-free | |||
| 161 | + | export CGO_ENABLED=0 | |||
| 162 | + | go run -c "import _ 'github.com/ncruces/go-sqlite3'" 2>/dev/null && echo "✓ SQLite driver loads CGO-free" | |||
| 163 | + | ``` | |||
| 164 | + | ||||
| 165 | + | ## Step 8: Validation Tests | |||
| 166 | + | ||||
| 167 | + | Run tests with different CGO settings: | |||
| 168 | + | ||||
| 169 | + | ```bash | |||
| 170 | + | # Test CGO-free components | |||
| 171 | + | export CGO_ENABLED=0 | |||
| 172 | + | go test ./internal/cli ./internal/parser ./internal/db && echo "✓ CGO-free tests pass" | |||
| 173 | + | ||||
| 174 | + | # Test full application with CGO | |||
| 175 | + | export CGO_ENABLED=1 | |||
| 176 | + | go test ./... && echo "✓ All tests pass with CGO" | |||
| 177 | + | ||||
| 178 | + | # Test with race detection (requires CGO) | |||
| 179 | + | go test -race ./... && echo "✓ No race conditions detected" | |||
| 180 | + | ``` | |||
| 181 | + | ||||
| 182 | + | ## Troubleshooting Common Issues | |||
| 183 | + | ||||
| 184 | + | ### Issue: Missing WebKit2GTK (Wails only) | |||
| 185 | + | **Symptom**: Wails installation fails with webkit error | |||
| 186 | + | **Solution** (Arch Linux): | |||
| 187 | + | ```bash | |||
| 188 | + | sudo pacman -S webkit2gtk gtk3 | |||
| 189 | + | dumber init deps --only=wails | |||
| 190 | + | ``` | |||
| 191 | + | ||||
| 192 | + | ### Issue: CGO disabled for Wails | |||
| 193 | + | **Symptom**: Wails build fails with CGO error | |||
| 194 | + | **Solution**: | |||
| 195 | + | ```bash | |||
| 196 | + | export CGO_ENABLED=1 | |||
| 197 | + | go build ./cmd/dumber | |||
| 198 | + | ``` | |||
| 199 | + | ||||
| 200 | + | ### Issue: Wrong SQLite driver | |||
| 201 | + | **Symptom**: CGO errors from mattn/go-sqlite3 | |||
| 202 | + | **Verification**: | |||
| 203 | + | ```bash | |||
| 204 | + | # Should show ncruces, not mattn | |||
| 205 | + | grep sqlite3 go.mod | |||
| 206 | + | ``` | |||
| 207 | + | ||||
| 208 | + | ### Issue: Network timeout | |||
| 209 | + | **Symptom**: Dependency download timeouts | |||
| 210 | + | **Solution**: | |||
| 211 | + | ```bash | |||
| 212 | + | go env -w GOPROXY=proxy.golang.org,direct | |||
| 213 | + | go env -w GOSUMDB=sum.golang.org | |||
| 214 | + | dumber init deps | |||
| 215 | + | ``` | |||
| 216 | + | ||||
| 217 | + | ## Success Criteria | |||
| 218 | + | ||||
| 219 | + | After completing all steps, you should have: | |||
| 220 | + | ||||
| 221 | + | - ✅ Valid Go module with CGO-free SQLite driver (`ncruces/go-sqlite3`) | |||
| 222 | + | - ✅ All CLI, parser, database components buildable with `CGO_ENABLED=0` | |||
| 223 | + | - ✅ Full application buildable with `CGO_ENABLED=1` (for Wails) | |||
| 224 | + | - ✅ Project directory structure created per constitution | |||
| 225 | + | - ✅ Configuration files generated and valid | |||
| 226 | + | - ✅ All tools (sqlc, wails) available and functional | |||
| 227 | + | - ✅ Clean separation: CGO only needed for browser layer | |||
| 228 | + | ||||
| 229 | + | ## Development Workflow Benefits | |||
| 230 | + | ||||
| 231 | + | This CGO strategy provides several advantages: | |||
| 232 | + | ||||
| 233 | + | 1. **Fast development cycle**: Most development can happen CGO-free | |||
| 234 | + | 2. **Better testing**: Database and CLI logic testable without CGO setup | |||
| 235 | + | 3. **Cleaner builds**: Only final integration requires CGO | |||
| 236 | + | 4. **Better performance**: `ncruces/go-sqlite3` is faster than `mattn/go-sqlite3` | |||
| 237 | + | ||||
| 238 | + | ## Next Steps | |||
| 239 | + | ||||
| 240 | + | After successful initialization: | |||
| 241 | + | ||||
| 242 | + | 1. **Start with CGO-free components**: CLI structure, URL parsing, database schema | |||
| 243 | + | 2. **Test incrementally**: Each component works independently | |||
| 244 | + | 3. **Integrate with Wails last**: Final step requires CGO for browser integration | |||
| 245 | + | 4. **Development workflow**: Most work can be done with `CGO_ENABLED=0` | |||
| 246 | + | ||||
| 247 | + | The project is now ready for feature development with optimal CGO separation.⏎̸ | |||
A specs/001-init-project/research.md +176 -0
| @@ -0,0 +1,176 @@ | |||||
| 1 | + | # Research: Project Initialization Dependencies | |||
| 2 | + | ||||
| 3 | + | **Date**: 2025-01-09 | |||
| 4 | + | **Context**: Resolve technical unknowns for dumber project initialization | |||
| 5 | + | ||||
| 6 | + | ## Research Questions Resolved | |||
| 7 | + | ||||
| 8 | + | ### 1. Go Module Initialization | |||
| 9 | + | **Decision**: Use `go mod init dumber` | |||
| 10 | + | **Rationale**: | |||
| 11 | + | - Simple module name matching the binary name | |||
| 12 | + | - Local development focus (no public import path needed) | |||
| 13 | + | - Follows Go module naming conventions | |||
| 14 | + | ||||
| 15 | + | **Alternatives considered**: | |||
| 16 | + | - `github.com/user/dumber` - rejected (unnecessary complexity for local tool) | |||
| 17 | + | - `dumber/v1` - rejected (premature versioning) | |||
| 18 | + | ||||
| 19 | + | ### 2. Wails v3-Alpha Compatibility | |||
| 20 | + | **Decision**: Use Wails v3.0.0-alpha.28 (latest available) | |||
| 21 | + | **Rationale**: | |||
| 22 | + | - Constitution specifically requires v3-alpha | |||
| 23 | + | - Alpha version provides newest WebKit2GTK integration | |||
| 24 | + | - Active development branch with Wayland improvements | |||
| 25 | + | - Breaking changes acceptable for new project | |||
| 26 | + | ||||
| 27 | + | **Alternatives considered**: | |||
| 28 | + | - Wails v2 stable - rejected (constitution mandates v3-alpha) | |||
| 29 | + | - Wait for v3 stable - rejected (delays development unnecessarily) | |||
| 30 | + | ||||
| 31 | + | **Integration approach**: | |||
| 32 | + | ```bash | |||
| 33 | + | go install github.com/wailsapp/wails/v3/cmd/wails@v3.0.0-alpha.28 | |||
| 34 | + | ``` | |||
| 35 | + | ||||
| 36 | + | ### 3. CLI Framework Selection | |||
| 37 | + | **Decision**: Cobra v1.8+ with Viper v1.18+ | |||
| 38 | + | **Rationale**: | |||
| 39 | + | - Constitution requirement: "Cobra + Viper for configuration" | |||
| 40 | + | - Mature, stable libraries with excellent Go ecosystem integration | |||
| 41 | + | - Perfect for dmenu/rofi integration via stdin/stdout | |||
| 42 | + | - Built-in flag parsing and configuration management | |||
| 43 | + | ||||
| 44 | + | **Key packages**: | |||
| 45 | + | - `github.com/spf13/cobra@latest` | |||
| 46 | + | - `github.com/spf13/viper@latest` | |||
| 47 | + | ||||
| 48 | + | ### 4. Database Stack | |||
| 49 | + | **Decision**: SQLite with ncruces/go-sqlite3 (CGO-free) + SQLC v1.25+ | |||
| 50 | + | **Rationale**: | |||
| 51 | + | - Constitution requirement: "SQLite + SQLC for type-safe SQL" | |||
| 52 | + | - `github.com/ncruces/go-sqlite3` is CGO-free (better performance, fewer build issues) | |||
| 53 | + | - Only Wails needs CGO, not database layer - cleaner separation | |||
| 54 | + | - SQLC generates type-safe Go code from SQL schemas | |||
| 55 | + | - Perfect for local history storage | |||
| 56 | + | ||||
| 57 | + | **Key packages**: | |||
| 58 | + | - `github.com/ncruces/go-sqlite3@latest` (CGO-free SQLite driver) | |||
| 59 | + | - Install SQLC CLI tool: `go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest` | |||
| 60 | + | ||||
| 61 | + | **Alternatives considered**: | |||
| 62 | + | - `github.com/mattn/go-sqlite3` - rejected (requires CGO unnecessarily) | |||
| 63 | + | - Pure Go implementations - rejected (ncruces is the modern standard) | |||
| 64 | + | ||||
| 65 | + | ### 5. Input Validation | |||
| 66 | + | **Decision**: go-playground/validator v10.16+ | |||
| 67 | + | **Rationale**: | |||
| 68 | + | - Constitution requirement | |||
| 69 | + | - Industry standard for Go struct validation | |||
| 70 | + | - URL validation capabilities for browser input | |||
| 71 | + | - Tag-based validation (clean code) | |||
| 72 | + | ||||
| 73 | + | **Key package**: | |||
| 74 | + | - `github.com/go-playground/validator/v10@latest` | |||
| 75 | + | ||||
| 76 | + | ### 6. Testing Framework | |||
| 77 | + | **Decision**: Standard Go testing + testify v1.8+ | |||
| 78 | + | **Rationale**: | |||
| 79 | + | - Standard library first approach (constitution: simplicity) | |||
| 80 | + | - testify for better assertions and test organization | |||
| 81 | + | - No need for heavyweight testing frameworks | |||
| 82 | + | ||||
| 83 | + | **Key package**: | |||
| 84 | + | - `github.com/stretchr/testify@latest` | |||
| 85 | + | ||||
| 86 | + | ### 7. Project Directory Structure | |||
| 87 | + | **Decision**: Follow constitution implementation order | |||
| 88 | + | ``` | |||
| 89 | + | / | |||
| 90 | + | ├── cmd/ # CLI entry point | |||
| 91 | + | ├── internal/ | |||
| 92 | + | │ ├── cli/ # Cobra commands | |||
| 93 | + | │ ├── db/ # SQLC generated code + migrations | |||
| 94 | + | │ ├── parser/ # URL parsing logic | |||
| 95 | + | │ └── browser/ # Wails integration | |||
| 96 | + | ├── migrations/ # SQL schema files | |||
| 97 | + | ├── configs/ # Configuration files (SQLC, Wails) | |||
| 98 | + | └── tests/ # Integration tests | |||
| 99 | + | ``` | |||
| 100 | + | ||||
| 101 | + | **Rationale**: | |||
| 102 | + | - `internal/` prevents external imports (Go convention) | |||
| 103 | + | - Matches constitution implementation order | |||
| 104 | + | - Separates concerns clearly | |||
| 105 | + | - Easy to test individual components | |||
| 106 | + | ||||
| 107 | + | ### 8. Build Configuration | |||
| 108 | + | **Decision**: Standard Go build with Wails embedding | |||
| 109 | + | **Rationale**: | |||
| 110 | + | - Single binary requirement from constitution | |||
| 111 | + | - Wails handles frontend asset embedding | |||
| 112 | + | - Standard go build for CLI-only functionality | |||
| 113 | + | ||||
| 114 | + | ## Dependency Installation Order | |||
| 115 | + | ||||
| 116 | + | Based on research, the installation sequence should be: | |||
| 117 | + | ||||
| 118 | + | 1. **Initialize Go module** | |||
| 119 | + | ```bash | |||
| 120 | + | go mod init dumber | |||
| 121 | + | ``` | |||
| 122 | + | ||||
| 123 | + | 2. **Install CLI dependencies** | |||
| 124 | + | ```bash | |||
| 125 | + | go get github.com/spf13/cobra@latest | |||
| 126 | + | go get github.com/spf13/viper@latest | |||
| 127 | + | ``` | |||
| 128 | + | ||||
| 129 | + | 3. **Install database dependencies** | |||
| 130 | + | ```bash | |||
| 131 | + | go get github.com/ncruces/go-sqlite3@latest | |||
| 132 | + | go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest | |||
| 133 | + | ``` | |||
| 134 | + | ||||
| 135 | + | 4. **Install validation** | |||
| 136 | + | ```bash | |||
| 137 | + | go get github.com/go-playground/validator/v10@latest | |||
| 138 | + | ``` | |||
| 139 | + | ||||
| 140 | + | 5. **Install testing tools** | |||
| 141 | + | ```bash | |||
| 142 | + | go get github.com/stretchr/testify@latest | |||
| 143 | + | ``` | |||
| 144 | + | ||||
| 145 | + | 6. **Install Wails (last - most complex)** | |||
| 146 | + | ```bash | |||
| 147 | + | go install github.com/wailsapp/wails/v3/cmd/wails@v3.0.0-alpha.28 | |||
| 148 | + | # Wails will handle its own module dependencies during init | |||
| 149 | + | ``` | |||
| 150 | + | ||||
| 151 | + | ## Risk Assessment | |||
| 152 | + | ||||
| 153 | + | **Low Risk**: | |||
| 154 | + | - Standard Go dependencies (Cobra, Viper, SQLite) | |||
| 155 | + | - Well-established patterns | |||
| 156 | + | ||||
| 157 | + | **Medium Risk**: | |||
| 158 | + | - Wails v3-alpha stability (alpha software) | |||
| 159 | + | - WebKit2GTK system dependency availability | |||
| 160 | + | - CGO requirement for Wails (but not for database layer) | |||
| 161 | + | ||||
| 162 | + | **Mitigation**: | |||
| 163 | + | - Test Wails installation immediately after dependency setup | |||
| 164 | + | - Verify WebKit2GTK availability in integration tests | |||
| 165 | + | - Document CGO requirement clearly (only for final Wails build) | |||
| 166 | + | - Database layer can be developed/tested CGO-free independently | |||
| 167 | + | ||||
| 168 | + | ## Next Phase Requirements | |||
| 169 | + | ||||
| 170 | + | All technical unknowns resolved. Phase 1 can proceed with: | |||
| 171 | + | - Data model design (SQLite schema) | |||
| 172 | + | - Contract definitions (CLI commands) | |||
| 173 | + | - Integration test scenarios | |||
| 174 | + | - Configuration file generation | |||
| 175 | + | ||||
| 176 | + | **Status**: ✅ Complete - All NEEDS CLARIFICATION items resolved⏎̸ | |||
A specs/001-init-project/spec.md +103 -0
| @@ -0,0 +1,103 @@ | |||||
| 1 | + | # Feature Specification: Project Initialization with Dependencies | |||
| 2 | + | ||||
| 3 | + | **Feature Branch**: `001-init-project` | |||
| 4 | + | **Created**: 2025-01-09 | |||
| 5 | + | **Status**: Draft | |||
| 6 | + | **Input**: User description: "Lets init the project adding all deps" | |||
| 7 | + | ||||
| 8 | + | ## Execution Flow (main) | |||
| 9 | + | ``` | |||
| 10 | + | 1. Parse user description from Input | |||
| 11 | + | → "Initialize project with all required dependencies" | |||
| 12 | + | 2. Extract key concepts from description | |||
| 13 | + | → Actors: developer, build system | |||
| 14 | + | → Actions: initialize, add dependencies, configure | |||
| 15 | + | → Data: project structure, dependency management | |||
| 16 | + | → Constraints: follow constitution requirements | |||
| 17 | + | 3. For each unclear aspect: | |||
| 18 | + | → No major ambiguities - project structure defined in constitution | |||
| 19 | + | 4. Fill User Scenarios & Testing section | |||
| 20 | + | → Clear flow: developer initializes → dependencies installed → project ready | |||
| 21 | + | 5. Generate Functional Requirements | |||
| 22 | + | → Each requirement testable through build/run verification | |||
| 23 | + | 6. Identify Key Entities: Go modules, dependencies, configuration files | |||
| 24 | + | 7. Run Review Checklist | |||
| 25 | + | → No [NEEDS CLARIFICATION] markers needed | |||
| 26 | + | → No implementation details beyond necessary project structure | |||
| 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 developer starting work on the dumber project, I need a properly initialized Go project with all required dependencies installed so that I can begin implementing features according to the project constitution without manual setup overhead. | |||
| 43 | + | ||||
| 44 | + | ### Acceptance Scenarios | |||
| 45 | + | 1. **Given** an empty project directory, **When** project initialization is complete, **Then** the project has a valid Go module structure | |||
| 46 | + | 2. **Given** the initialized project, **When** checking dependencies, **Then** all required packages from the constitution are available | |||
| 47 | + | 3. **Given** the initialized project, **When** running basic build commands, **Then** no dependency resolution errors occur | |||
| 48 | + | 4. **Given** the project structure, **When** examining the layout, **Then** it follows the planned architecture (CLI, database, browser components) | |||
| 49 | + | ||||
| 50 | + | ### Edge Cases | |||
| 51 | + | - What happens when Go version is incompatible with selected dependencies? | |||
| 52 | + | - How does system handle network failures during dependency installation? | |||
| 53 | + | - What occurs if required system packages (WebKit2GTK) are missing? | |||
| 54 | + | ||||
| 55 | + | ## Requirements *(mandatory)* | |||
| 56 | + | ||||
| 57 | + | ### Functional Requirements | |||
| 58 | + | - **FR-001**: System MUST initialize a valid Go module with appropriate module path | |||
| 59 | + | - **FR-002**: System MUST install CLI framework dependencies (Cobra, Viper) | |||
| 60 | + | - **FR-003**: System MUST install database dependencies (SQLite driver, SQLC tooling) | |||
| 61 | + | - **FR-004**: System MUST install validation dependencies (go-playground/validator) | |||
| 62 | + | - **FR-005**: System MUST install Wails v3-alpha framework for browser integration | |||
| 63 | + | - **FR-006**: System MUST create basic project directory structure aligned with planned architecture | |||
| 64 | + | - **FR-007**: System MUST configure SQLC for type-safe database code generation | |||
| 65 | + | - **FR-008**: System MUST verify all dependencies are compatible and buildable | |||
| 66 | + | - **FR-009**: System MUST create configuration files needed for the development workflow | |||
| 67 | + | ||||
| 68 | + | ### Key Entities | |||
| 69 | + | - **Go Module**: The main project module with proper versioning and dependency management | |||
| 70 | + | - **Configuration Files**: SQLC config, Wails config, and other tool configurations | |||
| 71 | + | - **Project Structure**: Directory layout supporting CLI, database, and browser components | |||
| 72 | + | - **Dependencies**: External packages required per the constitution (Cobra, Viper, SQLite, SQLC, Wails, validator) | |||
| 73 | + | ||||
| 74 | + | --- | |||
| 75 | + | ||||
| 76 | + | ## Review & Acceptance Checklist | |||
| 77 | + | ||||
| 78 | + | ### Content Quality | |||
| 79 | + | - [x] No implementation details (languages, frameworks, APIs) | |||
| 80 | + | - [x] Focused on user value and business needs | |||
| 81 | + | - [x] Written for non-technical stakeholders | |||
| 82 | + | - [x] All mandatory sections completed | |||
| 83 | + | ||||
| 84 | + | ### Requirement Completeness | |||
| 85 | + | - [x] No [NEEDS CLARIFICATION] markers remain | |||
| 86 | + | - [x] Requirements are testable and unambiguous | |||
| 87 | + | - [x] Success criteria are measurable | |||
| 88 | + | - [x] Scope is clearly bounded | |||
| 89 | + | - [x] Dependencies and assumptions identified | |||
| 90 | + | ||||
| 91 | + | --- | |||
| 92 | + | ||||
| 93 | + | ## Execution Status | |||
| 94 | + | ||||
| 95 | + | - [x] User description parsed | |||
| 96 | + | - [x] Key concepts extracted | |||
| 97 | + | - [x] Ambiguities marked | |||
| 98 | + | - [x] User scenarios defined | |||
| 99 | + | - [x] Requirements generated | |||
| 100 | + | - [x] Entities identified | |||
| 101 | + | - [x] Review checklist passed | |||
| 102 | + | ||||
| 103 | + | ---⏎̸ | |||
A specs/001-init-project/tasks.md +170 -0
| @@ -0,0 +1,170 @@ | |||||
| 1 | + | # Tasks: Project Initialization with Dependencies | |||
| 2 | + | ||||
| 3 | + | **Input**: Design documents from `/home/brice/dev/projects/dumber/specs/001-init-project/` | |||
| 4 | + | **Prerequisites**: plan.md, research.md, data-model.md, contracts/ | |||
| 5 | + | ||||
| 6 | + | ## Execution Flow (main) | |||
| 7 | + | Project initialization requires setting up Go module, installing dependencies with `go get @latest`, creating directory structure, and verifying installation. Tasks follow TDD principles with contract tests before implementation. | |||
| 8 | + | ||||
| 9 | + | ## Format: `[ID] [P?] Description` | |||
| 10 | + | - **[P]**: Can run in parallel (different files, no dependencies) | |||
| 11 | + | - Include exact file paths in descriptions | |||
| 12 | + | ||||
| 13 | + | ## Path Conventions | |||
| 14 | + | Single project structure: `cmd/`, `internal/`, `tests/` at repository root per constitution | |||
| 15 | + | ||||
| 16 | + | ## Phase 3.1: Setup and Structure | |||
| 17 | + | ||||
| 18 | + | - [ ] T001 Initialize Go module with `go mod init dumber` in project root | |||
| 19 | + | - [ ] T002 Create project directory structure: `cmd/dumber/`, `internal/{cli,db,parser,browser}/`, `migrations/`, `configs/`, `tests/{contract,integration,unit}/` | |||
| 20 | + | - [ ] T003 [P] Configure Git repository with .gitignore for Go projects | |||
| 21 | + | ||||
| 22 | + | ## Phase 3.2: Dependency Installation | |||
| 23 | + | ||||
| 24 | + | - [ ] T004 [P] Install CLI dependencies: `go get github.com/spf13/cobra@latest github.com/spf13/viper@latest` | |||
| 25 | + | - [ ] T005 [P] Install CGO-free database dependency: `go get github.com/ncruces/go-sqlite3@latest` | |||
| 26 | + | - [ ] T006 [P] Install validation dependency: `go get github.com/go-playground/validator/v10@latest` | |||
| 27 | + | - [ ] T007 [P] Install testing dependency: `go get github.com/stretchr/testify@latest` | |||
| 28 | + | - [ ] T008 Install SQLC tool: `go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest` | |||
| 29 | + | - [ ] T009 Install Wails v3-alpha tool: `go install github.com/wailsapp/wails/v3/cmd/wails@v3.0.0-alpha.28` | |||
| 30 | + | ||||
| 31 | + | ## Phase 3.3: Tests First (TDD) ⚠️ MUST COMPLETE BEFORE 3.4 | |||
| 32 | + | ||||
| 33 | + | **CRITICAL: These tests MUST be written and MUST FAIL before ANY implementation** | |||
| 34 | + | ||||
| 35 | + | - [ ] T010 [P] Contract test for ModuleConfig validation in `tests/contract/test_module_config.go` | |||
| 36 | + | - [ ] T011 [P] Contract test for CLI init command in `tests/contract/test_cli_init.go` | |||
| 37 | + | - [ ] T012 [P] Contract test for dependency installation in `tests/contract/test_deps_install.go` | |||
| 38 | + | - [ ] T013 [P] Integration test for full project initialization in `tests/integration/test_full_init.go` | |||
| 39 | + | - [ ] T014 [P] Integration test for CGO-free database build in `tests/integration/test_cgo_free_build.go` | |||
| 40 | + | - [ ] T015 [P] Integration test for Wails build (CGO required) in `tests/integration/test_wails_build.go` | |||
| 41 | + | ||||
| 42 | + | ## Phase 3.4: Core Implementation (ONLY after tests are failing) | |||
| 43 | + | ||||
| 44 | + | - [ ] T016 [P] ModuleConfig model in `internal/models/module.go` | |||
| 45 | + | - [ ] T017 [P] ProjectStructure model in `internal/models/structure.go` | |||
| 46 | + | - [ ] T018 [P] InstallationStatus model in `internal/models/status.go` | |||
| 47 | + | - [ ] T019 CLI root command setup in `cmd/dumber/main.go` | |||
| 48 | + | - [ ] T020 CLI init command implementation in `internal/cli/init.go` | |||
| 49 | + | - [ ] T021 Dependency installer service in `internal/services/installer.go` | |||
| 50 | + | - [ ] T022 Project structure creator in `internal/services/structure.go` | |||
| 51 | + | - [ ] T023 Configuration file generator in `internal/services/config.go` | |||
| 52 | + | ||||
| 53 | + | ## Phase 3.5: Configuration Files | |||
| 54 | + | ||||
| 55 | + | - [ ] T024 [P] Generate SQLC configuration template in `configs/sqlc.yaml` | |||
| 56 | + | - [ ] T025 [P] Generate Wails configuration template in `configs/wails.json` | |||
| 57 | + | - [ ] T026 [P] Create initial database migration in `migrations/001_initial.sql` | |||
| 58 | + | ||||
| 59 | + | ## Phase 3.6: Verification and Integration | |||
| 60 | + | ||||
| 61 | + | - [ ] T027 Build verification with CGO_ENABLED=0 for core components | |||
| 62 | + | - [ ] T028 Build verification with CGO_ENABLED=1 for Wails integration | |||
| 63 | + | - [ ] T029 Dependency resolution verification with `go mod download` | |||
| 64 | + | - [ ] T030 Tool availability verification: `sqlc version && wails version` | |||
| 65 | + | ||||
| 66 | + | ## Phase 3.7: Polish and Documentation | |||
| 67 | + | ||||
| 68 | + | - [ ] T031 [P] Unit tests for validation logic in `tests/unit/test_validation.go` | |||
| 69 | + | - [ ] T032 [P] Unit tests for config generation in `tests/unit/test_config_gen.go` | |||
| 70 | + | - [ ] T033 [P] Error handling and logging implementation | |||
| 71 | + | - [ ] T034 Update quickstart.md with actual execution results | |||
| 72 | + | - [ ] T035 Run full quickstart validation script | |||
| 73 | + | ||||
| 74 | + | ## Dependencies | |||
| 75 | + | ||||
| 76 | + | **Critical Path**: | |||
| 77 | + | - Setup (T001-T003) before dependencies (T004-T009) | |||
| 78 | + | - Dependencies before tests (T010-T015) | |||
| 79 | + | - Tests (T010-T015) before implementation (T016-T023) | |||
| 80 | + | - Core implementation before config files (T024-T026) | |||
| 81 | + | - Everything before verification (T027-T030) | |||
| 82 | + | ||||
| 83 | + | **Specific Blockers**: | |||
| 84 | + | - T001 blocks T004-T009 (need go.mod first) | |||
| 85 | + | - T016-T018 block T021-T023 (need models for services) | |||
| 86 | + | - T020 blocks T027-T030 (need CLI for verification) | |||
| 87 | + | ||||
| 88 | + | ## Parallel Execution Examples | |||
| 89 | + | ||||
| 90 | + | ### Install Dependencies Concurrently | |||
| 91 | + | ```bash | |||
| 92 | + | # Launch T004-T007 together (different packages): | |||
| 93 | + | Task: "Install CLI dependencies: go get github.com/spf13/cobra@latest github.com/spf13/viper@latest" | |||
| 94 | + | Task: "Install CGO-free database dependency: go get github.com/ncruces/go-sqlite3@latest" | |||
| 95 | + | Task: "Install validation dependency: go get github.com/go-playground/validator/v10@latest" | |||
| 96 | + | Task: "Install testing dependency: go get github.com/stretchr/testify@latest" | |||
| 97 | + | ``` | |||
| 98 | + | ||||
| 99 | + | ### Write Tests Concurrently | |||
| 100 | + | ```bash | |||
| 101 | + | # Launch T010-T015 together (different test files): | |||
| 102 | + | Task: "Contract test for ModuleConfig validation in tests/contract/test_module_config.go" | |||
| 103 | + | Task: "Contract test for CLI init command in tests/contract/test_cli_init.go" | |||
| 104 | + | Task: "Contract test for dependency installation in tests/contract/test_deps_install.go" | |||
| 105 | + | Task: "Integration test for full project initialization in tests/integration/test_full_init.go" | |||
| 106 | + | Task: "Integration test for CGO-free database build in tests/integration/test_cgo_free_build.go" | |||
| 107 | + | Task: "Integration test for Wails build (CGO required) in tests/integration/test_wails_build.go" | |||
| 108 | + | ``` | |||
| 109 | + | ||||
| 110 | + | ### Create Models Concurrently | |||
| 111 | + | ```bash | |||
| 112 | + | # Launch T016-T018 together (different model files): | |||
| 113 | + | Task: "ModuleConfig model in internal/models/module.go" | |||
| 114 | + | Task: "ProjectStructure model in internal/models/structure.go" | |||
| 115 | + | Task: "InstallationStatus model in internal/models/status.go" | |||
| 116 | + | ``` | |||
| 117 | + | ||||
| 118 | + | ## CGO Optimization Strategy | |||
| 119 | + | ||||
| 120 | + | **CGO-Free Development** (T004-T007, T010-T014, T016-T023): | |||
| 121 | + | - Most tasks can run with `CGO_ENABLED=0` | |||
| 122 | + | - Database layer uses ncruces/go-sqlite3 (CGO-free) | |||
| 123 | + | - CLI and parser components don't need CGO | |||
| 124 | + | - Faster development cycle for majority of work | |||
| 125 | + | ||||
| 126 | + | **CGO Required** (T009, T015, T028): | |||
| 127 | + | - Only Wails installation and integration requires `CGO_ENABLED=1` | |||
| 128 | + | - WebKit2GTK bindings need CGO for native rendering | |||
| 129 | + | - Clear separation allows independent development | |||
| 130 | + | ||||
| 131 | + | ## Notes | |||
| 132 | + | ||||
| 133 | + | - [P] tasks = different files, no dependencies | |||
| 134 | + | - Verify tests fail before implementing (TDD enforcement) | |||
| 135 | + | - Use `go get @latest` for all dependencies per user requirements | |||
| 136 | + | - Commit after each task completion | |||
| 137 | + | - CGO-free SQLite driver (ncruces) allows faster development cycle | |||
| 138 | + | - Only Wails final integration requires CGO | |||
| 139 | + | ||||
| 140 | + | ## Task Generation Rules Applied | |||
| 141 | + | ||||
| 142 | + | 1. **From Contracts**: | |||
| 143 | + | - cli-commands.md → T011, T020 (CLI init command) | |||
| 144 | + | - go-module.yaml → T010, T012 (module and dependency contracts) | |||
| 145 | + | ||||
| 146 | + | 2. **From Data Model**: | |||
| 147 | + | - ModuleConfig → T016 (model creation) | |||
| 148 | + | - ProjectStructure → T017 (model creation) | |||
| 149 | + | - InstallationStatus → T018 (model creation) | |||
| 150 | + | ||||
| 151 | + | 3. **From Research Decisions**: | |||
| 152 | + | - CGO-free SQLite → T005, T014 (specific driver selection) | |||
| 153 | + | - Wails v3-alpha → T009, T015 (version-specific installation) | |||
| 154 | + | ||||
| 155 | + | 4. **From Quickstart Scenarios**: | |||
| 156 | + | - Full initialization → T013 (integration test) | |||
| 157 | + | - Build verification → T027, T028 (CGO separation tests) | |||
| 158 | + | - Tool availability → T030 (verification scenario) | |||
| 159 | + | ||||
| 160 | + | ## Validation Checklist | |||
| 161 | + | ||||
| 162 | + | - [x] All contracts have corresponding tests (T010-T012) | |||
| 163 | + | - [x] All entities have model tasks (T016-T018) | |||
| 164 | + | - [x] All tests come before implementation (T010-T015 before T016-T023) | |||
| 165 | + | - [x] Parallel tasks truly independent ([P] tasks use different files) | |||
| 166 | + | - [x] Each task specifies exact file path | |||
| 167 | + | - [x] No task modifies same file as another [P] task | |||
| 168 | + | - [x] TDD principles enforced (tests must fail before implementation) | |||
| 169 | + | - [x] CGO optimization strategy clearly defined | |||
| 170 | + | - [x] Dependencies properly ordered and documented⏎̸ | |||
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 | + | ---⏎̸ | |||
A sqlc.yaml +15 -0
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | version: "2" | |||
| 2 | + | sql: | |||
| 3 | + | - engine: "sqlite" | |||
| 4 | + | queries: "./internal/db/queries/" | |||
| 5 | + | schema: "./migrations/" | |||
| 6 | + | gen: | |||
| 7 | + | go: | |||
| 8 | + | package: "db" | |||
| 9 | + | out: "./internal/db" | |||
| 10 | + | sql_package: "database/sql" | |||
| 11 | + | emit_json_tags: true | |||
| 12 | + | emit_empty_slices: true | |||
| 13 | + | emit_exported_queries: true | |||
| 14 | + | emit_interface: true | |||
| 15 | + | query_parameter_limit: 5⏎̸ | |||
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 | + | }⏎̸ | |||