forked from akai/readest
feat(koplugin): add support for annotation sync (#3579)
Add bidirectional annotation/highlight sync between KOReader and Readest: - Add xpointer0/xpointer1 fields to BookNote and DBBookNote types for KOReader XPointer positions alongside Readest's CFI format - Extend transform layer to pass through xpointer fields to/from DB - Convert CFI→XPointer on push and XPointer→CFI on pull in useNotesSync, discarding notes that fail conversion - Support KOReader's text()[K].N indexed text node format in xcfi.ts for paragraphs with inline elements (e.g. <a> page anchors) - Generate KOReader-compatible XPointers: text().N for single text nodes, text()[K].N only when multiple direct text nodes exist - Skip cfi-inert elements (injected by Readest at runtime) in XPointer path building and resolution - Map highlight colors between KOReader and Readest color systems - Implement KOReader plugin annotation push/pull with deterministic IDs, auto-sync on document open/close, and UIManager refresh on pull - Refactor koplugin into focused modules: syncauth, syncconfig, syncannotations, selfupdate Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,104 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useSync } from '@/hooks/useSync';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { BookNote, FIXED_LAYOUT_FORMATS } from '@/types/book';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { SYNC_NOTES_INTERVAL_SEC } from '@/services/constants';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { getXPointerFromCFI, getCFIFromXPointer, XCFI } from '@/utils/xcfi';
|
||||
import { getIndexFromCfi } from '@/utils/cfi';
|
||||
|
||||
export const useNotesSync = (bookKey: string) => {
|
||||
const { user } = useAuth();
|
||||
const { syncedNotes, syncNotes, lastSyncedAtNotes } = useSync(bookKey);
|
||||
const { getConfig, setConfig, getBookData } = useBookDataStore();
|
||||
const { getView } = useReaderStore();
|
||||
|
||||
const config = getConfig(bookKey);
|
||||
|
||||
const populateXPointersForPush = async (notes: BookNote[]): Promise<BookNote[]> => {
|
||||
const bookData = getBookData(bookKey);
|
||||
const book = bookData?.book;
|
||||
if (!book || FIXED_LAYOUT_FORMATS.has(book.format)) return notes;
|
||||
|
||||
const view = getView(bookKey);
|
||||
if (!view) return notes;
|
||||
|
||||
const enriched: BookNote[] = [];
|
||||
for (const note of notes) {
|
||||
if (note.cfi && !note.xpointer0) {
|
||||
try {
|
||||
const contents = view.renderer.getContents();
|
||||
const primaryIndex = view.renderer.primaryIndex;
|
||||
const content = contents.find((x) => x.index === primaryIndex) ?? contents[0];
|
||||
if (content) {
|
||||
const xpResult = await getXPointerFromCFI(
|
||||
note.cfi,
|
||||
content.doc,
|
||||
content.index || 0,
|
||||
bookData.bookDoc ?? undefined,
|
||||
);
|
||||
enriched.push({
|
||||
...note,
|
||||
xpointer0: xpResult.pos0 || xpResult.xpointer,
|
||||
xpointer1: xpResult.pos1,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Conversion failed — push without xpointers
|
||||
}
|
||||
}
|
||||
enriched.push(note);
|
||||
}
|
||||
return enriched;
|
||||
};
|
||||
|
||||
const convertXPointersOnPull = async (notes: BookNote[]): Promise<BookNote[]> => {
|
||||
const bookData = getBookData(bookKey);
|
||||
const book = bookData?.book;
|
||||
if (!book || FIXED_LAYOUT_FORMATS.has(book.format)) return notes.filter((n) => n.cfi);
|
||||
|
||||
const view = getView(bookKey);
|
||||
const converted: BookNote[] = [];
|
||||
for (const note of notes) {
|
||||
if (note.xpointer0 && !note.cfi) {
|
||||
try {
|
||||
let cfi: string | undefined;
|
||||
if (note.xpointer1) {
|
||||
const spineIndex = XCFI.extractSpineIndex(note.xpointer0);
|
||||
const doc = await bookData.bookDoc?.sections?.[spineIndex]?.createDocument();
|
||||
if (doc) {
|
||||
const converter = new XCFI(doc, spineIndex);
|
||||
cfi = converter.xPointerToCFI(note.xpointer0, note.xpointer1);
|
||||
}
|
||||
} else {
|
||||
const contents = view?.renderer.getContents() ?? [];
|
||||
const primaryIndex = view?.renderer.primaryIndex;
|
||||
const content = contents.find((x) => x.index === primaryIndex) ?? contents[0];
|
||||
cfi = await getCFIFromXPointer(
|
||||
note.xpointer0,
|
||||
content?.doc,
|
||||
content?.index,
|
||||
bookData.bookDoc ?? undefined,
|
||||
);
|
||||
}
|
||||
if (cfi) {
|
||||
converted.push({ ...note, cfi, updatedAt: Date.now() });
|
||||
}
|
||||
} catch {
|
||||
// Conversion failed — discard note
|
||||
}
|
||||
} else if (note.cfi) {
|
||||
converted.push(note);
|
||||
}
|
||||
// Discard notes with neither cfi nor xpointer
|
||||
}
|
||||
return converted;
|
||||
};
|
||||
|
||||
const getNewNotes = useCallback(() => {
|
||||
const config = getConfig(bookKey);
|
||||
const book = getBookData(bookKey)?.book;
|
||||
@@ -20,7 +106,10 @@ export const useNotesSync = (bookKey: string) => {
|
||||
|
||||
const bookNotes = config.booknotes ?? [];
|
||||
const newNotes = bookNotes.filter(
|
||||
(note) => lastSyncedAtNotes < note.updatedAt || lastSyncedAtNotes < (note.deletedAt ?? 0),
|
||||
(note) =>
|
||||
!note.xpointer0 ||
|
||||
lastSyncedAtNotes < note.updatedAt ||
|
||||
lastSyncedAtNotes < (note.deletedAt ?? 0),
|
||||
);
|
||||
newNotes.forEach((note) => {
|
||||
note.bookHash = book.hash;
|
||||
@@ -38,7 +127,13 @@ export const useNotesSync = (bookKey: string) => {
|
||||
() => {
|
||||
const book = getBookData(bookKey)?.book;
|
||||
const newNotes = getNewNotes();
|
||||
syncNotes(newNotes.notes, book?.hash, book?.metaHash, 'both');
|
||||
if (newNotes.notes?.length) {
|
||||
populateXPointersForPush(newNotes.notes).then((enriched) => {
|
||||
syncNotes(enriched, book?.hash, book?.metaHash, 'both');
|
||||
});
|
||||
} else {
|
||||
syncNotes(newNotes.notes, book?.hash, book?.metaHash, 'both');
|
||||
}
|
||||
},
|
||||
SYNC_NOTES_INTERVAL_SEC * 1000,
|
||||
{ emitLast: false },
|
||||
@@ -69,19 +164,32 @@ export const useNotesSync = (bookKey: string) => {
|
||||
}
|
||||
return note;
|
||||
};
|
||||
if (syncedNotes?.length && config) {
|
||||
const processSyncedNotes = async () => {
|
||||
if (!syncedNotes?.length || !config) return;
|
||||
const view = getView(bookKey);
|
||||
const book = getBookData(bookKey)?.book;
|
||||
const newNotes = syncedNotes.filter(
|
||||
(note) => note.bookHash === book?.hash || note.metaHash === book?.metaHash,
|
||||
);
|
||||
if (!newNotes.length) return;
|
||||
// Convert xpointer-only notes (from KOReader) to CFI
|
||||
const convertedNotes = await convertXPointersOnPull(newNotes);
|
||||
convertedNotes.forEach((note) => {
|
||||
if (note.cfi) {
|
||||
const index = getIndexFromCfi(note.cfi);
|
||||
if (index === view?.renderer.primaryIndex) {
|
||||
view.addAnnotation(note);
|
||||
}
|
||||
}
|
||||
});
|
||||
const oldNotes = config.booknotes ?? [];
|
||||
const mergedNotes = [
|
||||
...oldNotes.filter((oldNote) => !newNotes.some((newNote) => newNote.id === oldNote.id)),
|
||||
...newNotes.map(processNewNote),
|
||||
...oldNotes.filter((oldNote) => !convertedNotes.some((n) => n.id === oldNote.id)),
|
||||
...convertedNotes.map(processNewNote),
|
||||
];
|
||||
setConfig(bookKey, { booknotes: mergedNotes });
|
||||
}
|
||||
};
|
||||
processSyncedNotes();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [syncedNotes]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user