Files
readest/apps/readest-app/src/services/sync/adapters/settings.ts
T
Huang Xin 302363a9fd feat(sync): per-category sync gates + Manage Sync UI (#4099)
* feat(sync): add per-category sync gates + Manage Sync UI

The user can now enable / disable each sync category independently
in Settings → Data Sync (User page). The map syncs across devices
via the bundled `settings` replica, defaults to enabled so the
preference is opt-out, and applies on both push (`replicaPublish`,
legacy `useSync`) and pull (`useReplicaPull`, `useSync`) without
backfilling on re-enable.

Categories:
- `book` / `progress` / `note` — gate the legacy `SyncClient` paths
- `dictionary` / `font` / `texture` / `opds_catalog` — gate the
  replica-sync pulls + publishes for those kinds
- `settings` — togglable, but force-on while `dictionary` is enabled
  because the dictionary's `providerOrder` / `providerEnabled` /
  `webSearches` live in the bundled settings replica. The UI
  shows the locked toggle as blue (enabled) with a hint instead of
  greying it out, since the underlying state IS on.

UI:
- New `SyncCategoriesSection` lists every category with a
  description and a daisyUI toggle.
- New `Manage Sync` blue action on the User page (second slot,
  right after `Manage Subscription`); also surfaces inside the
  library `Advanced Settings` menu, deep-linking via
  `/user?section=sync`.
- `SyncPassphraseSection` moved into the Manage Sync panel
  alongside the categories list. `Unlock now` button removed —
  the gate fires automatically on first encrypted push/pull and
  the manual unlock affordance was confusing.

Adjacent cleanups:
- `LangPanel` Dictionaries card gets `overflow-hidden` so the
  hover highlight clips to the card's rounded corners.
- `FontPanel` gear icon replaced with a `Manage Fonts` row that
  matches the `Manage Dictionaries` pattern.

i18n: extracted + translated 31 in-scope locales for the new
strings (`Manage Sync`, `Data Sync`, `Manage Fonts`, plus the
category copy block).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(i18n): translate new sync-categories strings across 30 locales

Adds translations for the four strings extracted after the latest
SyncCategoriesSection iteration:

- `App settings` — toggle label for the bundled-settings sync gate
- `Theme, highlight colours, integrations (KOSync, Readwise,
  Hardcover), and dictionary order` — description under that toggle
- `Required while Dictionaries sync is enabled` — hint shown when
  the toggle is locked because dictionary sync depends on settings
- `Unavailable` — `(Unavailable)` suffix on disabled translator
  providers; was missing from most locales until i18next-scanner
  picked it up this run

Product names (KOSync, Readwise, Hardcover) left in Latin script.
`Dictionaries` references in the third string reuse each locale's
existing translation. `pt-BR` and `uz` deliberately untouched (out
of the in-scope set).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(i18n): fill in pt-BR for the new sync-categories strings

`pt-BR` is a registered, shipped locale (Portuguese (Brasil)) that
fell through the gaps in earlier batch runs because it isn't listed
in the i18n skill's locale-reference table. The fallback chain
`pt-BR → pt → en` softened the impact, but BR-specific phrasing
needs its own translations for the 20 new keys this PR added.

`uz` stays excluded — that locale isn't registered anywhere
(missing from i18next-scanner.config.cjs, src/i18n/i18n.ts, and
TRANSLATED_LANGS), so its translation file is dead code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(i18n): translate uz for the new sync-categories strings

`uz` is a registered locale (listed in `i18n-langs.json` and
`TRANSLATED_LANGS` as `'Oʻzbek'`) but earlier batch translation
runs excluded it because the i18n skill's static locale-reference
table was incomplete. Filling in the 20 strings this PR added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(agent): update i18n skill

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:52:09 +02:00

192 lines
7.2 KiB
TypeScript

import type { SystemSettings } from '@/types/settings';
import type { ReplicaAdapter } from '@/services/sync/replicaRegistry';
import type { FieldsObject, ReplicaRow } from '@/types/replica';
import { unwrap } from './helpers';
export const SETTINGS_KIND = 'settings';
export const SETTINGS_SCHEMA_VERSION = 1;
/**
* Stable replica_id for the singleton settings row. The kind is
* capped at maxRowsPerUser=1 server-side so this id is unique per
* user. Kept short to limit fields_jsonb overhead.
*/
export const SETTINGS_REPLICA_ID = 'singleton';
/**
* Whitelist of SystemSettings keys that sync via the bundled
* `settings` row. Each entry is a dot-path into SystemSettings so
* nested values (`globalViewSettings.uiLanguage`) and flat-map
* settings (future: `providerEnabled.<id>`) live alongside top-level
* scalars. Adding a new synced setting is a one-line addition.
*
* Notable exclusions:
* * Device-specific paths (`localBooksDir`, `lastOpenBooks`,
* `screenBrightness`, `customRootDir`) — wouldn't make sense
* across devices.
* * Collection settings already synced via dedicated kinds
* (`customFonts`, `customTextures`, `customDictionaries`,
* `opdsCatalogs`). Note: `dictionarySettings` sub-fields
* (providerOrder / providerEnabled / webSearches) ARE bundled
* here — see entries below.
*/
export const SETTINGS_WHITELIST = [
'globalViewSettings.userStylesheet',
'globalViewSettings.userUIStylesheet',
'globalReadSettings.customThemes',
'globalReadSettings.customHighlightColors',
'globalReadSettings.userHighlightColors',
'globalReadSettings.defaultHighlightLabels',
'globalReadSettings.customTtsHighlightColors',
// Dictionary preferences. Whole-field LWW — concurrent edits on
// different devices may lose one side, but in practice users don't
// edit these on two devices at once. `defaultProviderId` is
// deliberately excluded: it's the last-used tab, per-device state.
'dictionarySettings.providerOrder',
'dictionarySettings.providerEnabled',
'dictionarySettings.webSearches',
// External integrations. Server URL + identifiers sync as plaintext;
// the credential fields are listed in `encryptedFields` below so the
// publish/pull middleware wraps them in cipher envelopes.
'kosync.serverUrl',
'kosync.username',
'kosync.userkey',
'kosync.password',
'readwise.accessToken',
'hardcover.accessToken',
'syncCategories',
] as const;
/**
* Whitelisted paths whose values are credentials. The publish/pull
* crypto middleware wraps these in cipher envelopes via the active
* CryptoSession; pack / unpack themselves only see plaintext.
*
* Best-effort encryption: when the session is locked we drop the
* field from the push (no plaintext leak) and skip applying it on
* pull. The user explicitly unlocks via the Sync passphrase panel
* (or via an OPDS prompt) to enable cross-device credential sync.
* Settings sync deliberately does NOT trigger the lazy passphrase
* prompt itself — credential sync is opt-in via that explicit
* unlock; the rest of the bundled settings keep syncing quietly.
*/
export const SETTINGS_ENCRYPTED_FIELDS = [
'kosync.username',
'kosync.userkey',
'kosync.password',
'readwise.accessToken',
'hardcover.accessToken',
] as const;
export type SettingsWhitelistKey = (typeof SETTINGS_WHITELIST)[number];
// In practice every path comes from the compile-time SETTINGS_WHITELIST so
// these never appear, but readPath/writePath are exported helpers and the
// guard makes prototype pollution impossible if a future caller passes an
// untrusted path.
const isUnsafeKey = (k: string): boolean =>
k === '__proto__' || k === 'constructor' || k === 'prototype';
/** Read a dot-path value from a deep object. Returns undefined for absent paths. */
export const readPath = (obj: unknown, path: string): unknown => {
let cur: unknown = obj;
for (const part of path.split('.')) {
if (isUnsafeKey(part)) return undefined;
if (cur === null || cur === undefined || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[part];
}
return cur;
};
/**
* Set a dot-path value on a deep object, creating intermediate
* objects as needed. Mutates in place. Used by the pull side to
* build a partial SystemSettings patch from the row's flat fields.
*/
export const writePath = (obj: Record<string, unknown>, path: string, value: unknown): void => {
const parts = path.split('.');
if (parts.some(isUnsafeKey)) return;
let cur: Record<string, unknown> = obj;
for (let i = 0; i < parts.length - 1; i++) {
const k = parts[i]!;
const next = cur[k];
if (next === undefined || next === null || typeof next !== 'object') {
cur[k] = {};
}
cur = cur[k] as Record<string, unknown>;
}
cur[parts[parts.length - 1]!] = value;
};
/**
* Unpacked settings patch returned by the adapter. The replica
* orchestrator constraint (`extends ReplicaLocalRecord`) requires
* a `name`; we set a stable placeholder so the orchestrator's
* displayName fallback works without touching the user-visible
* SystemSettings shape.
*/
export interface SettingsRemoteRecord {
name: 'singleton';
patch: Partial<SystemSettings>;
/**
* Per-field cipher fingerprint of the last-decrypted pull. Populated
* from localStorage by the settings pull config so the orchestrator's
* cipher-fingerprint heuristic can decide whether to prompt — same
* pattern as OPDSCatalog.lastSeenCipher, just stored externally
* since the singleton settings row has no per-record local object.
*/
lastSeenCipher?: Record<string, string>;
}
const unwrapSettingsFields = (fields: FieldsObject): Record<string, unknown> => {
const out: Record<string, unknown> = {};
for (const path of SETTINGS_WHITELIST) {
const v = unwrap(fields[path]);
if (v !== undefined) out[path] = v;
}
return out;
};
export const settingsAdapter: ReplicaAdapter<SettingsRemoteRecord> = {
kind: SETTINGS_KIND,
schemaVersion: SETTINGS_SCHEMA_VERSION,
encryptedFields: SETTINGS_ENCRYPTED_FIELDS,
pack(record: SettingsRemoteRecord): Record<string, unknown> {
const fields: Record<string, unknown> = {};
for (const path of SETTINGS_WHITELIST) {
const value = readPath(record.patch, path);
if (value !== undefined) fields[path] = value;
}
return fields;
},
unpack(fields: Record<string, unknown>): SettingsRemoteRecord {
const patch: Record<string, unknown> = {};
for (const path of SETTINGS_WHITELIST) {
const v = fields[path];
if (v !== undefined) writePath(patch, path, v);
}
return { name: 'singleton', patch: patch as Partial<SystemSettings> };
},
async computeId(): Promise<string> {
return SETTINGS_REPLICA_ID;
},
unpackRow(row: ReplicaRow): SettingsRemoteRecord | null {
const flat = unwrapSettingsFields(row.fields_jsonb);
if (Object.keys(flat).length === 0) {
// Empty row (no whitelisted fields present yet) — nothing to apply.
return null;
}
const patch: Record<string, unknown> = {};
for (const [path, v] of Object.entries(flat)) {
writePath(patch, path, v);
}
return { name: 'singleton', patch: patch as Partial<SystemSettings> };
},
// No `binary` capability — settings is metadata-only.
};