feat(reader): import annotations from Moon+ Reader (.mrexpt) (#4174)

* feat(reader): import annotations from Moon+ Reader (.mrexpt)

Add a new menu entry under the reader sidebar 'More' menu that lets users import highlights and notes exported from the Moon+ Reader Android app.

Implementation:

- utils/mrexpt.ts: parser for the .mrexpt plaintext format (entry id, NCX navPoint index b4, character offset b6, type marker, word and note).

- services/annotation/providers/mrexpt.ts: convert mrexpt entries to BookNote[] using bookDoc. Locate the chapter via b4 -> toc -> spine, then TreeWalker-search the section DOM for the highlighted word with English suffix tolerance (ing/ed/s/...). Falls back to a section-level CFI when the exact word can't be located. Re-imports are deduplicated by a stable id derived from entryId.

- BookMenu: add 'Import from Moon+ Reader' menu item dispatching the 'import-mrexpt' event.

- Annotator: handle 'import-mrexpt' — pick the file (Web File / Tauri path), parse, convert against the live bookDoc, merge into booknotes (latest updatedAt wins), persist via saveConfig, and apply to all live views so highlights appear immediately. User feedback via toasts (importing / imported N / N unmatched / nothing new).

* refactor(reader): simplify Moon+ Reader import notifications

Reworks the .mrexpt import UX so it shows exactly one toast per run
instead of up to two, and removes redundant intermediate notices.

- Drop the intermediate "Importing N annotations…" toast. The toast
  system shows one toast at a time, so it merely flashed and was
  replaced by the result toast.
- Drop the duplicate "Failed to read the selected file." toast in the
  read catch block; it falls through to the existing empty-content
  check which surfaces the same message.
- Collapse the three-way result toast (already imported / N unmatched /
  N imported) into one: "Imported {{count}} annotations" or
  "No new annotations to import".
- Fix a result-message bug: when every converted note was already
  imported and nothing was unmatched, the toast read "Imported 0
  annotations." It now reports "No new annotations to import".
- Pluralize the success message via i18n `count` (the previous `{{n}}`
  placeholder never pluralized, e.g. "Imported 1 annotations").
- Extract the dedupe/merge logic into a pure, unit-tested
  `mergeImportedBookNotes` helper.

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

* chore(i18n): translate Moon+ Reader import strings

Run i18next extraction and translate the new .mrexpt import strings
across all 33 locales (340 keys). The import feature added in this PR
introduced translatable strings that had not yet been extracted.

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

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
loveheaven
2026-05-17 11:37:26 +08:00
committed by GitHub
parent a20f68fc11
commit 3620c61038
39 changed files with 1122 additions and 34 deletions
@@ -52,6 +52,12 @@ import { setProofreadRulesVisibility } from '@/app/reader/components/ProofreadRu
import ExportMarkdownDialog from './ExportMarkdownDialog';
import Alert from '@/components/Alert';
import ModalPortal from '@/components/ModalPortal';
import { useFileSelector } from '@/hooks/useFileSelector';
import { parseMrexpt } from '@/utils/mrexpt';
import {
convertMrexptEntriesToBookNotes,
mergeImportedBookNotes,
} from '@/services/annotation/providers/mrexpt';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -65,6 +71,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const { clearBooknotesNav } = useSidebarStore();
const { listenToNativeTouchEvents } = useDeviceControlStore();
const { loadCustomDictionaries } = useCustomDictionaryStore();
const { selectFiles } = useFileSelector(appService, _);
useNotesSync(bookKey);
useReadwiseSync(bookKey);
@@ -484,9 +491,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useEffect(() => {
eventDispatcher.on('export-annotations', handleExportMarkdown);
eventDispatcher.on('clear-annotations', handleClearAnnotations);
eventDispatcher.on('import-mrexpt', handleImportMrexpt);
return () => {
eventDispatcher.off('export-annotations', handleExportMarkdown);
eventDispatcher.off('clear-annotations', handleClearAnnotations);
eventDispatcher.off('import-mrexpt', handleImportMrexpt);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -859,6 +868,126 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
[selection?.text],
);
const handleImportMrexpt = async (event: CustomEvent) => {
const { bookKey: importBookKey } = event.detail;
if (bookKey !== importBookKey) return;
const { bookDoc } = bookData;
if (!bookDoc) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Book is not ready yet, please try again.'),
timeout: 2000,
});
return;
}
// Pick the .mrexpt file.
const result = await selectFiles({
type: 'generic',
accept: '.mrexpt,text/plain',
extensions: ['mrexpt', 'txt'],
multiple: false,
dialogTitle: _('Select Moon+ Reader Export File'),
});
if (result.error || result.files.length === 0) return;
const selectedFile = result.files[0]!;
// Read the file content as text on both Web (File) and Tauri (path).
let content = '';
try {
if (selectedFile.file) {
content = await selectedFile.file.text();
} else if (selectedFile.path) {
content = (await appService?.readFile(selectedFile.path, 'None', 'text')) as string;
}
} catch (e) {
console.warn('Failed to read mrexpt file:', e);
}
if (!content) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Failed to read the selected file.'),
timeout: 2000,
});
return;
}
const entries = parseMrexpt(content);
if (entries.length === 0) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('No annotations found in the file.'),
timeout: 2000,
});
return;
}
let conversion;
try {
conversion = await convertMrexptEntriesToBookNotes(entries, bookDoc, {
highlightStyle: settings.globalReadSettings.highlightStyle,
highlightColor:
settings.globalReadSettings.highlightStyles[settings.globalReadSettings.highlightStyle],
});
} catch (e) {
console.warn('Failed to convert mrexpt entries:', e);
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Failed to import annotations.'),
timeout: 3000,
});
return;
}
if (conversion.notes.length === 0) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('No annotations could be located in this book.'),
timeout: 2500,
});
return;
}
// Merge into the current book config, deduplicating by note id and
// preferring the latest updatedAt for any conflicting entries.
const config = getConfig(bookKey)!;
const { merged, applied, added, updated } = mergeImportedBookNotes(
config.booknotes ?? [],
conversion.notes,
);
const updatedConfig = updateBooknotes(bookKey, merged);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
// Apply imported (or resurrected) annotations to all live views so
// they appear immediately. We only re-draw the notes that actually
// changed in this round, otherwise duplicate addAnnotation calls
// can confuse the overlay layer.
const views = getViewsById(bookKey.split('-')[0]!);
for (const note of applied) {
try {
views.forEach((v) => v?.addAnnotation(note));
} catch (err) {
console.warn('Failed to add imported annotation', { note, err });
}
}
// A single result toast: the count if anything changed, otherwise a
// plain "nothing new" hint for a repeated import of the same file.
const imported = added + updated;
eventDispatcher.dispatch('toast', {
type: 'info',
message:
imported > 0
? _('Imported {{count}} annotations', { count: imported })
: _('No new annotations to import'),
timeout: 2500,
});
};
const handleExportMarkdown = async (event: CustomEvent) => {
const { bookKey: exportBookKey } = event.detail;
if (bookKey !== exportBookKey) return;