302363a9fd
* 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>
192 lines
7.2 KiB
TypeScript
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.
|
|
};
|