fix: handle FTS5 periods and prioritize selected omnibox results
- commit
- 27c9c62ef751205954ea4fff492f2ffd69f1efec
- author
- brice · · 2026-01-23 17:40 UTC
- parent
- d079703
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +93 -21
M internal/infrastructure/persistence/sqlite/history_repo.go +9 -2
| @@ -50,9 +50,14 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor | |||
| 50 | 50 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | |
| 51 | 51 | log := logging.FromContext(ctx) | |
| 52 | 52 | ||
| 53 | + | // Replace periods with spaces before splitting - FTS5 tokenizer treats periods as separators | |
| 54 | + | // so "gordon.bne" in URLs is indexed as "gordon" + "bne" tokens | |
| 55 | + | // This allows searching "gordon.bne" to match URLs containing both terms | |
| 56 | + | normalizedQuery := strings.ReplaceAll(query, ".", " ") | |
| 57 | + | ||
| 53 | 58 | // Split query into words and add prefix matching to each | |
| 54 | 59 | // This enables multi-word searches like "github issues" -> "github* issues*" | |
| 55 | - | words := strings.Fields(query) | |
| 60 | + | words := strings.Fields(normalizedQuery) | |
| 56 | 61 | if len(words) == 0 { | |
| 57 | 62 | return []entity.HistoryMatch{}, nil | |
| 58 | 63 | } | |
| @@ -67,8 +72,9 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||
| 67 | 72 | ||
| 68 | 73 | // Search both URL and title columns, then merge results | |
| 69 | 74 | // Use domain-boosted query for URL search to prioritize domain matches | |
| 75 | + | // Use sanitized first word for domain boost (intentional: domain boost targets first search term) | |
| 70 | 76 | urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{ | |
| 71 | - | Term: sql.NullString{String: words[0], Valid: true}, | |
| 77 | + | Term: sql.NullString{String: sanitizeFTS5Word(words[0]), Valid: true}, | |
| 72 | 78 | Query: ftsQuery, | |
| 73 | 79 | Limit: int64(limit), | |
| 74 | 80 | }) | |
| @@ -136,6 +142,7 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) * | |||
| 136 | 142 | ||
| 137 | 143 | // sanitizeFTS5Word removes FTS5 special characters from a search word. | |
| 138 | 144 | // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR | |
| 145 | + | // Note: Periods are handled earlier by replacing with spaces (tokenizer separator) | |
| 139 | 146 | func sanitizeFTS5Word(word string) string { | |
| 140 | 147 | // Remove characters that have special meaning in FTS5 | |
| 141 | 148 | var result strings.Builder | |
| @@ -50,9 +50,14 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor | |||||
| 50 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | 50 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | ||
| 51 | log := logging.FromContext(ctx) | 51 | log := logging.FromContext(ctx) | ||
| 52 | 52 | ||||
| 53 | + | // Replace periods with spaces before splitting - FTS5 tokenizer treats periods as separators | |||
| 54 | + | // so "gordon.bne" in URLs is indexed as "gordon" + "bne" tokens | |||
| 55 | + | // This allows searching "gordon.bne" to match URLs containing both terms | |||
| 56 | + | normalizedQuery := strings.ReplaceAll(query, ".", " ") | |||
| 57 | + | ||||
| 53 | // Split query into words and add prefix matching to each | 58 | // Split query into words and add prefix matching to each | ||
| 54 | // This enables multi-word searches like "github issues" -> "github* issues*" | 59 | // This enables multi-word searches like "github issues" -> "github* issues*" | ||
| 55 | - | words := strings.Fields(query) | 60 | + | words := strings.Fields(normalizedQuery) |
| 56 | if len(words) == 0 { | 61 | if len(words) == 0 { | ||
| 57 | return []entity.HistoryMatch{}, nil | 62 | return []entity.HistoryMatch{}, nil | ||
| 58 | } | 63 | } | ||
| @@ -67,8 +72,9 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||||
| 67 | 72 | ||||
| 68 | // Search both URL and title columns, then merge results | 73 | // Search both URL and title columns, then merge results | ||
| 69 | // Use domain-boosted query for URL search to prioritize domain matches | 74 | // Use domain-boosted query for URL search to prioritize domain matches | ||
| 75 | + | // Use sanitized first word for domain boost (intentional: domain boost targets first search term) | |||
| 70 | urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{ | 76 | urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{ | ||
| 71 | - | Term: sql.NullString{String: words[0], Valid: true}, | 77 | + | Term: sql.NullString{String: sanitizeFTS5Word(words[0]), Valid: true}, |
| 72 | Query: ftsQuery, | 78 | Query: ftsQuery, | ||
| 73 | Limit: int64(limit), | 79 | Limit: int64(limit), | ||
| 74 | }) | 80 | }) | ||
| @@ -136,6 +142,7 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) * | |||||
| 136 | 142 | ||||
| 137 | // sanitizeFTS5Word removes FTS5 special characters from a search word. | 143 | // sanitizeFTS5Word removes FTS5 special characters from a search word. | ||
| 138 | // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR | 144 | // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR | ||
| 145 | + | // Note: Periods are handled earlier by replacing with spaces (tokenizer separator) | |||
| 139 | func sanitizeFTS5Word(word string) string { | 146 | func sanitizeFTS5Word(word string) string { | ||
| 140 | // Remove characters that have special meaning in FTS5 | 147 | // Remove characters that have special meaning in FTS5 | ||
| 141 | var result strings.Builder | 148 | var result strings.Builder | ||
M internal/infrastructure/persistence/sqlite/history_repo_test.go +71 -0
| @@ -484,3 +484,74 @@ func TestHistoryRepository_Search_PrefixMatching(t *testing.T) { | |||
| 484 | 484 | assert.True(t, urls["https://github.com"]) | |
| 485 | 485 | assert.True(t, urls["https://gitlab.com"]) | |
| 486 | 486 | } | |
| 487 | + | ||
| 488 | + | func TestHistoryRepository_Search_DomainLikeQuery(t *testing.T) { | |
| 489 | + | ctx := historyTestCtx() | |
| 490 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 491 | + | ||
| 492 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 493 | + | require.NoError(t, err) | |
| 494 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 495 | + | ||
| 496 | + | repo := sqlite.NewHistoryRepository(db) | |
| 497 | + | ||
| 498 | + | // Save entries with domain-like URLs | |
| 499 | + | entries := []*entity.HistoryEntry{ | |
| 500 | + | {URL: "https://gordon.bnema.dev/", Title: "Gordon - Self-hosted Deployment Platform"}, | |
| 501 | + | {URL: "https://example.com", Title: "Example Site"}, | |
| 502 | + | {URL: "https://other.gordon.io", Title: "Another Gordon Site"}, | |
| 503 | + | } | |
| 504 | + | for _, e := range entries { | |
| 505 | + | require.NoError(t, repo.Save(ctx, e)) | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | // Search with period in query (domain-like) - periods should be treated as separators | |
| 509 | + | // "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema) | |
| 510 | + | results, err := repo.Search(ctx, "gordon.bnem", 10) | |
| 511 | + | require.NoError(t, err) | |
| 512 | + | require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev") | |
| 513 | + | ||
| 514 | + | // Verify the gordon.bnema.dev entry is found | |
| 515 | + | found := false | |
| 516 | + | for _, r := range results { | |
| 517 | + | if r.Entry.URL == "https://gordon.bnema.dev/" { | |
| 518 | + | found = true | |
| 519 | + | break | |
| 520 | + | } | |
| 521 | + | } | |
| 522 | + | assert.True(t, found, "gordon.bnema.dev should be in results") | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) { | |
| 526 | + | ctx := historyTestCtx() | |
| 527 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 528 | + | ||
| 529 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 530 | + | require.NoError(t, err) | |
| 531 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 532 | + | ||
| 533 | + | repo := sqlite.NewHistoryRepository(db) | |
| 534 | + | ||
| 535 | + | // Save entry | |
| 536 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |
| 537 | + | URL: "https://example.com/path", | |
| 538 | + | Title: "Example Page", | |
| 539 | + | })) | |
| 540 | + | ||
| 541 | + | // Search with FTS5 special characters - should not cause errors | |
| 542 | + | specialQueries := []string{ | |
| 543 | + | "example:", // colon | |
| 544 | + | "example*", // asterisk | |
| 545 | + | "example()", // parentheses | |
| 546 | + | `example"test`, // quotes | |
| 547 | + | "example^", // caret | |
| 548 | + | "example-test", // hyphen | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | for _, q := range specialQueries { | |
| 552 | + | results, err := repo.Search(ctx, q, 10) | |
| 553 | + | require.NoError(t, err, "query %q should not cause error", q) | |
| 554 | + | // May or may not find results, but should not error | |
| 555 | + | _ = results | |
| 556 | + | } | |
| 557 | + | } | |
| @@ -484,3 +484,74 @@ func TestHistoryRepository_Search_PrefixMatching(t *testing.T) { | |||||
| 484 | assert.True(t, urls["https://github.com"]) | 484 | assert.True(t, urls["https://github.com"]) | ||
| 485 | assert.True(t, urls["https://gitlab.com"]) | 485 | assert.True(t, urls["https://gitlab.com"]) | ||
| 486 | } | 486 | } | ||
| 487 | + | ||||
| 488 | + | func TestHistoryRepository_Search_DomainLikeQuery(t *testing.T) { | |||
| 489 | + | ctx := historyTestCtx() | |||
| 490 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 491 | + | ||||
| 492 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 493 | + | require.NoError(t, err) | |||
| 494 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 495 | + | ||||
| 496 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 497 | + | ||||
| 498 | + | // Save entries with domain-like URLs | |||
| 499 | + | entries := []*entity.HistoryEntry{ | |||
| 500 | + | {URL: "https://gordon.bnema.dev/", Title: "Gordon - Self-hosted Deployment Platform"}, | |||
| 501 | + | {URL: "https://example.com", Title: "Example Site"}, | |||
| 502 | + | {URL: "https://other.gordon.io", Title: "Another Gordon Site"}, | |||
| 503 | + | } | |||
| 504 | + | for _, e := range entries { | |||
| 505 | + | require.NoError(t, repo.Save(ctx, e)) | |||
| 506 | + | } | |||
| 507 | + | ||||
| 508 | + | // Search with period in query (domain-like) - periods should be treated as separators | |||
| 509 | + | // "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema) | |||
| 510 | + | results, err := repo.Search(ctx, "gordon.bnem", 10) | |||
| 511 | + | require.NoError(t, err) | |||
| 512 | + | require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev") | |||
| 513 | + | ||||
| 514 | + | // Verify the gordon.bnema.dev entry is found | |||
| 515 | + | found := false | |||
| 516 | + | for _, r := range results { | |||
| 517 | + | if r.Entry.URL == "https://gordon.bnema.dev/" { | |||
| 518 | + | found = true | |||
| 519 | + | break | |||
| 520 | + | } | |||
| 521 | + | } | |||
| 522 | + | assert.True(t, found, "gordon.bnema.dev should be in results") | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) { | |||
| 526 | + | ctx := historyTestCtx() | |||
| 527 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 528 | + | ||||
| 529 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 530 | + | require.NoError(t, err) | |||
| 531 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 532 | + | ||||
| 533 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 534 | + | ||||
| 535 | + | // Save entry | |||
| 536 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |||
| 537 | + | URL: "https://example.com/path", | |||
| 538 | + | Title: "Example Page", | |||
| 539 | + | })) | |||
| 540 | + | ||||
| 541 | + | // Search with FTS5 special characters - should not cause errors | |||
| 542 | + | specialQueries := []string{ | |||
| 543 | + | "example:", // colon | |||
| 544 | + | "example*", // asterisk | |||
| 545 | + | "example()", // parentheses | |||
| 546 | + | `example"test`, // quotes | |||
| 547 | + | "example^", // caret | |||
| 548 | + | "example-test", // hyphen | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | for _, q := range specialQueries { | |||
| 552 | + | results, err := repo.Search(ctx, q, 10) | |||
| 553 | + | require.NoError(t, err, "query %q should not cause error", q) | |||
| 554 | + | // May or may not find results, but should not error | |||
| 555 | + | _ = results | |||
| 556 | + | } | |||
| 557 | + | } | |||
M internal/ui/component/omnibox.go +13 -19
| @@ -1272,21 +1272,21 @@ func (o *Omnibox) navigateToSelected() { | |||
| 1272 | 1272 | ||
| 1273 | 1273 | var targetURL string | |
| 1274 | 1274 | ||
| 1275 | - | // Check if user typed a URL-like string (contains . and no spaces) | |
| 1276 | - | // If so, navigate to that URL directly instead of the selection | |
| 1277 | - | if o.looksLikeURL(entryText) { | |
| 1278 | - | targetURL = o.buildURL(entryText) | |
| 1279 | - | } else if idx < 0 { | |
| 1280 | - | // No selection - use entry text as URL/search | |
| 1281 | - | targetURL = o.buildURL(entryText) | |
| 1282 | - | } else if mode == ViewModeHistory { | |
| 1283 | - | if idx < len(suggestions) { | |
| 1284 | - | targetURL = suggestions[idx].URL | |
| 1275 | + | // If user has selected a result, navigate to that result | |
| 1276 | + | // Otherwise, treat entry text as URL/search | |
| 1277 | + | if idx >= 0 { | |
| 1278 | + | if mode == ViewModeHistory { | |
| 1279 | + | if idx < len(suggestions) { | |
| 1280 | + | targetURL = suggestions[idx].URL | |
| 1281 | + | } | |
| 1282 | + | } else { | |
| 1283 | + | if idx < len(favorites) { | |
| 1284 | + | targetURL = favorites[idx].URL | |
| 1285 | + | } | |
| 1285 | 1286 | } | |
| 1286 | 1287 | } else { | |
| 1287 | - | if idx < len(favorites) { | |
| 1288 | - | targetURL = favorites[idx].URL | |
| 1289 | - | } | |
| 1288 | + | // No selection - use entry text as URL/search | |
| 1289 | + | targetURL = o.buildURL(entryText) | |
| 1290 | 1290 | } | |
| 1291 | 1291 | ||
| 1292 | 1292 | if targetURL == "" { | |
| @@ -1300,12 +1300,6 @@ func (o *Omnibox) navigateToSelected() { | |||
| 1300 | 1300 | } | |
| 1301 | 1301 | } | |
| 1302 | 1302 | ||
| 1303 | - | // looksLikeURL checks if the text appears to be a URL (not a search query). | |
| 1304 | - | // Returns true for strings like "github.com", "google.com/search", etc. | |
| 1305 | - | func (o *Omnibox) looksLikeURL(text string) bool { | |
| 1306 | - | return url.LooksLikeURL(text) | |
| 1307 | - | } | |
| 1308 | - | ||
| 1309 | 1303 | // toggleFavorite adds or removes the selected item from favorites. | |
| 1310 | 1304 | // In History mode: adds the selected item to favorites | |
| 1311 | 1305 | // In Favorites mode: removes the selected item from favorites | |
| @@ -1272,21 +1272,21 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 1272 | 1272 | ||||
| 1273 | var targetURL string | 1273 | var targetURL string | ||
| 1274 | 1274 | ||||
| 1275 | - | // Check if user typed a URL-like string (contains . and no spaces) | 1275 | + | // If user has selected a result, navigate to that result |
| 1276 | - | // If so, navigate to that URL directly instead of the selection | 1276 | + | // Otherwise, treat entry text as URL/search |
| 1277 | - | if o.looksLikeURL(entryText) { | 1277 | + | if idx >= 0 { |
| 1278 | - | targetURL = o.buildURL(entryText) | 1278 | + | if mode == ViewModeHistory { |
| 1279 | - | } else if idx < 0 { | 1279 | + | if idx < len(suggestions) { |
| 1280 | - | // No selection - use entry text as URL/search | 1280 | + | targetURL = suggestions[idx].URL |
| 1281 | - | targetURL = o.buildURL(entryText) | 1281 | + | } |
| 1282 | - | } else if mode == ViewModeHistory { | 1282 | + | } else { |
| 1283 | - | if idx < len(suggestions) { | 1283 | + | if idx < len(favorites) { |
| 1284 | - | targetURL = suggestions[idx].URL | 1284 | + | targetURL = favorites[idx].URL |
| 1285 | + | } | |||
| 1285 | } | 1286 | } | ||
| 1286 | } else { | 1287 | } else { | ||
| 1287 | - | if idx < len(favorites) { | 1288 | + | // No selection - use entry text as URL/search |
| 1288 | - | targetURL = favorites[idx].URL | 1289 | + | targetURL = o.buildURL(entryText) |
| 1289 | - | } | |||
| 1290 | } | 1290 | } | ||
| 1291 | 1291 | ||||
| 1292 | if targetURL == "" { | 1292 | if targetURL == "" { | ||
| @@ -1300,12 +1300,6 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 1300 | } | 1300 | } | ||
| 1301 | } | 1301 | } | ||
| 1302 | 1302 | ||||
| 1303 | - | // looksLikeURL checks if the text appears to be a URL (not a search query). | |||
| 1304 | - | // Returns true for strings like "github.com", "google.com/search", etc. | |||
| 1305 | - | func (o *Omnibox) looksLikeURL(text string) bool { | |||
| 1306 | - | return url.LooksLikeURL(text) | |||
| 1307 | - | } | |||
| 1308 | - | ||||
| 1309 | // toggleFavorite adds or removes the selected item from favorites. | 1303 | // toggleFavorite adds or removes the selected item from favorites. | ||
| 1310 | // In History mode: adds the selected item to favorites | 1304 | // In History mode: adds the selected item to favorites | ||
| 1311 | // In Favorites mode: removes the selected item from favorites | 1305 | // In Favorites mode: removes the selected item from favorites | ||