From c27245e980b1bb1ad05e10e3756e1d9f2442c556 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 6 May 2026 01:39:26 +0800 Subject: [PATCH] feat(reader): support deeplink and web link in annotation export (#4067) Expose `annotation.appLink` (readest://) and `annotation.webLink` (https://web.readest.com) as template variables for custom export templates. The shipped default template now emits the readest:// app deeplink for the page link so exported notes open the native app. The non-template export mode keeps the universal https link. Preview links also gain target="_blank" so they open in a new tab instead of replacing the dialog. Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/__tests__/utils/note.test.ts | 39 +++++++++++++++++++ .../annotator/ExportMarkdownDialog.tsx | 17 ++++++-- apps/readest-app/src/utils/note.ts | 2 + 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/apps/readest-app/src/__tests__/utils/note.test.ts b/apps/readest-app/src/__tests__/utils/note.test.ts index 7fd65aa0..a0b09f99 100644 --- a/apps/readest-app/src/__tests__/utils/note.test.ts +++ b/apps/readest-app/src/__tests__/utils/note.test.ts @@ -518,6 +518,45 @@ describe('renderNoteTemplate', () => { }); }); + describe('Annotation link variants', () => { + const linkData: NoteTemplateData = { + title: 'Book', + author: 'Author', + exportDate: '2024-01-15', + chapters: [ + { + title: 'Ch1', + annotations: [ + { + text: 'quote', + webLink: 'https://web.readest.com/o/book/abc/annotation/n1', + appLink: 'readest://book/abc/annotation/n1', + link: 'https://web.readest.com/o/book/abc/annotation/n1', + }, + ], + }, + ], + }; + + it('should render annotation.webLink', () => { + const template = '{{ chapters[0].annotations[0].webLink }}'; + const result = renderNoteTemplate(template, linkData); + expect(result).toBe('https://web.readest.com/o/book/abc/annotation/n1'); + }); + + it('should render annotation.appLink with readest:// scheme', () => { + const template = '{{ chapters[0].annotations[0].appLink }}'; + const result = renderNoteTemplate(template, linkData); + expect(result).toBe('readest://book/abc/annotation/n1'); + }); + + it('should still render legacy annotation.link', () => { + const template = '{{ chapters[0].annotations[0].link }}'; + const result = renderNoteTemplate(template, linkData); + expect(result).toBe('https://web.readest.com/o/book/abc/annotation/n1'); + }); + }); + describe('Whitespace handling', () => { it('should trim blocks correctly', () => { const template = `Start diff --git a/apps/readest-app/src/app/reader/components/annotator/ExportMarkdownDialog.tsx b/apps/readest-app/src/app/reader/components/annotator/ExportMarkdownDialog.tsx index b8ff1976..90a2a4da 100644 --- a/apps/readest-app/src/app/reader/components/annotator/ExportMarkdownDialog.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/ExportMarkdownDialog.tsx @@ -8,7 +8,7 @@ import { BookNote, BooknoteGroup, NoteExportConfig } from '@/types/book'; import { DEFAULT_NOTE_EXPORT_CONFIG } from '@/services/constants'; import { saveViewSettings } from '@/helpers/settings'; import { renderNoteTemplate, formatBlockQuote } from '@/utils/note'; -import { buildAnnotationWebUrl } from '@/utils/deeplink'; +import { buildAnnotationAppUrl, buildAnnotationWebUrl } from '@/utils/deeplink'; import Dialog from '@/components/Dialog'; interface ExportMarkdownDialogProps { @@ -67,7 +67,7 @@ const ExportMarkdownDialog: React.FC = ({ {% if annotation.note %} **${_('Note:')}** {{ annotation.note }} {% endif %} -*{% if annotation.link %}[${_('Page:')} {{ annotation.page }}]({{ annotation.link }}){% else %}${_('Page:')} {{ annotation.page }}{% endif %} · ${_('Time:')} {{ annotation.timestamp | date('%Y-%m-%d %H:%M') }}* +*{% if annotation.appLink %}[${_('Page:')} {{ annotation.page }}]({{ annotation.appLink }}){% else %}${_('Page:')} {{ annotation.page }}{% endif %} · ${_('Time:')} {{ annotation.timestamp | date('%Y-%m-%d %H:%M') }}* {% endfor %} --- @@ -130,6 +130,8 @@ const ExportMarkdownDialog: React.FC = ({ cfi: note.cfi, bookHash, link: buildAnnotationWebUrl({ bookHash, noteId: note.id, cfi: note.cfi }), + webLink: buildAnnotationWebUrl({ bookHash, noteId: note.id, cfi: note.cfi }), + appLink: buildAnnotationAppUrl({ bookHash, noteId: note.id, cfi: note.cfi }), text: note.text || '', note: note.note || '', style: note.style, @@ -239,7 +241,8 @@ const ExportMarkdownDialog: React.FC = ({ // Convert markdown to HTML for preview const htmlPreview = useMemo(() => { if (!markdownPreview) return ''; - return marked.parse(markdownPreview); + const html = marked.parse(markdownPreview) as string; + return html.replace(/annotation.timestamp -{' '} {_('Annotation time')} +
  • + annotation.appLink -{' '} + {_('App deeplink (readest://)')} +
  • +
  • + annotation.webLink -{' '} + {_('Universal web link (https://)')} +
  • diff --git a/apps/readest-app/src/utils/note.ts b/apps/readest-app/src/utils/note.ts index a52abd39..69d8af65 100644 --- a/apps/readest-app/src/utils/note.ts +++ b/apps/readest-app/src/utils/note.ts @@ -11,6 +11,8 @@ export type NoteTemplateData = { cfi?: string; bookHash?: string; link?: string; + webLink?: string; + appLink?: string; text: string; note?: string; style?: string;