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:
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
+1 -1
View File
@@ -19,7 +19,7 @@
background: #13131f; background: #13131f;
} }
</style> </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"> <link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css">
</head> </head>
<body> <body>
+81 -10
View File
@@ -1413,26 +1413,97 @@
// Replaced by simple openInternalLinkModal above. // Replaced by simple openInternalLinkModal above.
// ===== Verstak link handler ===== // ===== Verstak link handler =====
function handleVerstakLink(e) { async function handleVerstakLink(e) {
const { type, id } = e.detail const { type, id } = e.detail
let msg = '' if (!id) return
switch (type) { switch (type) {
case 'secret':
msg = t('note.internal.secretNotImplemented')
break
case 'case': case 'case':
msg = t('note.internal.caseNotImplemented') await navigateToCase(id)
break break
case 'note': case 'note':
msg = t('note.internal.noteNotImplemented') await navigateToNote(id)
break break
case 'file': case 'file':
msg = t('note.internal.fileNotImplemented') await navigateToFile(id)
break
case 'secret':
showVerstakToastMessage(t('note.internal.secretNotImplemented'))
break break
default: 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) { function showVerstakToastMessage(msg) {
+4 -3
View File
@@ -207,9 +207,10 @@ export default {
'note.toolbar.internalLink': 'Внутренняя ссылка Verstak', 'note.toolbar.internalLink': 'Внутренняя ссылка Verstak',
'note.internal.secretNotImplemented': 'Сейф доступов ещё не реализован', 'note.internal.secretNotImplemented': 'Сейф доступов ещё не реализован',
'note.internal.caseNotImplemented': 'Переход к делу ещё не реализован', 'note.internal.caseNotFound': 'Дело не найдено',
'note.internal.noteNotImplemented': 'Переход к заметке ещё не реализован', 'note.internal.noteNotFound': 'Заметка не найдена',
'note.internal.fileNotImplemented': 'Переход к файлу ещё не реализован', 'note.internal.fileNotFound': 'Файл не найден',
'note.internal.fileFound': 'Файл найден: {title}',
'note.rename': 'Переименовать заметку', 'note.rename': 'Переименовать заметку',
'note.deleteConfirm': 'Удалить заметку «{title}»?', 'note.deleteConfirm': 'Удалить заметку «{title}»?',
+3 -2
View File
@@ -73,9 +73,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);
// 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. // 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 // Unknown verstak type — render as blocked
const escapedText = escapeHtml(text); const escapedText = escapeHtml(text);
+2 -2
View File
@@ -40,7 +40,7 @@ function renderLink(href, 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="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>`; 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 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 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 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 is NOT blocked', fileLink.includes('md-link--blocked'), false);
assert('file link text', fileLink.includes('>screen_4.png</a>'), true); 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); assert('file link has data-verstak-type', fileLink.includes('data-verstak-type="file"'), true);