feat: internal verstak:// link navigation (case/note/file)

1. handleVerstakLink now performs real navigation:
   - case/<id>: GetNodeDetail → selectNode (selects node in tree)
   - note/<id>: GetNodeDetail → find parent → selectNode(parent) → setActiveTab('notes') → openNote
   - file/<id>: GetNodeDetail → find parent → selectNode(parent) → setActiveTab('files') → loadFolder → openPreview
   - secret/<id>: toast 'Seaf access not implemented'
   - missing ids: toast 'Not found'

2. Changed href from about:blank to # (safe, DOMPurify won't strip)
   e.preventDefault() in click handler prevents scroll-to-top

3. Added i18n keys: caseNotFound, noteNotFound, fileNotFound, fileFound

4. All existing tests pass, build OK
This commit is contained in:
mirivlad 2026-06-15 12:27:59 +08:00
parent c8c5531c0c
commit db961ff0c3
7 changed files with 178 additions and 105 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -1413,26 +1413,97 @@
// Replaced by simple openInternalLinkModal above.
// ===== Verstak link handler =====
function handleVerstakLink(e) {
async function handleVerstakLink(e) {
const { type, id } = e.detail
let msg = ''
if (!id) return
switch (type) {
case 'secret':
msg = t('note.internal.secretNotImplemented')
break
case 'case':
msg = t('note.internal.caseNotImplemented')
await navigateToCase(id)
break
case 'note':
msg = t('note.internal.noteNotImplemented')
await navigateToNote(id)
break
case 'file':
msg = t('note.internal.fileNotImplemented')
await navigateToFile(id)
break
case 'secret':
showVerstakToastMessage(t('note.internal.secretNotImplemented'))
break
default:
msg = `verstak://${type}/${id}`
showVerstakToastMessage(`verstak://${type}/${id}`)
}
}
async function navigateToCase(id) {
try {
const node = await wailsCall('GetNodeDetail', id)
if (!node) {
showVerstakToastMessage(t('note.internal.caseNotFound'))
return
}
await selectNode(node)
} catch (e) {
showVerstakToastMessage(t('note.internal.caseNotFound'))
}
}
async function navigateToNote(id) {
try {
const node = await wailsCall('GetNodeDetail', id)
if (!node) {
showVerstakToastMessage(t('note.internal.noteNotFound'))
return
}
const parentId = node.parent_id || node.parentId || ''
if (parentId) {
const parent = await wailsCall('GetNodeDetail', parentId)
if (parent) {
await selectNode(parent)
}
}
setActiveTab('notes')
// Find the note in the loaded notes list and open it
const note = notes.find(n => n.id === id)
if (note) {
await openNote(note)
} else {
// Note might not be loaded yet, try to open by id directly
await openNote({ id: node.id, title: node.title })
}
} catch (e) {
showVerstakToastMessage(t('note.internal.noteNotFound'))
}
}
async function navigateToFile(id) {
try {
const node = await wailsCall('GetNodeDetail', id)
if (!node) {
showVerstakToastMessage(t('note.internal.fileNotFound'))
return
}
const parentId = node.parent_id || node.parentId || ''
if (parentId) {
const parent = await wailsCall('GetNodeDetail', parentId)
if (parent) {
await selectNode(parent)
}
setActiveTab('files')
await loadFolder(parentId)
// Find the file in the loaded fileItems and open preview
const fileItem = fileItems.find(f => f.id === id)
if (fileItem) {
await openPreview(fileItem)
} else {
showVerstakToastMessage(t('note.internal.fileFound', { title: node.title }))
}
} else {
showVerstakToastMessage(t('note.internal.fileFound', { title: node.title }))
}
} catch (e) {
showVerstakToastMessage(t('note.internal.fileNotFound'))
}
showVerstakToastMessage(msg)
}
function showVerstakToastMessage(msg) {

View File

@ -207,9 +207,10 @@ export default {
'note.toolbar.internalLink': 'Внутренняя ссылка Verstak',
'note.internal.secretNotImplemented': 'Сейф доступов ещё не реализован',
'note.internal.caseNotImplemented': 'Переход к делу ещё не реализован',
'note.internal.noteNotImplemented': 'Переход к заметке ещё не реализован',
'note.internal.fileNotImplemented': 'Переход к файлу ещё не реализован',
'note.internal.caseNotFound': 'Дело не найдено',
'note.internal.noteNotFound': 'Заметка не найдена',
'note.internal.fileNotFound': 'Файл не найден',
'note.internal.fileFound': 'Файл найден: {title}',
'note.rename': 'Переименовать заметку',
'note.deleteConfirm': 'Удалить заметку «{title}»?',

View File

@ -73,9 +73,10 @@ renderer.link = function ({ href, title, text }) {
if (parsed && ALLOWED_VERSTAK_TYPES.has(parsed.type)) {
const escapedHref = escapeAttr(trimmedHref);
const escapedText = escapeHtml(text);
// Use about:blank as a safe href that DOMPurify won't strip.
// Use href="#" as a safe placeholder — DOMPurify won't strip it.
// The actual navigation is handled by data-verstak-href + click handler.
return `<a href="about:blank" class="md-link md-link--internal" data-verstak-href="${escapedHref}" data-verstak-type="${escapeAttr(parsed.type)}" data-verstak-id="${escapeAttr(parsed.id)}">${escapedText}</a>`;
// e.preventDefault() in the click handler prevents scrolling to top.
return `<a href="#" 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);

View File

@ -40,7 +40,7 @@ function renderLink(href, text) {
if (parsed && ALLOWED_VERSTAK_TYPES.has(parsed.type)) {
const escapedHref = escapeAttr(trimmedHref);
const escapedText = escapeHtml(text);
return `<a href="about:blank" class="md-link md-link--internal" data-verstak-href="${escapedHref}" data-verstak-type="${escapeAttr(parsed.type)}" data-verstak-id="${escapeAttr(parsed.id)}">${escapedText}</a>`;
return `<a href="#" class="md-link md-link--internal" data-verstak-href="${escapedHref}" data-verstak-type="${escapeAttr(parsed.type)}" data-verstak-id="${escapeAttr(parsed.id)}">${escapedText}</a>`;
}
return `<span class="md-link--blocked" data-blocked-href="${escapeAttr(trimmedHref)}">${escapeHtml(text)}</span>`;
}
@ -69,7 +69,7 @@ const fileLink = renderLink('verstak://file/019e971db8967a53991d3ee22a64ccc7', '
assert('file link is <a>', fileLink.startsWith('<a '), true);
assert('file link has data-verstak-href', fileLink.includes('data-verstak-href="verstak://file/019e971db8967a53991d3ee22a64ccc7"'), true);
assert('file link has md-link--internal', fileLink.includes('md-link--internal'), true);
assert('file link has about:blank href', fileLink.includes('href="about:blank"'), true);
assert('file link has # href', fileLink.includes('href="#"'), true);
assert('file link is NOT blocked', fileLink.includes('md-link--blocked'), false);
assert('file link text', fileLink.includes('>screen_4.png</a>'), true);
assert('file link has data-verstak-type', fileLink.includes('data-verstak-type="file"'), true);