forked from akai/readest
ff96c6d3f7
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>
76 lines
2.7 KiB
TypeScript
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;
|
|
});
|
|
}
|