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:
Huang Xin
2026-03-21 23:43:51 +08:00
committed by GitHub
parent 2f430bf2ff
commit 76b239f382
15 changed files with 1507 additions and 686 deletions
@@ -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]);
};