fix: sanitize FTS5 special chars and interleave URL/title results
- commit
- ed73d491c3c87dc9cf969bd606f382a605000c4a
- author
- brice · · 2026-01-01 13:53 UTC
- parent
- 8ba0228
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +43 -24
M internal/infrastructure/persistence/sqlite/history_repo.go +43 -24
| @@ -56,9 +56,10 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | 58 | // Build FTS5 query: "word1* word2*" (implicit AND between terms) | |
| 59 | + | // Sanitize each word to remove FTS5 special characters | |
| 59 | 60 | parts := make([]string, len(words)) | |
| 60 | 61 | for i, word := range words { | |
| 61 | - | parts[i] = word + "*" | |
| 62 | + | parts[i] = sanitizeFTS5Word(word) + "*" | |
| 62 | 63 | } | |
| 63 | 64 | ftsQuery := strings.Join(parts, " ") | |
| 64 | 65 | ||
| @@ -81,40 +82,58 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||
| 81 | 82 | titleRows = []sqlc.History{} | |
| 82 | 83 | } | |
| 83 | 84 | ||
| 84 | - | // Merge results, deduplicating by ID | |
| 85 | + | // Merge results by interleaving URL and title matches for balanced results | |
| 85 | 86 | seen := make(map[int64]bool) | |
| 86 | 87 | var matches []entity.HistoryMatch | |
| 87 | 88 | ||
| 88 | - | for i := range urlRows { | |
| 89 | - | row := urlRows[i] | |
| 90 | - | if !seen[row.ID] { | |
| 91 | - | seen[row.ID] = true | |
| 92 | - | matches = append(matches, entity.HistoryMatch{ | |
| 93 | - | Entry: historyFromRow(row), | |
| 94 | - | Score: 1.0, | |
| 95 | - | }) | |
| 89 | + | i, j := 0, 0 | |
| 90 | + | for len(matches) < limit && (i < len(urlRows) || j < len(titleRows)) { | |
| 91 | + | // Take next URL match if available | |
| 92 | + | if i < len(urlRows) { | |
| 93 | + | row := urlRows[i] | |
| 94 | + | i++ | |
| 95 | + | if !seen[row.ID] { | |
| 96 | + | seen[row.ID] = true | |
| 97 | + | matches = append(matches, entity.HistoryMatch{ | |
| 98 | + | Entry: historyFromRow(row), | |
| 99 | + | Score: 1.0, | |
| 100 | + | }) | |
| 101 | + | } | |
| 96 | 102 | } | |
| 97 | - | } | |
| 98 | 103 | ||
| 99 | - | for i := range titleRows { | |
| 100 | - | row := titleRows[i] | |
| 101 | - | if !seen[row.ID] { | |
| 102 | - | seen[row.ID] = true | |
| 103 | - | matches = append(matches, entity.HistoryMatch{ | |
| 104 | - | Entry: historyFromRow(row), | |
| 105 | - | Score: 1.0, | |
| 106 | - | }) | |
| 104 | + | // Take next title match if available | |
| 105 | + | if j < len(titleRows) && len(matches) < limit { | |
| 106 | + | row := titleRows[j] | |
| 107 | + | j++ | |
| 108 | + | if !seen[row.ID] { | |
| 109 | + | seen[row.ID] = true | |
| 110 | + | matches = append(matches, entity.HistoryMatch{ | |
| 111 | + | Entry: historyFromRow(row), | |
| 112 | + | Score: 1.0, | |
| 113 | + | }) | |
| 114 | + | } | |
| 107 | 115 | } | |
| 108 | 116 | } | |
| 109 | 117 | ||
| 110 | - | // Limit results | |
| 111 | - | if len(matches) > limit { | |
| 112 | - | matches = matches[:limit] | |
| 113 | - | } | |
| 114 | - | ||
| 115 | 118 | return matches, nil | |
| 116 | 119 | } | |
| 117 | 120 | ||
| 121 | + | // sanitizeFTS5Word removes FTS5 special characters from a search word. | |
| 122 | + | // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR | |
| 123 | + | func sanitizeFTS5Word(word string) string { | |
| 124 | + | // Remove characters that have special meaning in FTS5 | |
| 125 | + | var result strings.Builder | |
| 126 | + | for _, r := range word { | |
| 127 | + | switch r { | |
| 128 | + | case '"', '*', '(', ')', ':', '^', '-': | |
| 129 | + | // Skip FTS5 special characters | |
| 130 | + | default: | |
| 131 | + | result.WriteRune(r) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | return result.String() | |
| 135 | + | } | |
| 136 | + | ||
| 118 | 137 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| 119 | 138 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | |
| 120 | 139 | Limit: int64(limit), | |
| @@ -56,9 +56,10 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | // Build FTS5 query: "word1* word2*" (implicit AND between terms) | 58 | // Build FTS5 query: "word1* word2*" (implicit AND between terms) | ||
| 59 | + | // Sanitize each word to remove FTS5 special characters | |||
| 59 | parts := make([]string, len(words)) | 60 | parts := make([]string, len(words)) | ||
| 60 | for i, word := range words { | 61 | for i, word := range words { | ||
| 61 | - | parts[i] = word + "*" | 62 | + | parts[i] = sanitizeFTS5Word(word) + "*" |
| 62 | } | 63 | } | ||
| 63 | ftsQuery := strings.Join(parts, " ") | 64 | ftsQuery := strings.Join(parts, " ") | ||
| 64 | 65 | ||||
| @@ -81,40 +82,58 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||||
| 81 | titleRows = []sqlc.History{} | 82 | titleRows = []sqlc.History{} | ||
| 82 | } | 83 | } | ||
| 83 | 84 | ||||
| 84 | - | // Merge results, deduplicating by ID | 85 | + | // Merge results by interleaving URL and title matches for balanced results |
| 85 | seen := make(map[int64]bool) | 86 | seen := make(map[int64]bool) | ||
| 86 | var matches []entity.HistoryMatch | 87 | var matches []entity.HistoryMatch | ||
| 87 | 88 | ||||
| 88 | - | for i := range urlRows { | 89 | + | i, j := 0, 0 |
| 89 | - | row := urlRows[i] | 90 | + | for len(matches) < limit && (i < len(urlRows) || j < len(titleRows)) { |
| 90 | - | if !seen[row.ID] { | 91 | + | // Take next URL match if available |
| 91 | - | seen[row.ID] = true | 92 | + | if i < len(urlRows) { |
| 92 | - | matches = append(matches, entity.HistoryMatch{ | 93 | + | row := urlRows[i] |
| 93 | - | Entry: historyFromRow(row), | 94 | + | i++ |
| 94 | - | Score: 1.0, | 95 | + | if !seen[row.ID] { |
| 95 | - | }) | 96 | + | seen[row.ID] = true |
| 97 | + | matches = append(matches, entity.HistoryMatch{ | |||
| 98 | + | Entry: historyFromRow(row), | |||
| 99 | + | Score: 1.0, | |||
| 100 | + | }) | |||
| 101 | + | } | |||
| 96 | } | 102 | } | ||
| 97 | - | } | |||
| 98 | 103 | ||||
| 99 | - | for i := range titleRows { | 104 | + | // Take next title match if available |
| 100 | - | row := titleRows[i] | 105 | + | if j < len(titleRows) && len(matches) < limit { |
| 101 | - | if !seen[row.ID] { | 106 | + | row := titleRows[j] |
| 102 | - | seen[row.ID] = true | 107 | + | j++ |
| 103 | - | matches = append(matches, entity.HistoryMatch{ | 108 | + | if !seen[row.ID] { |
| 104 | - | Entry: historyFromRow(row), | 109 | + | seen[row.ID] = true |
| 105 | - | Score: 1.0, | 110 | + | matches = append(matches, entity.HistoryMatch{ |
| 106 | - | }) | 111 | + | Entry: historyFromRow(row), |
| 112 | + | Score: 1.0, | |||
| 113 | + | }) | |||
| 114 | + | } | |||
| 107 | } | 115 | } | ||
| 108 | } | 116 | } | ||
| 109 | 117 | ||||
| 110 | - | // Limit results | |||
| 111 | - | if len(matches) > limit { | |||
| 112 | - | matches = matches[:limit] | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | return matches, nil | 118 | return matches, nil | ||
| 116 | } | 119 | } | ||
| 117 | 120 | ||||
| 121 | + | // sanitizeFTS5Word removes FTS5 special characters from a search word. | |||
| 122 | + | // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR | |||
| 123 | + | func sanitizeFTS5Word(word string) string { | |||
| 124 | + | // Remove characters that have special meaning in FTS5 | |||
| 125 | + | var result strings.Builder | |||
| 126 | + | for _, r := range word { | |||
| 127 | + | switch r { | |||
| 128 | + | case '"', '*', '(', ')', ':', '^', '-': | |||
| 129 | + | // Skip FTS5 special characters | |||
| 130 | + | default: | |||
| 131 | + | result.WriteRune(r) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | return result.String() | |||
| 135 | + | } | |||
| 136 | + | ||||
| 118 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | 137 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| 119 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | 138 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | ||
| 120 | Limit: int64(limit), | 139 | Limit: int64(limit), | ||