Files
readest/apps/readest-app/src/utils/booknoteMigration.ts
T
Huang Xin ff96c6d3f7 feat(annotations): unify highlights and annotations into one record (#3870, #4511) (#4647)
A highlight and its note are now a single BookNote. Adding a note attaches
it to the highlight at that CFI (or creates one with the current global
style) instead of creating a second record, and a unified record renders as
both a highlight overlay and a note bubble.

- onDrawAnnotation chooses the draw kind from the overlay value prefix
  (cfi -> highlight, NOTE_PREFIX -> bubble) instead of annotation.note, so a
  record with both a style and a note draws both. Fixes notes synced from
  KOReader losing their highlight (#4511).
- handleSaveNote updates the existing annotation at the CFI rather than
  pushing a new record (#3870); re-styling preserves the note.
- unifyAnnotations migration (book config schema v1 -> v2, run in
  deserializeConfig) collapses existing split highlight+note records into one
  survivor and tombstones the redundant record (deletedAt) so the merge syncs
  to the cloud and KOReader.
- Sidebar: a note's quoted highlight text uses the theme foreground so it
  stays legible on the highlight background.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:37:46 +02:00

76 lines
2.7 KiB
TypeScript

import { BookNote } from '@/types/book';
/**
* Schema v1 → v2 migration: collapse a highlight and its note that were stored
* as two separate `type:'annotation'` records at the same CFI into one unified
* record, tombstoning the redundant record(s) so the deletion propagates to the
* cloud and KOReader.
*
* The survivor is chosen deterministically (prefer a record with a `style`, then
* earliest `createdAt`, then smallest `id`) so independent devices converge under
* the server's last-writer-wins merge. The latest-updated non-empty note wins.
*
* Idempotent: a CFI that already has a single live record is left untouched, and
* a re-run after migration finds nothing to merge. Bookmarks, excerpts, and
* `global` highlights are never touched. Returns the same array reference when
* nothing changed.
*/
export function unifyAnnotations(booknotes: BookNote[]): BookNote[] {
const groups = new Map<string, BookNote[]>();
for (const note of booknotes) {
if (note.type !== 'annotation') continue;
if (note.deletedAt) continue;
if (note.global) continue;
if (!note.cfi) continue;
const bucket = groups.get(note.cfi);
if (bucket) bucket.push(note);
else groups.set(note.cfi, [note]);
}
const survivorById = new Map<string, BookNote>();
const tombstonedIds = new Set<string>();
let changed = false;
const now = Date.now();
for (const group of groups.values()) {
if (group.length < 2) continue;
changed = true;
const sorted = [...group].sort((a, b) => {
const aStyled = a.style ? 0 : 1;
const bStyled = b.style ? 0 : 1;
if (aStyled !== bStyled) return aStyled - bStyled;
const aCreated = a.createdAt ?? 0;
const bCreated = b.createdAt ?? 0;
if (aCreated !== bCreated) return aCreated - bCreated;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
const survivor = sorted[0]!;
let note = survivor.note ?? '';
let noteUpdatedAt = note.trim().length > 0 ? (survivor.updatedAt ?? 0) : -1;
for (const member of group) {
const memberNote = member.note ?? '';
if (memberNote.trim().length === 0) continue;
const memberUpdatedAt = member.updatedAt ?? 0;
if (memberUpdatedAt > noteUpdatedAt) {
note = memberNote;
noteUpdatedAt = memberUpdatedAt;
}
}
survivorById.set(survivor.id, { ...survivor, note, updatedAt: now });
for (const member of group) {
if (member.id !== survivor.id) tombstonedIds.add(member.id);
}
}
if (!changed) return booknotes;
return booknotes.map((note) => {
if (survivorById.has(note.id)) return survivorById.get(note.id)!;
if (tombstonedIds.has(note.id)) return { ...note, deletedAt: now, updatedAt: now };
return note;
});
}