fix: global search case-insensitive + keyboard layout swap

Unified search normalization across InternalLinkPicker and GlobalSearch:

1. GlobalSearch.svelte: multi-variant search (same as InternalLinkPicker)
   - expandKeyboardVariants() for RU/EN layout swap
   - Parallel Search queries with dedup by type+nodeId+targetId+title
   - 180ms debounce preserved

2. Backend: fix LOWER() in SQL for links/actions
   - Replace LOWER(column) LIKE with lowercased columns (title_lower, url_lower, etc.)
   - Migration 020: add lowercased columns + indexes for links and actions
   - BackfillLinksLower() + BackfillActionsLower() in storage.go
   - Update INSERT in bindings_links.go and action.go to populate lowercased columns

3. FTS5 search: Unicode case-insensitive
   - Index lowercased title/content/tags in search_index
   - sanitizeFTS() now lowercases query before MATCH
   - RebuildFTS() called after migrations

4. Case-insensitive search for nodes (already done in previous commit, verified):
   - title_lower column with Go strings.ToLower
   - Search() queries title_lower with lowercased query

All test suites PASS, full build OK.
This commit is contained in:
2026-06-15 10:52:34 +08:00
parent 88eb99e9af
commit 700e4dae5b
9 changed files with 187 additions and 32 deletions
+4 -2
View File
@@ -88,11 +88,13 @@ func (s *Service) Create(nodeID, kind, title, command, workingDir, url string, a
_, err := s.db.Exec(
`INSERT INTO actions (id,node_id,title,kind,command,args_json,working_dir,url,
confirm_required,capture_output,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
confirm_required,capture_output,created_at,updated_at,
title_lower,kind_lower,url_lower,command_lower)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
rec.ID, rec.NodeID, rec.Title, rec.Kind, rec.Command, string(argsJSON),
rec.WorkingDir, rec.URL, boolInt(rec.ConfirmRequired), boolInt(rec.CaptureOutput),
rec.CreatedAt.Format(time.RFC3339), rec.UpdatedAt.Format(time.RFC3339),
strings.ToLower(rec.Title), strings.ToLower(rec.Kind), strings.ToLower(rec.URL), strings.ToLower(rec.Command),
)
if err != nil {
return nil, err
+3 -1
View File
@@ -32,7 +32,7 @@ func (s *Service) Index(nodeID, title, content, path, tags, docType string) erro
_, err := s.db.Exec(
`INSERT INTO search_index (node_id,title,content,path,tags,type)
VALUES (?,?,?,?,?,?)`,
nodeID, title, content, path, tags, docType,
nodeID, strings.ToLower(title), strings.ToLower(content), path, strings.ToLower(tags), docType,
)
return err
}
@@ -92,7 +92,9 @@ func (s *Service) Search(query string) ([]Result, error) {
func sanitizeFTS(q string) string {
// Wrap in double quotes for phrase search, escape inner quotes.
// Also lowercase the query since we index lowercased content.
q = strings.TrimSpace(q)
q = strings.ToLower(q)
q = strings.ReplaceAll(q, `"`, `""`)
return `"` + q + `"`
}
@@ -0,0 +1,34 @@
package storage
// migration020 — add title_lower columns to links and actions for case-insensitive search.
const migration020 = `
ALTER TABLE links ADD COLUMN title_lower TEXT NOT NULL DEFAULT '';
ALTER TABLE links ADD COLUMN url_lower TEXT NOT NULL DEFAULT '';
ALTER TABLE links ADD COLUMN hostname_lower TEXT NOT NULL DEFAULT '';
ALTER TABLE links ADD COLUMN note_lower TEXT NOT NULL DEFAULT '';
UPDATE links SET title_lower = LOWER(title) WHERE title_lower = '';
UPDATE links SET url_lower = LOWER(url) WHERE url_lower = '';
UPDATE links SET hostname_lower = LOWER(hostname) WHERE hostname_lower = '';
UPDATE links SET note_lower = LOWER(note) WHERE note_lower = '';
CREATE INDEX IF NOT EXISTS idx_links_title_lower ON links(title_lower);
CREATE INDEX IF NOT EXISTS idx_links_url_lower ON links(url_lower);
CREATE INDEX IF NOT EXISTS idx_links_hostname_lower ON links(hostname_lower);
CREATE INDEX IF NOT EXISTS idx_links_note_lower ON links(note_lower);
ALTER TABLE actions ADD COLUMN title_lower TEXT NOT NULL DEFAULT '';
ALTER TABLE actions ADD COLUMN kind_lower TEXT NOT NULL DEFAULT '';
ALTER TABLE actions ADD COLUMN url_lower TEXT NOT NULL DEFAULT '';
ALTER TABLE actions ADD COLUMN command_lower TEXT NOT NULL DEFAULT '';
UPDATE actions SET title_lower = LOWER(title) WHERE title_lower = '';
UPDATE actions SET kind_lower = LOWER(kind) WHERE kind_lower = '';
UPDATE actions SET url_lower = LOWER(url) WHERE url_lower = '';
UPDATE actions SET command_lower = LOWER(command) WHERE command_lower = '';
CREATE INDEX IF NOT EXISTS idx_actions_title_lower ON actions(title_lower);
CREATE INDEX IF NOT EXISTS idx_actions_kind_lower ON actions(kind_lower);
CREATE INDEX IF NOT EXISTS idx_actions_url_lower ON actions(url_lower);
CREATE INDEX IF NOT EXISTS idx_actions_command_lower ON actions(command_lower);
`
+87
View File
@@ -43,6 +43,16 @@ func Open(path string) (*DB, error) {
db.Close()
return nil, err
}
if err := w.BackfillLinksLower(); err != nil {
db.Close()
return nil, err
}
if err := w.BackfillActionsLower(); err != nil {
db.Close()
return nil, err
}
// Rebuild FTS5 index to pick up lowercased indexing changes
_ = w.RebuildFTS()
return w, nil
}
@@ -80,6 +90,7 @@ var migrationFiles = map[int]string{
17: migration017,
18: migration018,
19: migration019,
20: migration020,
}
func (db *DB) runInitialSchema() error {
@@ -172,3 +183,79 @@ func (db *DB) BackfillTitleLower() error {
}
return nil
}
// BackfillLinksLower populates lowercased columns for links where they are empty.
func (db *DB) BackfillLinksLower() error {
rows, err := db.Query("SELECT id, title, url, hostname, COALESCE(note,'') FROM links WHERE title_lower = ''")
if err != nil {
return err
}
defer rows.Close()
type linkLower struct {
id, title, url, hostname, note string
}
var items []linkLower
for rows.Next() {
var l linkLower
if err := rows.Scan(&l.id, &l.title, &l.url, &l.hostname, &l.note); err != nil {
return err
}
items = append(items, l)
}
if err := rows.Err(); err != nil {
return err
}
for _, l := range items {
if _, err := db.Exec(
"UPDATE links SET title_lower=?, url_lower=?, hostname_lower=?, note_lower=? WHERE id=?",
strings.ToLower(l.title), strings.ToLower(l.url), strings.ToLower(l.hostname), strings.ToLower(l.note), l.id,
); err != nil {
return err
}
}
return nil
}
// BackfillActionsLower populates lowercased columns for actions where they are empty.
func (db *DB) BackfillActionsLower() error {
rows, err := db.Query("SELECT id, title, kind, COALESCE(url,''), COALESCE(command,'') FROM actions WHERE title_lower = ''")
if err != nil {
return err
}
defer rows.Close()
type actionLower struct {
id, title, kind, url, command string
}
var items []actionLower
for rows.Next() {
var a actionLower
if err := rows.Scan(&a.id, &a.title, &a.kind, &a.url, &a.command); err != nil {
return err
}
items = append(items, a)
}
if err := rows.Err(); err != nil {
return err
}
for _, a := range items {
if _, err := db.Exec(
"UPDATE actions SET title_lower=?, kind_lower=?, url_lower=?, command_lower=? WHERE id=?",
strings.ToLower(a.title), strings.ToLower(a.kind), strings.ToLower(a.url), strings.ToLower(a.command), a.id,
); err != nil {
return err
}
}
return nil
}
// RebuildFTS rebuilds the FTS5 search index (e.g. after schema changes).
func (db *DB) RebuildFTS() error {
_, _ = db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
node_id UNINDEXED, title, content, path, tags, type)`)
_, err := db.Exec("DELETE FROM search_index")
return err
}