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:
mirivlad 2026-06-15 10:52:34 +08:00
parent 88eb99e9af
commit 700e4dae5b
9 changed files with 187 additions and 32 deletions

View File

@ -127,9 +127,11 @@ func (a *App) createResolvedLink(nodeID, rawURL, title, note, source, capturedAt
now := time.Now().UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339)
id := util.UUID7() id := util.UUID7()
if _, err := a.db.Exec( if _, err := a.db.Exec(
`INSERT INTO links (id,node_id,title,url,hostname,note,source,captured_at,created_at,updated_at) `INSERT INTO links (id,node_id,title,url,hostname,note,source,captured_at,created_at,updated_at,
VALUES (?,?,?,?,?,?,?,?,?,?)`, title_lower,url_lower,hostname_lower,note_lower)
id, nodeID, title, rawURL, hostnameForURL(rawURL), note, source, capturedAt, now, now); err != nil { VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, nodeID, title, rawURL, hostnameForURL(rawURL), note, source, capturedAt, now, now,
strings.ToLower(title), strings.ToLower(rawURL), strings.ToLower(hostnameForURL(rawURL)), strings.ToLower(note)); err != nil {
return nil, err return nil, err
} }
return a.getLink(id) return a.getLink(id)

View File

@ -321,7 +321,7 @@ func (a *App) Search(query string) ([]SearchResultDTO, error) {
FROM links l FROM links l
LEFT JOIN nodes n ON n.id = l.node_id LEFT JOIN nodes n ON n.id = l.node_id
WHERE n.deleted_at IS NULL WHERE n.deleted_at IS NULL
AND (LOWER(l.title) LIKE ? OR LOWER(l.url) LIKE ? OR LOWER(l.hostname) LIKE ? OR LOWER(COALESCE(l.note,'')) LIKE ?) AND (l.title_lower LIKE ? OR l.url_lower LIKE ? OR l.hostname_lower LIKE ? OR l.note_lower LIKE ?)
ORDER BY l.created_at DESC ORDER BY l.created_at DESC
LIMIT ?`, LIMIT ?`,
likeQuery(query), likeQuery(query), likeQuery(query), likeQuery(query), 20-len(out)) likeQuery(query), likeQuery(query), likeQuery(query), likeQuery(query), 20-len(out))
@ -359,7 +359,7 @@ func (a *App) Search(query string) ([]SearchResultDTO, error) {
FROM actions ac FROM actions ac
LEFT JOIN nodes n ON n.id = ac.node_id LEFT JOIN nodes n ON n.id = ac.node_id
WHERE n.deleted_at IS NULL WHERE n.deleted_at IS NULL
AND (LOWER(ac.title) LIKE ? OR LOWER(ac.kind) LIKE ? OR LOWER(COALESCE(ac.url,'')) LIKE ? OR LOWER(COALESCE(ac.command,'')) LIKE ?) AND (ac.title_lower LIKE ? OR ac.kind_lower LIKE ? OR ac.url_lower LIKE ? OR ac.command_lower LIKE ?)
ORDER BY ac.created_at DESC ORDER BY ac.created_at DESC
LIMIT ?`, LIMIT ?`,
likeQuery(query), likeQuery(query), likeQuery(query), likeQuery(query), 20-len(out)) likeQuery(query), likeQuery(query), likeQuery(query), likeQuery(query), 20-len(out))

View File

@ -19,7 +19,7 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-Dn9NERfF.js"></script> <script type="module" crossorigin src="/assets/main-FdTYg97q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css"> <link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css">
</head> </head>
<body> <body>

View File

@ -1,6 +1,7 @@
<script> <script>
import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte' import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte'
import { t } from './i18n' import { t } from './i18n'
import { expandKeyboardVariants } from './util/keyboardLayout'
export let wailsCall = async () => [] export let wailsCall = async () => []
export let typeLabel = (type) => type || '' export let typeLabel = (type) => type || ''
@ -42,9 +43,36 @@
} }
loading = true loading = true
try { try {
results = await wailsCall('Search', q) || [] // Build query variants: original + keyboard-swapped + lowercased
const variants = expandKeyboardVariants(q)
// Deduplicate preserving order
const seenVariants = new Set()
const queries = []
for (const v of variants) {
if (!seenVariants.has(v)) {
seenVariants.add(v)
queries.push(v)
}
}
// Execute all variant queries in parallel, collect results
const allResults = await Promise.all(
queries.map(v => wailsCall('Search', v).catch(() => []))
)
// Merge and deduplicate by node ID (or type+title for links/actions)
const merged = new Map()
// First pass: results from the original query get priority
for (let qi = 0; qi < allResults.length; qi++) {
const arr = allResults[qi] || []
for (const r of arr) {
const key = r.type + ':' + r.nodeId + ':' + (r.targetId || '') + ':' + r.title
if (!merged.has(key)) {
merged.set(key, r)
}
}
}
results = Array.from(merged.values())
selectedIndex = 0 selectedIndex = 0
open = true open = results.length > 0
} catch (e) { } catch (e) {
results = [] results = []
open = false open = false

View File

@ -88,11 +88,13 @@ func (s *Service) Create(nodeID, kind, title, command, workingDir, url string, a
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO actions (id,node_id,title,kind,command,args_json,working_dir,url, `INSERT INTO actions (id,node_id,title,kind,command,args_json,working_dir,url,
confirm_required,capture_output,created_at,updated_at) confirm_required,capture_output,created_at,updated_at,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, title_lower,kind_lower,url_lower,command_lower)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
rec.ID, rec.NodeID, rec.Title, rec.Kind, rec.Command, string(argsJSON), rec.ID, rec.NodeID, rec.Title, rec.Kind, rec.Command, string(argsJSON),
rec.WorkingDir, rec.URL, boolInt(rec.ConfirmRequired), boolInt(rec.CaptureOutput), rec.WorkingDir, rec.URL, boolInt(rec.ConfirmRequired), boolInt(rec.CaptureOutput),
rec.CreatedAt.Format(time.RFC3339), rec.UpdatedAt.Format(time.RFC3339), 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 { if err != nil {
return nil, err return nil, err

View File

@ -32,7 +32,7 @@ func (s *Service) Index(nodeID, title, content, path, tags, docType string) erro
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO search_index (node_id,title,content,path,tags,type) `INSERT INTO search_index (node_id,title,content,path,tags,type)
VALUES (?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?)`,
nodeID, title, content, path, tags, docType, nodeID, strings.ToLower(title), strings.ToLower(content), path, strings.ToLower(tags), docType,
) )
return err return err
} }
@ -92,7 +92,9 @@ func (s *Service) Search(query string) ([]Result, error) {
func sanitizeFTS(q string) string { func sanitizeFTS(q string) string {
// Wrap in double quotes for phrase search, escape inner quotes. // 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.TrimSpace(q)
q = strings.ToLower(q)
q = strings.ReplaceAll(q, `"`, `""`) q = strings.ReplaceAll(q, `"`, `""`)
return `"` + q + `"` return `"` + q + `"`
} }

View File

@ -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);
`

View File

@ -43,6 +43,16 @@ func Open(path string) (*DB, error) {
db.Close() db.Close()
return nil, err 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 return w, nil
} }
@ -80,6 +90,7 @@ var migrationFiles = map[int]string{
17: migration017, 17: migration017,
18: migration018, 18: migration018,
19: migration019, 19: migration019,
20: migration020,
} }
func (db *DB) runInitialSchema() error { func (db *DB) runInitialSchema() error {
@ -172,3 +183,79 @@ func (db *DB) BackfillTitleLower() error {
} }
return nil 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
}