forked from akai/readest
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:
@@ -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: '',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user