fix(shortcuts): align backend JSON and placeholder handling\n\n- Add json tags to SearchShortcut (url, description)\n- Frontend normalizes field case and supports %s/{query} templates\n- Parser URL-encodes query and supports %s in templates
- commit
- 606e9515edceb5f72a4384c6c5670d57e1a311b4
- author
- bamen · · 2025-09-09 16:17 UTC
- committer
- bamen · · 2025-09-09 19:30 UTC
- parent
- c0744da
- signature
- unsigned
3 files · +45 -28
M frontend/src/services/app.ts +11 -1
| @@ -63,7 +63,17 @@ export class AppService { | |||
| 63 | 63 | async loadShortcuts(): Promise<void> { | |
| 64 | 64 | try { | |
| 65 | 65 | const cfg = await fetch('/api/config').then(r => r.json()); | |
| 66 | - | this.shortcuts = cfg?.search_shortcuts || {}; | |
| 66 | + | const raw = cfg?.search_shortcuts || {}; | |
| 67 | + | // Normalize field casing from backend (supports URL/Description and url/description) | |
| 68 | + | const normalized: Record<string, { description: string; url: string }> = {}; | |
| 69 | + | for (const [key, value] of Object.entries(raw)) { | |
| 70 | + | const v: any = value as any; | |
| 71 | + | normalized[key] = { | |
| 72 | + | description: v.description ?? v.Description ?? '', | |
| 73 | + | url: v.url ?? v.URL ?? '' | |
| 74 | + | }; | |
| 75 | + | } | |
| 76 | + | this.shortcuts = normalized; | |
| 67 | 77 | } catch (error) { | |
| 68 | 78 | console.error('Failed to load shortcuts:', error); | |
| 69 | 79 | // Mock shortcuts for development/fallback | |
| @@ -63,7 +63,17 @@ export class AppService { | |||||
| 63 | async loadShortcuts(): Promise<void> { | 63 | async loadShortcuts(): Promise<void> { | ||
| 64 | try { | 64 | try { | ||
| 65 | const cfg = await fetch('/api/config').then(r => r.json()); | 65 | const cfg = await fetch('/api/config').then(r => r.json()); | ||
| 66 | - | this.shortcuts = cfg?.search_shortcuts || {}; | 66 | + | const raw = cfg?.search_shortcuts || {}; |
| 67 | + | // Normalize field casing from backend (supports URL/Description and url/description) | |||
| 68 | + | const normalized: Record<string, { description: string; url: string }> = {}; | |||
| 69 | + | for (const [key, value] of Object.entries(raw)) { | |||
| 70 | + | const v: any = value as any; | |||
| 71 | + | normalized[key] = { | |||
| 72 | + | description: v.description ?? v.Description ?? '', | |||
| 73 | + | url: v.url ?? v.URL ?? '' | |||
| 74 | + | }; | |||
| 75 | + | } | |||
| 76 | + | this.shortcuts = normalized; | |||
| 67 | } catch (error) { | 77 | } catch (error) { | ||
| 68 | console.error('Failed to load shortcuts:', error); | 78 | console.error('Failed to load shortcuts:', error); | ||
| 69 | // Mock shortcuts for development/fallback | 79 | // Mock shortcuts for development/fallback | ||
M internal/config/config.go +2 -2
| @@ -52,8 +52,8 @@ type HistoryConfig struct { | |||
| 52 | 52 | ||
| 53 | 53 | // SearchShortcut represents a search shortcut configuration. | |
| 54 | 54 | type SearchShortcut struct { | |
| 55 | - | URL string `mapstructure:"url" yaml:"url"` | |
| 56 | - | Description string `mapstructure:"description" yaml:"description"` | |
| 55 | + | URL string `mapstructure:"url" yaml:"url" json:"url"` | |
| 56 | + | Description string `mapstructure:"description" yaml:"description" json:"description"` | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | 59 | // DmenuConfig holds dmenu/rofi integration configuration. | |
| @@ -52,8 +52,8 @@ type HistoryConfig struct { | |||||
| 52 | 52 | ||||
| 53 | // SearchShortcut represents a search shortcut configuration. | 53 | // SearchShortcut represents a search shortcut configuration. | ||
| 54 | type SearchShortcut struct { | 54 | type SearchShortcut struct { | ||
| 55 | - | URL string `mapstructure:"url" yaml:"url"` | 55 | + | URL string `mapstructure:"url" yaml:"url" json:"url"` |
| 56 | - | Description string `mapstructure:"description" yaml:"description"` | 56 | + | Description string `mapstructure:"description" yaml:"description" json:"description"` |
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | // DmenuConfig holds dmenu/rofi integration configuration. | 59 | // DmenuConfig holds dmenu/rofi integration configuration. | ||
M internal/parser/parser.go +32 -25
| @@ -1,11 +1,12 @@ | |||
| 1 | 1 | package parser | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "strings" | |
| 6 | - | "time" | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "time" | |
| 7 | + | neturl "net/url" | |
| 7 | 8 | ||
| 8 | - | "github.com/bnema/dumber/internal/config" | |
| 9 | + | "github.com/bnema/dumber/internal/config" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | // ParseInput parses user input and returns a ParseResult with URL and metadata. | |
| @@ -156,10 +157,12 @@ func (p *Parser) parseFallbackSearch(cleanInput, originalInput string, startTime | |||
| 156 | 157 | ||
| 157 | 158 | // processShortcut processes a search shortcut and builds the final URL. | |
| 158 | 159 | func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) { | |
| 159 | - | urlTemplate := shortcut.URL | |
| 160 | + | urlTemplate := shortcut.URL | |
| 160 | 161 | ||
| 161 | - | // Replace placeholder with query | |
| 162 | - | finalURL := strings.ReplaceAll(urlTemplate, "{query}", query) | |
| 162 | + | // Replace placeholder with query | |
| 163 | + | q := neturl.QueryEscape(query) | |
| 164 | + | finalURL := strings.ReplaceAll(urlTemplate, "{query}", q) | |
| 165 | + | finalURL = strings.ReplaceAll(finalURL, "%s", q) | |
| 163 | 166 | ||
| 164 | 167 | // Validate the resulting URL | |
| 165 | 168 | validator := NewURLValidator() | |
| @@ -172,24 +175,28 @@ func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.Sear | |||
| 172 | 175 | ||
| 173 | 176 | // buildSearchURL builds a search URL using the default search engine. | |
| 174 | 177 | 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) | |
| 178 | + | q := neturl.QueryEscape(query) | |
| 179 | + | // If no specific search engine, use Google as default | |
| 180 | + | if searchEngine == "" { | |
| 181 | + | defaultShortcut, exists := p.config.SearchShortcuts["g"] | |
| 182 | + | if exists { | |
| 183 | + | url := strings.ReplaceAll(defaultShortcut.URL, "{query}", q) | |
| 184 | + | url = strings.ReplaceAll(url, "%s", q) | |
| 185 | + | return url | |
| 186 | + | } | |
| 187 | + | // Fallback to Google if no "g" shortcut configured | |
| 188 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", q) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // Use specified search engine | |
| 192 | + | shortcut, exists := p.config.SearchShortcuts[searchEngine] | |
| 193 | + | if !exists { | |
| 194 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", q) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | u := strings.ReplaceAll(shortcut.URL, "{query}", q) | |
| 198 | + | u = strings.ReplaceAll(u, "%s", q) | |
| 199 | + | return u | |
| 193 | 200 | } | |
| 194 | 201 | ||
| 195 | 202 | // FuzzySearchHistory performs fuzzy search on history and returns ranked matches. | |
| @@ -1,11 +1,12 @@ | |||||
| 1 | package parser | 1 | package parser | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "fmt" | 4 | + | "fmt" |
| 5 | - | "strings" | 5 | + | "strings" |
| 6 | - | "time" | 6 | + | "time" |
| 7 | + | neturl "net/url" | |||
| 7 | 8 | ||||
| 8 | - | "github.com/bnema/dumber/internal/config" | 9 | + | "github.com/bnema/dumber/internal/config" |
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | // ParseInput parses user input and returns a ParseResult with URL and metadata. | 12 | // ParseInput parses user input and returns a ParseResult with URL and metadata. | ||
| @@ -156,10 +157,12 @@ func (p *Parser) parseFallbackSearch(cleanInput, originalInput string, startTime | |||||
| 156 | 157 | ||||
| 157 | // processShortcut processes a search shortcut and builds the final URL. | 158 | // processShortcut processes a search shortcut and builds the final URL. | ||
| 158 | func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) { | 159 | func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) { | ||
| 159 | - | urlTemplate := shortcut.URL | 160 | + | urlTemplate := shortcut.URL |
| 160 | 161 | ||||
| 161 | - | // Replace placeholder with query | 162 | + | // Replace placeholder with query |
| 162 | - | finalURL := strings.ReplaceAll(urlTemplate, "{query}", query) | 163 | + | q := neturl.QueryEscape(query) |
| 164 | + | finalURL := strings.ReplaceAll(urlTemplate, "{query}", q) | |||
| 165 | + | finalURL = strings.ReplaceAll(finalURL, "%s", q) | |||
| 163 | 166 | ||||
| 164 | // Validate the resulting URL | 167 | // Validate the resulting URL | ||
| 165 | validator := NewURLValidator() | 168 | validator := NewURLValidator() | ||
| @@ -172,24 +175,28 @@ func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.Sear | |||||
| 172 | 175 | ||||
| 173 | // buildSearchURL builds a search URL using the default search engine. | 176 | // buildSearchURL builds a search URL using the default search engine. | ||
| 174 | func (p *Parser) buildSearchURL(searchEngine, query string) string { | 177 | func (p *Parser) buildSearchURL(searchEngine, query string) string { | ||
| 175 | - | // If no specific search engine, use Google as default | 178 | + | q := neturl.QueryEscape(query) |
| 176 | - | if searchEngine == "" { | 179 | + | // If no specific search engine, use Google as default |
| 177 | - | defaultShortcut, exists := p.config.SearchShortcuts["g"] | 180 | + | if searchEngine == "" { |
| 178 | - | if exists { | 181 | + | defaultShortcut, exists := p.config.SearchShortcuts["g"] |
| 179 | - | url := strings.ReplaceAll(defaultShortcut.URL, "{query}", query) | 182 | + | if exists { |
| 180 | - | return url | 183 | + | url := strings.ReplaceAll(defaultShortcut.URL, "{query}", q) |
| 181 | - | } | 184 | + | url = strings.ReplaceAll(url, "%s", q) |
| 182 | - | // Fallback to Google if no "g" shortcut configured | 185 | + | return url |
| 183 | - | return fmt.Sprintf("https://www.google.com/search?q=%s", query) | 186 | + | } |
| 184 | - | } | 187 | + | // Fallback to Google if no "g" shortcut configured |
| 185 | - | 188 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", q) | |
| 186 | - | // Use specified search engine | 189 | + | } |
| 187 | - | shortcut, exists := p.config.SearchShortcuts[searchEngine] | 190 | + | |
| 188 | - | if !exists { | 191 | + | // Use specified search engine |
| 189 | - | return fmt.Sprintf("https://www.google.com/search?q=%s", query) | 192 | + | shortcut, exists := p.config.SearchShortcuts[searchEngine] |
| 190 | - | } | 193 | + | if !exists { |
| 191 | - | 194 | + | return fmt.Sprintf("https://www.google.com/search?q=%s", q) | |
| 192 | - | return strings.ReplaceAll(shortcut.URL, "{query}", query) | 195 | + | } |
| 196 | + | ||||
| 197 | + | u := strings.ReplaceAll(shortcut.URL, "{query}", q) | |||
| 198 | + | u = strings.ReplaceAll(u, "%s", q) | |||
| 199 | + | return u | |||
| 193 | } | 200 | } | ||
| 194 | 201 | ||||
| 195 | // FuzzySearchHistory performs fuzzy search on history and returns ranked matches. | 202 | // FuzzySearchHistory performs fuzzy search on history and returns ranked matches. | ||