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:
parent
88eb99e9af
commit
700e4dae5b
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 + `"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
`
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue