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; + }) => ( +
+
+ ), +})); + +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 }) - + (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