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:
parent
7521eea109
commit
88eb99e9af
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-C5mYAmha.js"></script>
|
<script type="module" crossorigin src="/assets/main-Dn9NERfF.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,5 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { escapeMarkdownLabel } from '../../markdown/markdown';
|
||||||
|
import { expandKeyboardVariants } from '../../util/keyboardLayout';
|
||||||
|
|
||||||
export let visible = false;
|
export let visible = false;
|
||||||
|
|
||||||
|
|
@ -23,14 +25,9 @@
|
||||||
|
|
||||||
// Map node types from backend to our filter types
|
// Map node types from backend to our filter types
|
||||||
function nodeTypeToFilter(type) {
|
function nodeTypeToFilter(type) {
|
||||||
// case, project, client, document, recipe, space → case
|
|
||||||
// note → note
|
|
||||||
// file → file
|
|
||||||
// secret → secret
|
|
||||||
if (type === 'note') return 'note';
|
if (type === 'note') return 'note';
|
||||||
if (type === 'file') return 'file';
|
if (type === 'file') return 'file';
|
||||||
if (type === 'secret') return 'secret';
|
if (type === 'secret') return 'secret';
|
||||||
// Everything else goes to "case"
|
|
||||||
return 'case';
|
return 'case';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,9 +39,32 @@
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const res = await window['go']['main']['App']['SearchNodes'](query.trim()) || [];
|
// Expand query with keyboard layout variants for tolerant search
|
||||||
|
const variants = expandKeyboardVariants(query.trim());
|
||||||
|
// Deduplicate: skip variants identical to the original query's lowercase
|
||||||
|
const seen = new Set();
|
||||||
|
const queries = [];
|
||||||
|
for (const v of variants) {
|
||||||
|
if (!seen.has(v)) {
|
||||||
|
seen.add(v);
|
||||||
|
queries.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Execute all variant queries in parallel
|
||||||
|
const allResults = await Promise.all(
|
||||||
|
queries.map(q => window['go']['main']['App']['SearchNodes'](q).catch(() => []))
|
||||||
|
);
|
||||||
|
// Merge and deduplicate by node ID
|
||||||
|
const merged = new Map();
|
||||||
|
for (const arr of allResults) {
|
||||||
|
for (const n of (arr || [])) {
|
||||||
|
if (!merged.has(n.id)) {
|
||||||
|
merged.set(n.id, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Filter by active type
|
// Filter by active type
|
||||||
results = res.filter(n => nodeTypeToFilter(n.type) === activeType);
|
results = Array.from(merged.values()).filter(n => nodeTypeToFilter(n.type) === activeType);
|
||||||
selectedIndex = 0;
|
selectedIndex = 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = String(e);
|
||||||
|
|
@ -77,7 +97,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectResult(item) {
|
function selectResult(item) {
|
||||||
const md = `[${item.title}](verstak://${nodeTypeToFilter(item.type)}/${item.id})`;
|
const label = escapeMarkdownLabel(item.title || item.id);
|
||||||
|
const md = `[${label}](verstak://${nodeTypeToFilter(item.type)}/${item.id})`;
|
||||||
dispatch('insert', { markdown: md });
|
dispatch('insert', { markdown: md });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,10 @@ renderer.link = function ({ href, title, text }) {
|
||||||
if (parsed && ALLOWED_VERSTAK_TYPES.has(parsed.type)) {
|
if (parsed && ALLOWED_VERSTAK_TYPES.has(parsed.type)) {
|
||||||
const escapedHref = escapeAttr(trimmedHref);
|
const escapedHref = escapeAttr(trimmedHref);
|
||||||
const escapedText = escapeHtml(text);
|
const escapedText = escapeHtml(text);
|
||||||
return `<a href="javascript:void(0)" class="md-link md-link--internal" data-verstak-href="${escapedHref}" data-verstak-type="${escapeAttr(parsed.type)}" data-verstak-id="${escapeAttr(parsed.id)}">${escapedText}</a>`;
|
// Use a hash-based href so DOMPurify doesn't strip it;
|
||||||
|
// the actual navigation is handled by data-verstak-href + click handler.
|
||||||
|
const hashId = 'verstak-' + encodeURIComponent(parsed.type) + '-' + encodeURIComponent(parsed.id);
|
||||||
|
return `<a href="#${hashId}" class="md-link md-link--internal" data-verstak-href="${escapedHref}" data-verstak-type="${escapeAttr(parsed.type)}" data-verstak-id="${escapeAttr(parsed.id)}">${escapedText}</a>`;
|
||||||
}
|
}
|
||||||
// Unknown verstak type — render as blocked
|
// Unknown verstak type — render as blocked
|
||||||
const escapedText = escapeHtml(text);
|
const escapedText = escapeHtml(text);
|
||||||
|
|
@ -155,6 +158,14 @@ export function parseVerstakUrl(href) {
|
||||||
return { type: match[1], id: match[2] };
|
return { type: match[1], id: match[2] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape markdown special characters in link label text.
|
||||||
|
* Escapes [ ] ( ) so they don't break markdown inline link syntax.
|
||||||
|
*/
|
||||||
|
export function escapeMarkdownLabel(s) {
|
||||||
|
return s.replace(/([[\]()])/g, '\\$1');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a verstak:// type is supported.
|
* Check if a verstak:// type is supported.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* Keyboard layout swap helper for RU/EN QWERTY keyboards.
|
||||||
|
*
|
||||||
|
* When a user types with the wrong layout enabled, the characters
|
||||||
|
* are silently mapped to a different letter. This module converts
|
||||||
|
* such "garbled" text back to what the user intended.
|
||||||
|
*
|
||||||
|
* RU layout mapping (what you get when you type RU keys with EN layout active):
|
||||||
|
* й→q, ц→w, у→e, к→r, е→t, н→y, г→u, ш→i, щ→o, з→p, х→[, ъ→]
|
||||||
|
* ф→a, ы→s, в→d, а→f, п→g, р→h, о→j, л→k, д→l, ж→;, э→'
|
||||||
|
* я→z, ч→c, с→v, м→b, и→n, т→m, ж→,, ю→.
|
||||||
|
* Uppercase variants behave identically (just swapped case).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Maps: wrong-layout char → intended char (both directions)
|
||||||
|
const RU_TO_EN: Record<string, string> = {
|
||||||
|
й: 'q', ц: 'w', у: 'e', к: 'r', е: 't', н: 'y', г: 'u', ш: 'i', щ: 'o', з: 'p',
|
||||||
|
х: '[', ъ: ']', ф: 'a', ы: 's', в: 'd', а: 'f', п: 'g', р: 'h', о: 'j', л: 'k',
|
||||||
|
д: 'l', ж: ';', э: "'", я: 'z', ч: 'c', с: 'v', м: 'b', и: 'n', т: 'm', ь: ',',
|
||||||
|
ю: '.', ё: '`',
|
||||||
|
Й: 'Q', Ц: 'W', У: 'E', К: 'R', Е: 'T', Н: 'Y', Г: 'U', Ш: 'I', Щ: 'O', З: 'P',
|
||||||
|
Х: '{', Ъ: '}', Ф: 'A', Ы: 'S', В: 'D', А: 'F', П: 'G', Р: 'H', О: 'J', Л: 'K',
|
||||||
|
Д: 'L', Ж: ':', Э: '"', Я: 'Z', Ч: 'C', С: 'V', М: 'B', И: 'N', Т: 'M', Ь: '<',
|
||||||
|
Ю: '>', Ё: '~',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EN_TO_RU: Record<string, string> = {};
|
||||||
|
for (const [ru, en] of Object.entries(RU_TO_EN)) {
|
||||||
|
EN_TO_RU[en] = ru;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Swap each character using the provided map, leaving unknown chars unchanged. */
|
||||||
|
function swap(s: string, map: Record<string, string>): string {
|
||||||
|
let out = '';
|
||||||
|
for (const ch of s) {
|
||||||
|
out += map[ch] ?? ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert text typed on a Russian keyboard with English layout active. */
|
||||||
|
export function ruToEn(s: string): string {
|
||||||
|
return swap(s, RU_TO_EN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert text typed on an English keyboard with Russian layout active. */
|
||||||
|
export function enToRu(s: string): string {
|
||||||
|
return swap(s, EN_TO_RU);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce all search-query variants for keyboard-layout-tolerant search.
|
||||||
|
*
|
||||||
|
* Returns a deduplicated array that always contains the original query,
|
||||||
|
* and up to two layout-swapped variants (EN↔RU).
|
||||||
|
*/
|
||||||
|
export function expandKeyboardVariants(query: string): string[] {
|
||||||
|
const variants = new Set<string>();
|
||||||
|
variants.add(query);
|
||||||
|
|
||||||
|
const en = ruToEn(query);
|
||||||
|
if (en !== query) variants.add(en);
|
||||||
|
|
||||||
|
const ru = enToRu(query);
|
||||||
|
if (ru !== query) variants.add(ru);
|
||||||
|
|
||||||
|
// Also add lowercased variants of each
|
||||||
|
const lowerVariants = new Set<string>();
|
||||||
|
for (const v of variants) {
|
||||||
|
lowerVariants.add(v.toLowerCase());
|
||||||
|
}
|
||||||
|
for (const v of lowerVariants) {
|
||||||
|
variants.add(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(variants);
|
||||||
|
}
|
||||||
|
|
@ -108,11 +108,11 @@ func (r *Repository) insertNode(n *Node) error {
|
||||||
|
|
||||||
_, err := r.db.Exec(
|
_, err := r.db.Exec(
|
||||||
`INSERT INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,
|
`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)
|
created_at,updated_at,deleted_at,revision,device_id,title_lower)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
n.ID, parent, n.Type, n.Title, n.Slug, n.TemplateID, n.FsPath, n.Section,
|
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.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
|
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).
|
// Search finds active nodes whose title contains the query (case-insensitive).
|
||||||
func (r *Repository) Search(query string, limit int) ([]Node, error) {
|
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
|
q := `SELECT ` + nodeColumns + ` FROM nodes
|
||||||
WHERE deleted_at IS NULL AND LOWER(title) LIKE LOWER(?) ORDER BY sort_order, title LIMIT ?`
|
WHERE deleted_at IS NULL AND title_lower LIKE ? ORDER BY sort_order, title LIMIT ?`
|
||||||
rows, err := r.db.Query(q, "%"+query+"%", limit)
|
rows, err := r.db.Query(q, "%"+strings.ToLower(query)+"%", limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -328,9 +330,9 @@ func (r *Repository) UpdateTitle(id, title string) error {
|
||||||
slug := Slugify(title)
|
slug := Slugify(title)
|
||||||
t := now()
|
t := now()
|
||||||
res, err := r.db.Exec(
|
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`,
|
WHERE id=? AND deleted_at IS NULL`,
|
||||||
title, slug, t, id)
|
title, slug, strings.ToLower(title), t, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -284,3 +284,46 @@ func TestInitEndToEnd(t *testing.T) {
|
||||||
|
|
||||||
// Silence "os" import; keep unused-reference guard from breaking.
|
// Silence "os" import; keep unused-reference guard from breaking.
|
||||||
var _ = os.Args
|
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()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := w.BackfillTitleLower(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return w, nil
|
return w, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,6 +79,7 @@ var migrationFiles = map[int]string{
|
||||||
16: migration016,
|
16: migration016,
|
||||||
17: migration017,
|
17: migration017,
|
||||||
18: migration018,
|
18: migration018,
|
||||||
|
19: migration019,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) runInitialSchema() error {
|
func (db *DB) runInitialSchema() error {
|
||||||
|
|
@ -134,3 +139,36 @@ func (db *DB) applyMigration(version int, raw string) error {
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue