feat(annotations): configurable export link type + dedicated Import Annotations modal (#4350)

* feat(export): make annotation export link type configurable

Add an Annotation Link selector (App / Web) to the Export Annotations
dialog. Defaults to the app deeplink in the native app and the universal
web link on the web, so web exports no longer emit readest:// links that
only the desktop/mobile app can open. The default markdown template now
uses the configurable annotation.link variable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(annotations): move Moon+ Reader import into a dedicated Import Annotations modal

Replace the single 'Import from Moon+ Reader' menu item with an 'Import
Annotations' entry (below 'Export Annotations') that opens a dedicated
modal listing import sources. Currently lists Moon+ Reader; the boxed-list
layout makes adding future providers a one-row change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-29 13:48:00 +08:00
committed by GitHub
parent 3c14d5a4b9
commit 2f5e583653
9 changed files with 209 additions and 21 deletions
@@ -0,0 +1,49 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import ImportAnnotationsDialog from '@/app/reader/components/annotator/ImportAnnotationsDialog';
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (key: string) => key,
}));
vi.mock('@/components/Dialog', () => ({
__esModule: true,
default: ({
title,
children,
onClose,
}: {
title?: string;
children: React.ReactNode;
onClose: () => void;
}) => (
<div role='dialog' aria-label={title}>
<button type='button' aria-label='close-dialog' onClick={onClose} />
{children}
</div>
),
}));
afterEach(() => {
cleanup();
});
describe('ImportAnnotationsDialog', () => {
it('renders the Moon+ Reader import source', () => {
render(<ImportAnnotationsDialog isOpen onClose={vi.fn()} onImportMoonReader={vi.fn()} />);
expect(screen.getByRole('dialog', { name: 'Import Annotations' })).toBeTruthy();
expect(screen.getByText('Moon+ Reader')).toBeTruthy();
});
it('invokes onImportMoonReader when the Moon+ Reader row is clicked', () => {
const onImportMoonReader = vi.fn();
render(
<ImportAnnotationsDialog isOpen onClose={vi.fn()} onImportMoonReader={onImportMoonReader} />,
);
fireEvent.click(screen.getByText('Moon+ Reader'));
expect(onImportMoonReader).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { buildAnnotationUrl } from '../../utils/deeplink';
describe('buildAnnotationUrl', () => {
const link = { bookHash: 'abc', noteId: 'n1', cfi: '/6/4!/4/2' };
it('builds the custom-scheme app URL when linkType is "app"', () => {
const url = buildAnnotationUrl(link, 'app');
expect(url.startsWith('readest://book/abc/annotation/n1')).toBe(true);
});
it('builds the HTTPS web URL when linkType is "web"', () => {
const url = buildAnnotationUrl(link, 'web');
expect(url.startsWith('https://')).toBe(true);
expect(url).toContain('/o/book/abc/annotation/n1');
});
it('preserves the cfi query for both link types', () => {
const encoded = encodeURIComponent(link.cfi);
expect(buildAnnotationUrl(link, 'app')).toContain(`cfi=${encoded}`);
expect(buildAnnotationUrl(link, 'web')).toContain(`cfi=${encoded}`);
});
it('omits the cfi query when no cfi is provided', () => {
const url = buildAnnotationUrl({ bookHash: 'abc', noteId: 'n1' }, 'app');
expect(url).toBe('readest://book/abc/annotation/n1');
});
});
@@ -65,6 +65,7 @@ import useShortcuts from '@/hooks/useShortcuts';
import ProofreadPopup from './ProofreadPopup';
import { setProofreadRulesVisibility } from '@/app/reader/components/ProofreadRules';
import ExportMarkdownDialog from './ExportMarkdownDialog';
import ImportAnnotationsDialog from './ImportAnnotationsDialog';
import Alert from '@/components/Alert';
import ModalPortal from '@/components/ModalPortal';
import { useFileSelector } from '@/hooks/useFileSelector';
@@ -125,6 +126,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [editingAnnotation, setEditingAnnotation] = useState<BookNote | null>(null);
const [externalDragPoint, setExternalDragPoint] = useState<Point | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [importingMrexpt, setImportingMrexpt] = useState(false);
// "Clear Annotations" confirm dialog. Hosted here (and not in BookMenu)
// because the menu unmounts the moment the user picks the entry, which
@@ -533,11 +535,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useEffect(() => {
eventDispatcher.on('export-annotations', handleExportMarkdown);
eventDispatcher.on('clear-annotations', handleClearAnnotations);
eventDispatcher.on('import-mrexpt', handleImportMrexpt);
eventDispatcher.on('import-annotations', handleImportAnnotations);
return () => {
eventDispatcher.off('export-annotations', handleExportMarkdown);
eventDispatcher.off('clear-annotations', handleClearAnnotations);
eventDispatcher.off('import-mrexpt', handleImportMrexpt);
eventDispatcher.off('import-annotations', handleImportAnnotations);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -994,9 +996,14 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
[selection?.text],
);
const handleImportMrexpt = async (event: CustomEvent) => {
const handleImportAnnotations = (event: CustomEvent) => {
const { bookKey: importBookKey } = event.detail;
if (bookKey !== importBookKey) return;
setShowImportDialog(true);
};
const importFromMoonReader = async () => {
setShowImportDialog(false);
const { bookDoc } = bookData;
if (!bookDoc) {
@@ -1421,6 +1428,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onExport={handleConfirmExport}
/>
)}
{showImportDialog && (
<ImportAnnotationsDialog
isOpen={showImportDialog}
onClose={() => setShowImportDialog(false)}
onImportMoonReader={importFromMoonReader}
/>
)}
{clearAnnotationsCount > 0 && (
<ModalPortal>
<Alert
@@ -8,7 +8,12 @@ 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 { buildAnnotationAppUrl, buildAnnotationWebUrl } from '@/utils/deeplink';
import {
AnnotationLinkType,
buildAnnotationAppUrl,
buildAnnotationUrl,
buildAnnotationWebUrl,
} from '@/utils/deeplink';
import Dialog from '@/components/Dialog';
interface ExportMarkdownDialogProps {
@@ -71,7 +76,7 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
{% if annotation.note %}
**${_('Note:')}** {{ annotation.note }}
{% endif %}
*{% if annotation.appLink %}[${_('Page:')} {{ annotation.page }}]({{ annotation.appLink }}){% else %}${_('Page:')} {{ annotation.page }}{% endif %} · ${_('Time:')} {{ annotation.timestamp | date('%Y-%m-%d %H:%M') }}*
*{% if annotation.link %}[${_('Page:')} {{ annotation.page }}]({{ annotation.link }}){% else %}${_('Page:')} {{ annotation.page }}{% endif %} · ${_('Time:')} {{ annotation.timestamp | date('%Y-%m-%d %H:%M') }}*
{% endfor %}
---
@@ -79,13 +84,13 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
const [exportConfig, setExportConfig] = useState<NoteExportConfig>(() => {
const noteExportConfig = viewSettings?.noteExportConfig || DEFAULT_NOTE_EXPORT_CONFIG;
if (!noteExportConfig.customTemplate) {
return {
...noteExportConfig,
customTemplate: defaultTemplate,
};
}
return noteExportConfig;
return {
...noteExportConfig,
// Configs persisted before link types existed fall back to the
// platform-aware default (app in the native app, web on the web).
linkType: noteExportConfig.linkType ?? DEFAULT_NOTE_EXPORT_CONFIG.linkType,
customTemplate: noteExportConfig.customTemplate || defaultTemplate,
};
});
const [showSource, setShowSource] = useState(false);
@@ -133,7 +138,10 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
id: note.id,
cfi: note.cfi,
bookHash,
link: buildAnnotationWebUrl({ bookHash, noteId: note.id, cfi: note.cfi }),
link: buildAnnotationUrl(
{ bookHash, noteId: note.id, cfi: note.cfi },
exportConfig.linkType,
),
webLink: buildAnnotationWebUrl({ bookHash, noteId: note.id, cfi: note.cfi }),
appLink: buildAnnotationAppUrl({ bookHash, noteId: note.id, cfi: note.cfi }),
text: note.text || '',
@@ -201,11 +209,10 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
if (exportConfig.includePageNumber && note.page) {
const pageText = _('Page: {{number}}', { number: note.page });
if (bookHash && note.id) {
const url = buildAnnotationWebUrl({
bookHash,
noteId: note.id,
cfi: note.cfi,
});
const url = buildAnnotationUrl(
{ bookHash, noteId: note.id, cfi: note.cfi },
exportConfig.linkType,
);
pageStr = `[${pageText}](${url})`;
} else {
pageStr = pageText;
@@ -389,6 +396,23 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
<span className='text-sm'>{_('Note Date')}</span>
</label>
</div>
<div className='flex items-center justify-between gap-3'>
<span className='text-sm'>{_('Annotation Link')}</span>
<select
value={exportConfig.linkType}
onChange={(e) =>
setExportConfig((prev) => ({
...prev,
linkType: e.target.value as AnnotationLinkType,
}))
}
className='select select-bordered select-sm eink-bordered'
>
<option value='app'>{_('App Link')}</option>
<option value='web'>{_('Web Link')}</option>
</select>
</div>
</div>
{/* Advanced Options */}
@@ -525,6 +549,10 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
<code className='bg-base-300 rounded px-1'>annotation.timestamp</code> -{' '}
{_('Annotation time')}
</li>
<li className='ml-8'>
<code className='bg-base-300 rounded px-1'>annotation.link</code> -{' '}
{_('Annotation link (follows the selected Link Type)')}
</li>
<li className='ml-8'>
<code className='bg-base-300 rounded px-1'>annotation.appLink</code> -{' '}
{_('App deeplink (readest://)')}
@@ -0,0 +1,48 @@
import React from 'react';
import { MdNightlightRound } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
import { BoxedList, NavigationRow } from '@/components/settings/primitives';
import Dialog from '@/components/Dialog';
interface ImportAnnotationsDialogProps {
isOpen: boolean;
onClose: () => void;
onImportMoonReader: () => void;
}
/**
* Dedicated modal listing the annotation-import sources. Each source is a
* boxed-list row; new providers (Calibre, KOReader, …) are added by dropping
* another `<NavigationRow>` here and wiring its callback in the Annotator.
*/
const ImportAnnotationsDialog: React.FC<ImportAnnotationsDialogProps> = ({
isOpen,
onClose,
onImportMoonReader,
}) => {
const _ = useTranslation();
return (
<Dialog
isOpen={isOpen}
title={_('Import Annotations')}
onClose={onClose}
boxClassName='sm:!h-auto sm:!max-h-[90vh] sm:!w-[420px]'
contentClassName='sm:!px-6'
>
<BoxedList
title={_('Import From')}
description={_('Import highlights and notes exported from another reading app.')}
>
<NavigationRow
icon={MdNightlightRound}
title={_('Moon+ Reader')}
status={_('Moon+ Reader export file (.mrexpt)')}
onClick={onImportMoonReader}
/>
</BoxedList>
</Dialog>
);
};
export default ImportAnnotationsDialog;
@@ -71,8 +71,8 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
eventDispatcher.dispatch('export-annotations', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const handleImportFromMoonReader = () => {
eventDispatcher.dispatch('import-mrexpt', { bookKey: sideBarBookKey });
const handleImportAnnotations = () => {
eventDispatcher.dispatch('import-annotations', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const handleToggleSortTOC = () => {
@@ -199,7 +199,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
<MenuItem label={_('Proofread')} onClick={showProofreadRulesWindow} />
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('Export Annotations')} onClick={handleExportAnnotations} />
<MenuItem label={_('Import from Moon+ Reader')} onClick={handleImportFromMoonReader} />
<MenuItem label={_('Import Annotations')} onClick={handleImportAnnotations} />
<MenuItem
label={_('Clear Annotations')}
disabled={annotationsToClear === 0}
@@ -380,6 +380,10 @@ export const DEFAULT_NOTE_EXPORT_CONFIG: NoteExportConfig = {
includePageNumber: true,
includeTimestamp: false,
includeChapterSeparator: false,
// Default to the app deeplink in the native app and the universal web link on
// the web. Inlined platform check avoids a circular import with
// environment.ts, which imports from this module.
linkType: process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri' ? 'app' : 'web',
noteSeparator: '\n\n',
useCustomTemplate: false,
customTemplate: '',
+2
View File
@@ -1,6 +1,7 @@
import { BookMetadata } from '@/libs/document';
import { TTSHighlightOptions } from '@/services/tts/types';
import { TTSMediaMetadataMode } from '@/services/tts/types';
import type { AnnotationLinkType } from '@/utils/deeplink';
import { AnnotationToolType } from './annotator';
export type BookFormat =
@@ -315,6 +316,7 @@ export interface NoteExportConfig {
includePageNumber: boolean;
includeTimestamp: boolean;
includeChapterSeparator: boolean;
linkType: AnnotationLinkType;
noteSeparator: string;
useCustomTemplate: boolean;
customTemplate: string;
+15
View File
@@ -6,6 +6,12 @@ export type AnnotationDeepLink = {
cfi?: string;
};
/**
* Which form of annotation link markdown export embeds: the custom-scheme
* `readest://` app deeplink or the universal `https://` web link.
*/
export type AnnotationLinkType = 'app' | 'web';
const ANNOTATION_PATH_PREFIX = '/o/book/';
/**
@@ -28,6 +34,15 @@ export const buildAnnotationAppUrl = ({ bookHash, noteId, cfi }: AnnotationDeepL
return cfi ? `${base}?cfi=${encodeURIComponent(cfi)}` : base;
};
/**
* Build the annotation link for the requested {@link AnnotationLinkType}.
* `app` yields the custom-scheme deeplink; `web` yields the universal HTTPS form.
*/
export const buildAnnotationUrl = (
link: AnnotationDeepLink,
linkType: AnnotationLinkType,
): string => (linkType === 'app' ? buildAnnotationAppUrl(link) : buildAnnotationWebUrl(link));
/**
* Parse an incoming readest:// or https://web.readest.com annotation URL.
* Accepts the new hierarchical form (book/{hash}/annotation/{id}) and the