082edc204b
* docs: design for syncing updated book data (cover + file) (#4544) Cover-change sync via a content hash (coverHash = partial MD5 of cover.png) plus a cover_updated_at field-level merge timestamp; file updates ride the existing re-import / metaHash dedupe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): sync updated book covers across devices (#4544) Editing a book's cover wrote cover.png locally but changed no hash (the cover is keyed by the file hash), so peers had no signal to re-download it and the change never propagated. Give the cover its own content-addressed version: - coverHash = partial MD5 of cover.png; a peer re-downloads the cover iff the synced hash differs from the local one (idempotent, no churn on identical/re-extracted covers — compatible with the metaHash dedupe). - coverUpdatedAt = field-level LWW timestamp so a page-turn that wins whole-row LWW on updated_at can't clobber a cover edit (mirrors the reading_status_updated_at fix for #4634). Editing a cover recomputes the hash, bumps coverUpdatedAt, and re-uploads only the cover; the server merges cover fields independently; peers re-download on a hash diff. File updates continue to ride the existing re-import / metaHash dedupe (changed file -> changed hash -> re-key). Migration 016 adds cover_hash / cover_updated_at to books. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
244 lines
5.7 KiB
TypeScript
244 lines
5.7 KiB
TypeScript
import {
|
|
Book,
|
|
BookConfig,
|
|
BookFormat,
|
|
BookNote,
|
|
BookNoteType,
|
|
HighlightColor,
|
|
HighlightStyle,
|
|
ReadingStatus,
|
|
} from '@/types/book';
|
|
import { DBBookConfig, DBBook, DBBookNote } from '@/types/records';
|
|
import { sanitizeString } from './sanitize';
|
|
|
|
export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DBBookConfig => {
|
|
const {
|
|
bookHash,
|
|
metaHash,
|
|
progress,
|
|
location,
|
|
xpointer,
|
|
rsvpPosition,
|
|
searchConfig,
|
|
viewSettings,
|
|
updatedAt,
|
|
} = bookConfig as BookConfig;
|
|
|
|
return {
|
|
user_id: userId,
|
|
book_hash: bookHash!,
|
|
meta_hash: metaHash,
|
|
location: location,
|
|
xpointer: xpointer,
|
|
progress: progress && JSON.stringify(progress),
|
|
rsvp_position: rsvpPosition && JSON.stringify(rsvpPosition),
|
|
search_config: searchConfig && JSON.stringify(searchConfig),
|
|
view_settings: viewSettings && JSON.stringify(viewSettings),
|
|
updated_at: new Date(updatedAt ?? Date.now()).toISOString(),
|
|
};
|
|
};
|
|
|
|
export const transformBookConfigFromDB = (dbBookConfig: DBBookConfig): BookConfig => {
|
|
const {
|
|
book_hash,
|
|
meta_hash,
|
|
progress,
|
|
location,
|
|
xpointer,
|
|
rsvp_position,
|
|
search_config,
|
|
view_settings,
|
|
updated_at,
|
|
} = dbBookConfig;
|
|
return {
|
|
bookHash: book_hash,
|
|
metaHash: meta_hash,
|
|
location,
|
|
xpointer,
|
|
progress: progress && JSON.parse(progress),
|
|
rsvpPosition: rsvp_position && JSON.parse(rsvp_position),
|
|
searchConfig: search_config && JSON.parse(search_config),
|
|
viewSettings: view_settings && JSON.parse(view_settings),
|
|
updatedAt: new Date(updated_at!).getTime(),
|
|
} as BookConfig;
|
|
};
|
|
|
|
export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
|
const {
|
|
hash,
|
|
metaHash,
|
|
format,
|
|
title,
|
|
sourceTitle,
|
|
author,
|
|
groupId,
|
|
groupName,
|
|
tags,
|
|
progress,
|
|
readingStatus,
|
|
readingStatusUpdatedAt,
|
|
coverHash,
|
|
coverUpdatedAt,
|
|
metadata,
|
|
createdAt,
|
|
updatedAt,
|
|
deletedAt,
|
|
uploadedAt,
|
|
} = book as Book;
|
|
|
|
return {
|
|
user_id: userId,
|
|
book_hash: hash,
|
|
meta_hash: metaHash,
|
|
format,
|
|
title: sanitizeString(title)!,
|
|
author: sanitizeString(author)!,
|
|
group_id: groupId,
|
|
group_name: sanitizeString(groupName),
|
|
tags: tags,
|
|
progress: progress,
|
|
reading_status: readingStatus,
|
|
reading_status_updated_at: readingStatusUpdatedAt
|
|
? new Date(readingStatusUpdatedAt).toISOString()
|
|
: null,
|
|
cover_hash: coverHash ?? null,
|
|
cover_updated_at: coverUpdatedAt ? new Date(coverUpdatedAt).toISOString() : null,
|
|
source_title: sanitizeString(sourceTitle),
|
|
metadata: metadata ? sanitizeString(JSON.stringify(metadata)) : null,
|
|
created_at: new Date(createdAt ?? Date.now()).toISOString(),
|
|
updated_at: new Date(updatedAt ?? Date.now()).toISOString(),
|
|
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : null,
|
|
uploaded_at: uploadedAt ? new Date(uploadedAt).toISOString() : null,
|
|
};
|
|
};
|
|
|
|
export const transformBookFromDB = (dbBook: DBBook): Book => {
|
|
const {
|
|
book_hash,
|
|
meta_hash,
|
|
format,
|
|
title,
|
|
author,
|
|
group_id,
|
|
group_name,
|
|
tags,
|
|
progress,
|
|
reading_status,
|
|
reading_status_updated_at,
|
|
cover_hash,
|
|
cover_updated_at,
|
|
source_title,
|
|
metadata,
|
|
created_at,
|
|
updated_at,
|
|
deleted_at,
|
|
uploaded_at,
|
|
} = dbBook;
|
|
|
|
return {
|
|
hash: book_hash,
|
|
metaHash: meta_hash,
|
|
format: format as BookFormat,
|
|
title,
|
|
author,
|
|
groupId: group_id,
|
|
groupName: group_name,
|
|
tags: tags,
|
|
progress: progress,
|
|
readingStatus: reading_status as ReadingStatus,
|
|
readingStatusUpdatedAt: reading_status_updated_at
|
|
? new Date(reading_status_updated_at).getTime()
|
|
: undefined,
|
|
coverHash: cover_hash ?? null,
|
|
coverUpdatedAt: cover_updated_at ? new Date(cover_updated_at).getTime() : null,
|
|
sourceTitle: source_title,
|
|
metadata: metadata ? JSON.parse(metadata) : null,
|
|
createdAt: new Date(created_at!).getTime(),
|
|
updatedAt: new Date(updated_at!).getTime(),
|
|
deletedAt: deleted_at ? new Date(deleted_at).getTime() : null,
|
|
uploadedAt: uploaded_at ? new Date(uploaded_at).getTime() : null,
|
|
};
|
|
};
|
|
|
|
export const transformBookNoteToDB = (bookNote: unknown, userId: string): DBBookNote => {
|
|
const {
|
|
bookHash,
|
|
metaHash,
|
|
id,
|
|
type,
|
|
cfi,
|
|
xpointer0,
|
|
xpointer1,
|
|
page,
|
|
text,
|
|
style,
|
|
color,
|
|
note,
|
|
global,
|
|
createdAt,
|
|
updatedAt,
|
|
deletedAt,
|
|
} = bookNote as BookNote;
|
|
|
|
return {
|
|
user_id: userId,
|
|
book_hash: bookHash!,
|
|
meta_hash: metaHash,
|
|
id,
|
|
type,
|
|
cfi,
|
|
xpointer0,
|
|
xpointer1,
|
|
page,
|
|
text: sanitizeString(text),
|
|
style,
|
|
color,
|
|
note,
|
|
global,
|
|
created_at: new Date(createdAt ?? Date.now()).toISOString(),
|
|
updated_at: new Date(updatedAt ?? Date.now()).toISOString(),
|
|
// note that only null deleted_at is updated to the database, undefined is not
|
|
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : null,
|
|
};
|
|
};
|
|
|
|
export const transformBookNoteFromDB = (dbBookNote: DBBookNote): BookNote => {
|
|
const {
|
|
book_hash,
|
|
meta_hash,
|
|
id,
|
|
type,
|
|
cfi,
|
|
xpointer0,
|
|
xpointer1,
|
|
page,
|
|
text,
|
|
style,
|
|
color,
|
|
note,
|
|
global,
|
|
created_at,
|
|
updated_at,
|
|
deleted_at,
|
|
} = dbBookNote;
|
|
|
|
return {
|
|
bookHash: book_hash,
|
|
metaHash: meta_hash,
|
|
id,
|
|
type: type as BookNoteType,
|
|
cfi: cfi ?? '',
|
|
xpointer0,
|
|
xpointer1,
|
|
page,
|
|
text,
|
|
style: style as HighlightStyle,
|
|
color: color as HighlightColor,
|
|
note,
|
|
global,
|
|
createdAt: new Date(created_at!).getTime(),
|
|
updatedAt: new Date(updated_at!).getTime(),
|
|
deletedAt: deleted_at ? new Date(deleted_at).getTime() : null,
|
|
};
|
|
};
|