forked from akai/readest
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>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BookSearchConfig,
|
||||
ViewSettings,
|
||||
} from '@/types/book';
|
||||
import { unifyAnnotations } from '@/utils/booknoteMigration';
|
||||
|
||||
export const stampBookConfigSchema = <T extends Partial<BookConfig>>(config: T): T => {
|
||||
return { ...config, schemaVersion: BOOK_CONFIG_SCHEMA_VERSION };
|
||||
@@ -64,6 +65,11 @@ export const deserializeConfig = (
|
||||
const { viewSettings, searchConfig } = config;
|
||||
config.viewSettings = { ...globalViewSettings, ...viewSettings };
|
||||
config.searchConfig = { ...defaultSearchConfig, ...searchConfig };
|
||||
// v1 -> v2: collapse split highlight+note records into one unified record so a
|
||||
// note renders with its highlight and round-trips cleanly to KOReader.
|
||||
if ((config.schemaVersion ?? 0) < 2 && config.booknotes?.length) {
|
||||
config.booknotes = unifyAnnotations(config.booknotes);
|
||||
}
|
||||
config.schemaVersion ??= BOOK_CONFIG_SCHEMA_VERSION;
|
||||
config.updatedAt ??= Date.now();
|
||||
return config;
|
||||
|
||||
Reference in New Issue
Block a user