fix: verstak:// links in preview, case-insensitive search, keyboard layout swap
1. Fix verstak:// links rendered as blocked/strikethrough in markdown preview: - Changed href from 'javascript:void(0)' to hash-based '#verstak-type-id' - DOMPurify no longer strips the link; click handler uses data-verstak-href - CSS already handles .md-link--internal with cyan color, no strikethrough 2. Add markdown label escaping for internal link picker: - New escapeMarkdownLabel() in markdown.ts escapes [ ] ( ) - Applied in InternalLinkPicker.selectResult() before inserting markdown 3. Fix case-insensitive search for RU/EN: - Add title_lower column (migration 019) populated by Go strings.ToLower - BackfillTitleLower() runs after migrations to populate existing rows - Search() now queries title_lower with Go-level lowercase (Unicode-aware) - insertNode() and UpdateTitle() populate title_lower automatically - New migration 019 + BackfillTitleLower in storage.go - Tests: TestSearchCaseInsensitive, TestSearchFindsCreatedNode 4. Add keyboard layout swap search support: - New keyboardLayout.ts utility with RU↔EN QWERTY mapping - expandKeyboardVariants() generates original + swapped + lowercased variants - InternalLinkPicker.search() queries all variants in parallel, deduplicates by ID - Examples: dthcnfr → верстак, руддщ → hello Files changed: - markdown.ts: hash href + escapeMarkdownLabel export - InternalLinkPicker.svelte: label escaping + layout swap search - keyboardLayout.ts: new RU/EN layout swap utility - repository.go: title_lower in Search/insertNode/UpdateTitle - storage.go: migration019 + BackfillTitleLower - migrations_019.sql.go: new migration - search_test.go, repository_test.go: new tests
This commit is contained in:
@@ -108,11 +108,11 @@ func (r *Repository) insertNode(n *Node) error {
|
||||
|
||||
_, err := r.db.Exec(
|
||||
`INSERT INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,
|
||||
created_at,updated_at,deleted_at,revision,device_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
created_at,updated_at,deleted_at,revision,device_id,title_lower)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
n.ID, parent, n.Type, n.Title, n.Slug, n.TemplateID, n.FsPath, n.Section,
|
||||
n.SortOrder, n.Archived, n.CreatedAt.Format(time.RFC3339), n.UpdatedAt.Format(time.RFC3339),
|
||||
n.DeletedAt, n.Revision, n.DeviceID,
|
||||
n.DeletedAt, n.Revision, n.DeviceID, strings.ToLower(n.Title),
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -232,9 +232,11 @@ func (r *Repository) CountChildren(parentID string, types ...string) (int, error
|
||||
|
||||
// Search finds active nodes whose title contains the query (case-insensitive).
|
||||
func (r *Repository) Search(query string, limit int) ([]Node, error) {
|
||||
// Use title_lower for case-insensitive search (SQLite LOWER() only handles ASCII).
|
||||
// title_lower is populated by triggers on insert/update.
|
||||
q := `SELECT ` + nodeColumns + ` FROM nodes
|
||||
WHERE deleted_at IS NULL AND LOWER(title) LIKE LOWER(?) ORDER BY sort_order, title LIMIT ?`
|
||||
rows, err := r.db.Query(q, "%"+query+"%", limit)
|
||||
WHERE deleted_at IS NULL AND title_lower LIKE ? ORDER BY sort_order, title LIMIT ?`
|
||||
rows, err := r.db.Query(q, "%"+strings.ToLower(query)+"%", limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -328,9 +330,9 @@ func (r *Repository) UpdateTitle(id, title string) error {
|
||||
slug := Slugify(title)
|
||||
t := now()
|
||||
res, err := r.db.Exec(
|
||||
`UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1
|
||||
`UPDATE nodes SET title=?, slug=?, title_lower=?, updated_at=?, revision=revision+1
|
||||
WHERE id=? AND deleted_at IS NULL`,
|
||||
title, slug, t, id)
|
||||
title, slug, strings.ToLower(title), t, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -284,3 +284,46 @@ func TestInitEndToEnd(t *testing.T) {
|
||||
|
||||
// Silence "os" import; keep unused-reference guard from breaking.
|
||||
var _ = os.Args
|
||||
|
||||
func TestSearchCaseInsensitive(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
// Create nodes with mixed-case titles
|
||||
_, err := repo.Create(nil, TypeCase, "Верстак Project", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = repo.Create(nil, TypeCase, "SCREEN Shot", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = repo.Create(nil, TypeCase, "hello World", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
query string
|
||||
wantMin int
|
||||
}{
|
||||
{"верстак", 1}, // RU lowercase finds RU mixed-case
|
||||
{"ВЕРСТАК", 1}, // RU uppercase finds RU mixed-case
|
||||
{"screen", 1}, // EN lowercase finds EN mixed-case
|
||||
{"SCREEN", 1}, // EN uppercase finds EN mixed-case
|
||||
{"hello", 1}, // EN lowercase
|
||||
{"HELLO", 1}, // EN uppercase
|
||||
{"nonexistent", 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
results, err := repo.Search(tt.query, 20)
|
||||
if err != nil {
|
||||
t.Errorf("Search(%q): %v", tt.query, err)
|
||||
continue
|
||||
}
|
||||
if len(results) < tt.wantMin {
|
||||
t.Errorf("Search(%q) = %d results, want at least %d", tt.query, len(results), tt.wantMin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package nodes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchFindsCreatedNode(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
_, err := repo.Create(nil, TypeCase, "TestNode_ABC", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results, err := repo.Search("testnode", 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Errorf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package storage
|
||||
|
||||
// migration019 — add title_lower column for case-insensitive search.
|
||||
const migration019 = `
|
||||
ALTER TABLE nodes ADD COLUMN title_lower TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_title_lower ON nodes(title_lower);
|
||||
`
|
||||
@@ -39,6 +39,10 @@ func Open(path string) (*DB, error) {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := w.BackfillTitleLower(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
@@ -75,6 +79,7 @@ var migrationFiles = map[int]string{
|
||||
16: migration016,
|
||||
17: migration017,
|
||||
18: migration018,
|
||||
19: migration019,
|
||||
}
|
||||
|
||||
func (db *DB) runInitialSchema() error {
|
||||
@@ -134,3 +139,36 @@ func (db *DB) applyMigration(version int, raw string) error {
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// BackfillTitleLower populates title_lower for rows where it is still empty.
|
||||
// Uses Go strings.ToLower for Unicode-aware case folding.
|
||||
func (db *DB) BackfillTitleLower() error {
|
||||
rows, err := db.Query("SELECT id, title FROM nodes WHERE title_lower = '' AND deleted_at IS NULL")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type pair struct {
|
||||
id string
|
||||
lower string
|
||||
}
|
||||
var pairs []pair
|
||||
for rows.Next() {
|
||||
var id, title string
|
||||
if err := rows.Scan(&id, &title); err != nil {
|
||||
return err
|
||||
}
|
||||
pairs = append(pairs, pair{id: id, lower: strings.ToLower(title)})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range pairs {
|
||||
if _, err := db.Exec("UPDATE nodes SET title_lower = ? WHERE id = ?", p.lower, p.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user