feat(reader): sync per-book proofread rules across devices (#4781)

Per-book and selection-scope proofread (find/replace) rules were pushed in
the synced book config but dropped on pull (applyRemoteProgress only applied
location), so they never propagated across devices. Merge them by id on the
config pull, mirroring the booknote CRDT path. Library-scope rules keep
syncing via the settings replica.

- Add updatedAt/deletedAt to ProofreadRule. Delete is now a tombstone for
  book/selection scope so a removal is not resurrected by a peer's live copy;
  library-scope deletion keeps the hard splice (settings-replica whole-field
  LWW already handles it).
- Add mergeProofreadRules (by id, updatedAt/deletedAt last-write-wins) and
  merge into applyRemoteProgress; refresh the live view only when the merged
  rules actually changed.
- Backfill a content-derived id for id-less rules (legacy/foreign/hand-edited)
  via ensureRuleId, and seed book/library ids from content so the same rule
  created on two devices dedupes instead of duplicating. Selection rules keep
  a per-instance unique id. Without this, id-less rules collide on one Map key
  and clobber each other.
- Filter tombstoned rules from the transformer and the manager dialog list.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-25 15:49:30 +08:00
committed by GitHub
parent 0589cb4f4a
commit 79ae8a48ba
10 changed files with 530 additions and 24 deletions
@@ -246,14 +246,20 @@ const useReplacementRules = (bookKey: string | null) => {
const persistedConfig = bookKey ? getConfig(bookKey) : null;
const persistedBookRules = persistedConfig?.viewSettings?.proofreadRules || [];
// Prefer persisted rules; fall back to in-memory
const bookRuleSource = persistedBookRules.length ? persistedBookRules : inMemoryRules;
// Prefer persisted rules; fall back to in-memory. Drop tombstoned rules
// (deletedAt set) — deletion is a soft tombstone now so it can sync across
// devices (see store/proofreadStore.ts), but it must not show in the list.
const bookRuleSource = (persistedBookRules.length ? persistedBookRules : inMemoryRules).filter(
(r: ProofreadRule) => !r.deletedAt,
);
const singleRules = bookRuleSource
.filter((r: ProofreadRule) => r.scope === 'selection')
.sort(byOrder);
const bookScopedRules = bookRuleSource.filter((r: ProofreadRule) => r.scope === 'book');
const globalRules = settings?.globalViewSettings?.proofreadRules || [];
const globalRules = (settings?.globalViewSettings?.proofreadRules || []).filter(
(r: ProofreadRule) => !r.deletedAt,
);
// Merge book-scoped and global rules
const globalRuleIds = new Set(globalRules.map((gr: ProofreadRule) => gr.id));