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:
2026-06-15 10:39:44 +08:00
parent 7521eea109
commit 88eb99e9af
10 changed files with 264 additions and 41 deletions
@@ -1,5 +1,7 @@
<script>
import { createEventDispatcher } from 'svelte';
import { escapeMarkdownLabel } from '../../markdown/markdown';
import { expandKeyboardVariants } from '../../util/keyboardLayout';
export let visible = false;
@@ -23,14 +25,9 @@
// Map node types from backend to our filter types
function nodeTypeToFilter(type) {
// case, project, client, document, recipe, space → case
// note → note
// file → file
// secret → secret
if (type === 'note') return 'note';
if (type === 'file') return 'file';
if (type === 'secret') return 'secret';
// Everything else goes to "case"
return 'case';
}
@@ -42,9 +39,32 @@
loading = true;
error = '';
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
results = res.filter(n => nodeTypeToFilter(n.type) === activeType);
results = Array.from(merged.values()).filter(n => nodeTypeToFilter(n.type) === activeType);
selectedIndex = 0;
} catch (e) {
error = String(e);
@@ -77,7 +97,8 @@
}
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 });
}
+12 -1
View File
@@ -68,7 +68,10 @@ renderer.link = function ({ href, title, text }) {
if (parsed && ALLOWED_VERSTAK_TYPES.has(parsed.type)) {
const escapedHref = escapeAttr(trimmedHref);
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
const escapedText = escapeHtml(text);
@@ -155,6 +158,14 @@ export function parseVerstakUrl(href) {
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.
*/
+77
View File
@@ -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);
}