From dc83f6e21b387cdc2c910ba33565abf97e10f030 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Sep 2026 07:10:47 +0200 Subject: [PATCH] fix: preserve export navigation and clinical source boundaries --- public/js/assistant/citations.js | 13 ++++-- public/js/assistant/export.js | 12 +++++ test/assistant-saved-tables.test.js | 69 +++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/public/js/assistant/citations.js b/public/js/assistant/citations.js index 7b285c1..20a8556 100644 --- a/public/js/assistant/citations.js +++ b/public/js/assistant/citations.js @@ -1,3 +1,6 @@ +// Recognize HTML syntax (including quoted >), not clinical comparisons such as <1 month. +var HTML_TAG_PATTERN = /|<\/[A-Za-z][A-Za-z0-9-]*\s*>|<[A-Za-z][A-Za-z0-9-]*(?:\s+[A-Za-z_:][A-Za-z0-9_.:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`\x00-\x20]+))?)*\s*\/?>/; + export function renderAssistantMarkdown(md, sources, options) { var opts = options || {}; var text = String(md || ''); @@ -16,7 +19,9 @@ export function renderAssistantMarkdown(md, sources, options) { var inlineCode = textProtection(text, 'code'); text = text.replace(/(`+)[\s\S]*?\1/g, inlineCode.hold); var links = textProtection(text, 'links'); - text = text.replace(/!?\[[^\]\n]*\]\([^\n]*?\)|https?:\/\/[^\s<>]+|<[^>]*>/g, links.hold); + text = text.replace(new RegExp( + /!?\[[^\]\n]*\]\([^\n]*?\)|https?:\/\/(?:\\[^\s]|[^\s<>|\\])+|<(?:https?:\/\/|mailto:)[^\s<>]+>|<[^\s<>@]+@[^\s<>@]+>/.source + '|' + HTML_TAG_PATTERN.source, + 'gi'), links.hold); text = text.replace(/\\\[((?:\d+\s*,\s*)*\d+)\\\]/g, '[$1]'); text = renderLatexText(text, opts.katex, embedded.hold); var limited = false; @@ -56,7 +61,9 @@ function wrapTables(html) { export function renderCitationLinks(html, sources, options) { var opts = options || {}; - return String(html || '').replace(/<(pre|code|a)\b[^>]*>[\s\S]*?<\/\1>|<[^>]*>|\[((?:\d+\s*,\s*)*\d+)\]/gi, function (match, tag, cluster) { + return String(html || '').replace(new RegExp( + /<(pre|code|a)\b[^>]*>[\s\S]*?<\/\1>/.source + '|' + HTML_TAG_PATTERN.source + '|' + /\[((?:\d+\s*,\s*)*\d+)\]/.source, + 'gi'), function (match, tag, cluster) { if (!cluster) return match; var nums = cluster.split(',').map(function (n) { return Number(n.trim()); }).filter(function (n) { return Number.isInteger(n) && n > 0; }); if (!nums.length || nums.some(function (n) { return !sources[n - 1]; })) return match; @@ -176,7 +183,7 @@ export function normalizeMarkdownText(text, options) { var protectedText = textProtection(text); text = protectBlocks(text, opts, protectedText.hold); // These spans must not become headings/lists or apparent legacy row boundaries. - text = text.replace(/(`+)[\s\S]*?\1|\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)|\$[^$\n]+\$|!?\[[^\]\n]*\]\([^\n]*?\)|https?:\/\/[^\s<>]+|\\\|/g, protectedText.hold); + text = text.replace(/(`+)[\s\S]*?\1|\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)|\$[^$\n]+\$|!?\[[^\]\n]*\]\([^\n]*?\)|https?:\/\/(?:\\[^\s]|[^\s<>|\\])+|\\\|/gi, protectedText.hold); text = text.split('\n').map(function(line) { if (!/\|[ \t]*:?-{2,}:?[ \t]*\|/.test(line)) return line; var recovered = recoverLegacyTableLine(line); diff --git a/public/js/assistant/export.js b/public/js/assistant/export.js index b087c9d..83ec24b 100644 --- a/public/js/assistant/export.js +++ b/public/js/assistant/export.js @@ -88,6 +88,18 @@ export function createAssistantExporter(options) { }); if (closeBtn) closeBtn.addEventListener('click', closeInlineExport); modal.addEventListener('click', function (event) { + if (!event.defaultPrevented && !event.button && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey) { + var citation = event.target.closest('.assistant-cite'); + var href = citation && citation.getAttribute('href'); + var reference = /^#ref-\d+-\d+$/.test(href || '') && + modal.querySelector(':scope > .assistant-export-sheet > .export-section > .refs > ' + href); + if (reference) { + event.preventDefault(); // Fragment navigation also fires the Back/popstate close handler. + reference.tabIndex = -1; + reference.focus({ preventScroll: true }); + reference.scrollIntoView({ block: 'nearest' }); + } + } if (event.target === modal) closeInlineExport(); }); try { window.history.pushState({ assistantExport: true }, '', window.location.href); } catch (e) {} diff --git a/test/assistant-saved-tables.test.js b/test/assistant-saved-tables.test.js index bf9ed8d..566d9f2 100644 --- a/test/assistant-saved-tables.test.js +++ b/test/assistant-saved-tables.test.js @@ -207,6 +207,9 @@ test('math/code literals and sentinel-shaped input survive postprocessing withou reopen(app, '[URL](https://example.test/$notmath$)'); assert.equal(bubble(app).querySelector('a').getAttribute('href'), 'https://example.test/$notmath$'); assert.ok(!expressions.includes('notmath')); + reopen(app, 'Alpha'); + assert.equal(bubble(app).querySelector('span[title]').title, 'threshold > 1 [2][1] $notmath$'); + assert.ok(!expressions.includes('notmath'), 'quoted HTML attributes are literal, including >, citations and math'); }); test('legacy loss provenance and retained raw extension survive v2 re-save and follow-up with strict guards', async t => { @@ -285,3 +288,69 @@ test('clicking reused citation numbers selects the original turn source without assert.deepEqual(app.saves[0].sources, second, 'viewing an older source does not replace the latest retrieval map'); assert.equal(app.saves[0].messages[1].content, 'First answer. [1]'); }); + +test('inline export reference activation preserves the preview while browser Back still closes it', t => { + const app = ui(t); + reopen(app, table); + app.context.exportAnswerPdf(); + const modal = app.document.querySelector('#assistant-export-modal'); + const link = modal.querySelector('.assistant-cite[href="#ref-1-2"]'); + const reference = modal.querySelector('#ref-1-2'); + let scrolled = 0; + reference.scrollIntoView = () => { scrolled++; }; + const decoy = app.document.createElement('span'); + decoy.id = reference.id; + decoy.scrollIntoView = () => { throw new Error('Answer markup must not replace the reference target'); }; + modal.querySelector('.answer').prepend(decoy); + const originalHash = app.window.location.hash; + const click = new app.window.MouseEvent('click', { bubbles: true, cancelable: true }); + link.dispatchEvent(click); + assert.equal(click.defaultPrevented, true, 'fragment navigation must not trigger the popstate close handler'); + assert.equal(scrolled, 1); + assert.equal(app.document.activeElement, reference); + assert.equal(app.window.location.hash, originalHash); + assert.equal(modal.isConnected, true); + + // Observe the application's decision, then stop jsdom's native navigation. + let preventedByApp; + modal.addEventListener('click', event => { + preventedByApp = event.defaultPrevented; + event.preventDefault(); + }); + for (const modifiers of [{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) { + link.dispatchEvent(new app.window.MouseEvent('click', { bubbles: true, cancelable: true, ...modifiers })); + assert.equal(preventedByApp, false, 'modified clicks retain native semantics'); + assert.equal(scrolled, 1); + } + app.window.dispatchEvent(new app.window.PopStateEvent('popstate')); + assert.equal(app.document.querySelector('#assistant-export-modal'), null); +}); + +test('comparison angles and compact URLs preserve every source-column link, export reference and raw saved turn', async t => { + const fixtures = [ + '| Age | Dose | Sources |\n| --- | --- | --- |\n| <1 month | 2 | 1 |\n| >1 month | 3 | 2 |', + '| Item | Sources |\n|---|---|\n|https://example.test/reference|1|\n|Beta|2|', + '| Item | Sources |\n|---|---|\n|https://example.test/a\\|b|1|\n|Beta|2|', + '| Item | Sources |\n|---|---|\n||1|\n|Beta|2|' + ]; + for (const parser of [marked, null]) { + const app = ui(t, parser); + if (!parser) app.window.markdownit = MarkdownIt; + for (const content of fixtures) { + reopen(app, content); + assert.equal(rows(bubble(app)).length, 2); + assert.deepEqual([...bubble(app).querySelectorAll('.assistant-cite')].map(a => a.dataset.sourceNumber), ['1', '2']); + await app.context.saveCurrentChat(); + assert.equal(app.saves.at(-1).messages[1].content, content); + app.context.exportAnswerPdf(); + const modal = app.document.querySelector('#assistant-export-modal'); + assert.deepEqual([...modal.querySelectorAll('.assistant-cite')].map(a => a.dataset.sourceNumber), ['1', '2']); + for (const [number, page] of [[1, 7], [2, 19]]) { + const link = modal.querySelector('.assistant-cite[data-source-number="' + number + '"]'); + assert.match(modal.querySelector(link.getAttribute('href')).textContent, new RegExp('page ' + page)); + } + if (content.includes('<1 month')) assert.deepEqual(rows(modal).map(row => row[0]), ['<1 month', '>1 month']); + if (content.includes('a\\|b')) assert.equal(decodeURIComponent(modal.querySelector('a[href^="https:"]').getAttribute('href')), 'https://example.test/a|b'); + } + } +});