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
| @@ -0,0 +1,185 @@ | |||
| 1 | + | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= | |
| 2 | + | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= | |
| 3 | + | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= | |
| 4 | + | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= | |
| 5 | + | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= | |
| 6 | + | github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= | |
| 7 | + | github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= | |
| 8 | + | github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= | |
| 9 | + | github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= | |
| 10 | + | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= | |
| 11 | + | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= | |
| 12 | + | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= | |
| 13 | + | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= | |
| 14 | + | github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= | |
| 15 | + | github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= | |
| 16 | + | github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= | |
| 17 | + | github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= | |
| 18 | + | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | |
| 19 | + | github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= | |
| 20 | + | github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= | |
| 21 | + | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 22 | + | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | |
| 23 | + | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 24 | + | github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= | |
| 25 | + | github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= | |
| 26 | + | github.com/elazarl/goproxy v1.4.0 h1:4GyuSbFa+s26+3rmYNSuUVsx+HgPrV1bk1jXI0l9wjM= | |
| 27 | + | github.com/elazarl/goproxy v1.4.0/go.mod h1:X/5W/t+gzDyLfHW4DrMdpjqYjpXsURlBt9lpBDxZZZQ= | |
| 28 | + | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= | |
| 29 | + | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= | |
| 30 | + | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= | |
| 31 | + | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= | |
| 32 | + | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= | |
| 33 | + | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= | |
| 34 | + | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= | |
| 35 | + | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= | |
| 36 | + | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= | |
| 37 | + | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= | |
| 38 | + | github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= | |
| 39 | + | github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= | |
| 40 | + | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= | |
| 41 | + | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= | |
| 42 | + | github.com/go-git/go-git/v5 v5.13.2 h1:7O7xvsK7K+rZPKW6AQR1YyNhfywkv7B8/FsP3ki6Zv0= | |
| 43 | + | github.com/go-git/go-git/v5 v5.13.2/go.mod h1:hWdW5P4YZRjmpGHwRH2v3zkWcNl6HeXaXQEMGb3NJ9A= | |
| 44 | + | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= | |
| 45 | + | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= | |
| 46 | + | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= | |
| 47 | + | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= | |
| 48 | + | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= | |
| 49 | + | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | |
| 50 | + | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= | |
| 51 | + | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= | |
| 52 | + | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | |
| 53 | + | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | |
| 54 | + | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | |
| 55 | + | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | |
| 56 | + | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | |
| 57 | + | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | |
| 58 | + | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= | |
| 59 | + | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= | |
| 60 | + | github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= | |
| 61 | + | github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= | |
| 62 | + | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= | |
| 63 | + | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= | |
| 64 | + | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= | |
| 65 | + | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | |
| 66 | + | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | |
| 67 | + | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= | |
| 68 | + | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= | |
| 69 | + | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | |
| 70 | + | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | |
| 71 | + | github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= | |
| 72 | + | github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= | |
| 73 | + | github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= | |
| 74 | + | github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= | |
| 75 | + | github.com/lmittmann/tint v1.0.7 h1:D/0OqWZ0YOGZ6AyC+5Y2kD8PBEzBk6rFHVSfOqCkF9Y= | |
| 76 | + | github.com/lmittmann/tint v1.0.7/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= | |
| 77 | + | github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= | |
| 78 | + | github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= | |
| 79 | + | github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= | |
| 80 | + | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= | |
| 81 | + | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= | |
| 82 | + | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | |
| 83 | + | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | |
| 84 | + | github.com/ncruces/go-sqlite3 v0.29.0 h1:1tsLiagCoqZEfcHDeKsNSv5jvrY/Iu393pAnw2wLNJU= | |
| 85 | + | github.com/ncruces/go-sqlite3 v0.29.0/go.mod h1:r1hSvYKPNJ+OlUA1O3r8o9LAawzPAlqeZiIdxTBBBJ0= | |
| 86 | + | github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= | |
| 87 | + | github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= | |
| 88 | + | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= | |
| 89 | + | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= | |
| 90 | + | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= | |
| 91 | + | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= | |
| 92 | + | github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= | |
| 93 | + | github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= | |
| 94 | + | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= | |
| 95 | + | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= | |
| 96 | + | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | |
| 97 | + | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | |
| 98 | + | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | |
| 99 | + | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | |
| 100 | + | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= | |
| 101 | + | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= | |
| 102 | + | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= | |
| 103 | + | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= | |
| 104 | + | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= | |
| 105 | + | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | |
| 106 | + | github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= | |
| 107 | + | github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= | |
| 108 | + | github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= | |
| 109 | + | github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= | |
| 110 | + | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= | |
| 111 | + | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= | |
| 112 | + | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= | |
| 113 | + | github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= | |
| 114 | + | github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= | |
| 115 | + | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= | |
| 116 | + | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= | |
| 117 | + | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= | |
| 118 | + | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= | |
| 119 | + | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= | |
| 120 | + | github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= | |
| 121 | + | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= | |
| 122 | + | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= | |
| 123 | + | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | |
| 124 | + | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= | |
| 125 | + | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | |
| 126 | + | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= | |
| 127 | + | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= | |
| 128 | + | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | |
| 129 | + | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= | |
| 130 | + | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= | |
| 131 | + | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= | |
| 132 | + | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | |
| 133 | + | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= | |
| 134 | + | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= | |
| 135 | + | github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= | |
| 136 | + | github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= | |
| 137 | + | github.com/wailsapp/go-webview2 v1.0.21 h1:k3dtoZU4KCoN/AEIbWiPln3P2661GtA2oEgA2Pb+maA= | |
| 138 | + | github.com/wailsapp/go-webview2 v1.0.21/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= | |
| 139 | + | github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= | |
| 140 | + | github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= | |
| 141 | + | github.com/wailsapp/wails/v3 v3.0.0-alpha.28 h1:N6U+o178bHOchBMt5IIy+O53+abzBhs+79qGRfMRwQM= | |
| 142 | + | github.com/wailsapp/wails/v3 v3.0.0-alpha.28/go.mod h1:UZpnhYuju4saspCJrIHAvC0H5XjtKnqd26FRxJLrQ0M= | |
| 143 | + | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= | |
| 144 | + | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= | |
| 145 | + | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | |
| 146 | + | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | |
| 147 | + | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= | |
| 148 | + | golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= | |
| 149 | + | golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= | |
| 150 | + | golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= | |
| 151 | + | golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= | |
| 152 | + | golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= | |
| 153 | + | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= | |
| 154 | + | golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= | |
| 155 | + | golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= | |
| 156 | + | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | |
| 157 | + | golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | |
| 158 | + | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | |
| 159 | + | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | |
| 160 | + | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | |
| 161 | + | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 162 | + | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 163 | + | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 164 | + | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 165 | + | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= | |
| 166 | + | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | |
| 167 | + | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | |
| 168 | + | golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= | |
| 169 | + | golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= | |
| 170 | + | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | |
| 171 | + | golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= | |
| 172 | + | golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= | |
| 173 | + | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | |
| 174 | + | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | |
| 175 | + | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | |
| 176 | + | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | |
| 177 | + | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | |
| 178 | + | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= | |
| 179 | + | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= | |
| 180 | + | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= | |
| 181 | + | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= | |
| 182 | + | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | |
| 183 | + | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= | |
| 184 | + | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | |
| 185 | + | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | |
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 | |
| 2 | - | <!-- Example: Spec Constitution, TaskFlow Constitution, etc. --> | |
| 1 | + | # Dumb Browser Constitution | |
| 3 | 2 | ||
| 4 | 3 | ## Core Principles | |
| 5 | 4 | ||
| 6 | - | ### [PRINCIPLE_1_NAME] | |
| 7 | - | <!-- Example: I. Library-First --> | |
| 8 | - | [PRINCIPLE_1_DESCRIPTION] | |
| 9 | - | <!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries --> | |
| 5 | + | ### I. Speed First (NON-NEGOTIABLE) | |
| 6 | + | - Fast startup time is paramount - no feature worth compromising speed | |
| 7 | + | - Single binary with embedded assets - no external dependencies at runtime | |
| 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] | |
| 12 | - | <!-- Example: II. CLI Interface --> | |
| 13 | - | [PRINCIPLE_2_DESCRIPTION] | |
| 14 | - | <!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats --> | |
| 11 | + | ### II. Universal Launcher Compatibility | |
| 12 | + | - Support any dmenu-compatible launcher (fuzzel, rofi, tofi, wofi, bemenu) | |
| 13 | + | - Standard stdin/stdout protocol - no launcher-specific integrations | |
| 14 | + | - Clean text-based format for history/suggestions | |
| 15 | + | - Pipeline-friendly: `dumber --dmenu | launcher | dumber --dmenu` | |
| 15 | 16 | ||
| 16 | - | ### [PRINCIPLE_3_NAME] | |
| 17 | - | <!-- Example: III. Test-First (NON-NEGOTIABLE) --> | |
| 18 | - | [PRINCIPLE_3_DESCRIPTION] | |
| 19 | - | <!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced --> | |
| 17 | + | ### III. Smart URL Handling | |
| 18 | + | - Direct URL detection: `reddit.com` → navigate directly | |
| 19 | + | - Search shortcuts: `g: golang tutorial` → Google search | |
| 20 | + | - History-first search: `reddit` → check history before web search | |
| 21 | + | - No ambiguity - clear rules for input interpretation | |
| 20 | 22 | ||
| 21 | - | ### [PRINCIPLE_4_NAME] | |
| 22 | - | <!-- Example: IV. Integration Testing --> | |
| 23 | - | [PRINCIPLE_4_DESCRIPTION] | |
| 24 | - | <!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas --> | |
| 23 | + | ### IV. Privacy-Conscious History | |
| 24 | + | - Local SQLite database only - no cloud sync | |
| 25 | + | - Configurable retention periods and cleanup | |
| 26 | + | - Visit counting for intelligent suggestions | |
| 27 | + | - User controls data - easy to clear/export | |
| 25 | 28 | ||
| 26 | - | ### [PRINCIPLE_5_NAME] | |
| 27 | - | <!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity --> | |
| 28 | - | [PRINCIPLE_5_DESCRIPTION] | |
| 29 | - | <!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles --> | |
| 29 | + | ### V. Wayland Native | |
| 30 | + | - Built for modern Linux desktop - sway, hyprland, etc. | |
| 31 | + | - WebKit2GTK provides native Wayland integration | |
| 32 | + | - Single instance handling for launcher integration | |
| 33 | + | - Minimal resource usage - no background services | |
| 30 | 34 | ||
| 31 | - | ## [SECTION_2_NAME] | |
| 32 | - | <!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. --> | |
| 35 | + | ## Technical Standards | |
| 33 | 36 | ||
| 34 | - | [SECTION_2_CONTENT] | |
| 35 | - | <!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. --> | |
| 37 | + | ### Stack Requirements | |
| 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] | |
| 38 | - | <!-- Example: Development Workflow, Review Process, Quality Gates, etc. --> | |
| 45 | + | ### Performance Targets | |
| 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] | |
| 41 | - | <!-- Example: Code review requirements, testing gates, deployment approval process, etc. --> | |
| 51 | + | ### Quality Gates | |
| 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 | 88 | ## Governance | |
| 44 | - | <!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan --> | |
| 45 | 89 | ||
| 46 | - | [GOVERNANCE_RULES] | |
| 47 | - | <!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance --> | |
| 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. | |
| 48 | 91 | ||
| 49 | - | **Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] | |
| 50 | - | <!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->⏎̸ | |
| 92 | + | **Version**: 1.0.0 | **Ratified**: 2025-01-09 | **Last Amended**: 2025-01-09⏎̸ | |
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 | + | }⏎̸ | |