forked from akai/readest
3620c61038
* 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>
163 lines
5.0 KiB
TypeScript
163 lines
5.0 KiB
TypeScript
/**
|
|
* Moon+ Reader (.mrexpt) export file parser.
|
|
*
|
|
* The .mrexpt format is a plaintext file produced by the Android app
|
|
* "Moon+ Reader". Each entry encodes a highlight or annotation captured by
|
|
* the user while reading. The same logic was originally implemented in
|
|
* Python (see MultilingualWord/extract_words_from_epub.py) and is ported
|
|
* here so Readest can import the entries as BookNotes.
|
|
*
|
|
* File layout:
|
|
* line 0: numeric file header
|
|
* line 1: "indent:true|false"
|
|
* line 2: "trim:true|false"
|
|
* then a sequence of entries separated by a single line containing "#":
|
|
* line 0: entry id
|
|
* line 1: book title
|
|
* line 2: original-case file path
|
|
* line 3: lowercase file path
|
|
* line 4: b4 — NCX navPoint 0-based index (chapter locator)
|
|
* line 5: b5 — paragraph offset high bits (usually 0)
|
|
* line 6: b6 — character offset within the document
|
|
* line 7: word character length
|
|
* line 8: color/marker value (-28160 = pure highlight, others = note color)
|
|
* line 9: timestamp (ms)
|
|
* line 10: empty
|
|
* line 11: empty (pure highlight) or note text (with note)
|
|
* line 12: the highlighted word/phrase
|
|
* line 13: type marker — "1" pure highlight, "0" with note
|
|
* line 14-15: padding zeroes
|
|
*/
|
|
|
|
export interface MrexptEntry {
|
|
/** Highlighted text. */
|
|
word: string;
|
|
/** Optional user note attached to the highlight. */
|
|
note: string;
|
|
/** NCX navPoint 0-based index (chapter locator). */
|
|
b4: number;
|
|
/** Paragraph offset high bits, usually 0. */
|
|
b5: number;
|
|
/** Character offset inside the document. */
|
|
b6: number;
|
|
/** Word character length as recorded by Moon+ Reader. */
|
|
wordLength: number;
|
|
/** Creation timestamp (ms). */
|
|
timestamp: number;
|
|
/** Book title as embedded in the entry. */
|
|
bookTitle: string;
|
|
/** File path as embedded in the entry. */
|
|
bookPath: string;
|
|
/** Original entry id from the file. */
|
|
entryId: string;
|
|
/** Whether the entry has an attached note (type marker "0"). */
|
|
hasNote: boolean;
|
|
}
|
|
|
|
const safeParseInt = (value: string | undefined): number => {
|
|
if (!value) return 0;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return 0;
|
|
// allow leading minus
|
|
const numeric = trimmed.replace(/^-/, '');
|
|
if (!/^\d+$/.test(numeric)) return 0;
|
|
const parsed = parseInt(trimmed, 10);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
};
|
|
|
|
const isNumericLine = (value: string): boolean => {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return false;
|
|
return /^-?\d+$/.test(trimmed);
|
|
};
|
|
|
|
/** Parse the raw text content of a .mrexpt file into a list of entries. */
|
|
export const parseMrexpt = (content: string): MrexptEntry[] => {
|
|
// Normalize line endings, then split on a line containing exactly "#".
|
|
// We deliberately use "\n#\n" to mirror the Python implementation.
|
|
const normalized = content.replace(/\r\n?/g, '\n');
|
|
const rawEntries = normalized.split('\n#\n');
|
|
|
|
const entries: MrexptEntry[] = [];
|
|
|
|
for (let i = 0; i < rawEntries.length; i++) {
|
|
let lines = rawEntries[i]!.split('\n');
|
|
|
|
if (i === 0) {
|
|
// First block contains the 3-line file header before the first entry.
|
|
if (lines.length >= 3 && lines[1]!.startsWith('indent:')) {
|
|
lines = lines.slice(3);
|
|
} else {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (lines.length < 13) continue;
|
|
|
|
const entryId = (lines[0] ?? '').trim();
|
|
const bookTitle = (lines[1] ?? '').trim();
|
|
const bookPath = (lines[2] ?? '').trim();
|
|
const b4 = safeParseInt(lines[4]);
|
|
const b5 = safeParseInt(lines[5]);
|
|
const b6 = safeParseInt(lines[6]);
|
|
const wordLength = safeParseInt(lines[7]);
|
|
const timestamp = safeParseInt(lines[9]);
|
|
|
|
let word = '';
|
|
let note = '';
|
|
let hasNote = false;
|
|
|
|
if (lines.length >= 14) {
|
|
const typeMarker = (lines[13] ?? '').trim();
|
|
if (typeMarker === '0') {
|
|
// Annotation: note in line 11, word in line 12.
|
|
note = (lines[11] ?? '').trim();
|
|
word = (lines[12] ?? '').trim();
|
|
hasNote = true;
|
|
} else if (typeMarker === '1') {
|
|
// Pure highlight: line 11 empty, word in line 12.
|
|
word = (lines[12] ?? '').trim();
|
|
} else {
|
|
// Unknown marker — fall back to the first non-numeric line after 10.
|
|
for (let idx = 10; idx < Math.min(lines.length, 15); idx++) {
|
|
const candidate = (lines[idx] ?? '').trim();
|
|
if (candidate && !isNumericLine(candidate)) {
|
|
word = candidate;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Older entries: try line 12 then a non-numeric scan.
|
|
word = (lines[12] ?? '').trim();
|
|
if (!word) {
|
|
for (let idx = 10; idx < lines.length; idx++) {
|
|
const candidate = (lines[idx] ?? '').trim();
|
|
if (candidate && !isNumericLine(candidate)) {
|
|
word = candidate;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!word) continue;
|
|
|
|
entries.push({
|
|
word,
|
|
note,
|
|
b4,
|
|
b5,
|
|
b6,
|
|
wordLength,
|
|
timestamp,
|
|
bookTitle,
|
|
bookPath,
|
|
entryId,
|
|
hasNote,
|
|
});
|
|
}
|
|
|
|
return entries;
|
|
};
|