diff --git a/apps/readest-app/src/__tests__/app/reader/annotator/ImportAnnotationsDialog.test.tsx b/apps/readest-app/src/__tests__/app/reader/annotator/ImportAnnotationsDialog.test.tsx
new file mode 100644
index 00000000..8c04a663
--- /dev/null
+++ b/apps/readest-app/src/__tests__/app/reader/annotator/ImportAnnotationsDialog.test.tsx
@@ -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;
+ }) => (
+
+
+ {children}
+
+ ),
+}));
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('ImportAnnotationsDialog', () => {
+ it('renders the Moon+ Reader import source', () => {
+ render();
+
+ 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(
+ ,
+ );
+
+ fireEvent.click(screen.getByText('Moon+ Reader'));
+ expect(onImportMoonReader).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/readest-app/src/__tests__/utils/deeplink.test.ts b/apps/readest-app/src/__tests__/utils/deeplink.test.ts
new file mode 100644
index 00000000..555be1f8
--- /dev/null
+++ b/apps/readest-app/src/__tests__/utils/deeplink.test.ts
@@ -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');
+ });
+});
diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx
index c78a11bf..27a1bbae 100644
--- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx
+++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx
@@ -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(null);
const [externalDragPoint, setExternalDragPoint] = useState(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 && (
+ setShowImportDialog(false)}
+ onImportMoonReader={importFromMoonReader}
+ />
+ )}
{clearAnnotationsCount > 0 && (
= ({
{% 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 = ({
const [exportConfig, setExportConfig] = useState(() => {
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 = ({
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 = ({
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 = ({
{_('Note Date')}
+
+
+ {_('Annotation Link')}
+
+
{/* Advanced Options */}
@@ -525,6 +549,10 @@ const ExportMarkdownDialog: React.FC = ({
annotation.timestamp -{' '}
{_('Annotation time')}
+
+ annotation.link -{' '}
+ {_('Annotation link (follows the selected Link Type)')}
+
annotation.appLink -{' '}
{_('App deeplink (readest://)')}
diff --git a/apps/readest-app/src/app/reader/components/annotator/ImportAnnotationsDialog.tsx b/apps/readest-app/src/app/reader/components/annotator/ImportAnnotationsDialog.tsx
new file mode 100644
index 00000000..067de794
--- /dev/null
+++ b/apps/readest-app/src/app/reader/components/annotator/ImportAnnotationsDialog.tsx
@@ -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 `` here and wiring its callback in the Annotator.
+ */
+const ImportAnnotationsDialog: React.FC = ({
+ isOpen,
+ onClose,
+ onImportMoonReader,
+}) => {
+ const _ = useTranslation();
+
+ return (
+
+ );
+};
+
+export default ImportAnnotationsDialog;
diff --git a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx
index 9a5de248..940f6c8f 100644
--- a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx
+++ b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx
@@ -71,8 +71,8 @@ const BookMenu: React.FC = ({ 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 = ({ menuClassName, setIsDropdownOpen })
-
+