Files
readest/apps/readest-app/src/services/settingsService.ts
T
Huang Xin cbdc3b8f52 feat(sync): wire dictionary store through replica sync (follow-up to #4075) (#4076)
* feat(sync): cross-device dictionary sync

Custom MDict / StarDict / DICT / SLOB dictionaries now sync across
signed-in devices via the replica layer.

- Store mutations publish replica rows with field-level LWW + tombstones.
- Re-importing the same content (renamed or after delete) preserves the
  user's label and reincarnates the server row instead of duplicating.
- Manifest commits after binary upload so other devices never see a row
  whose binaries aren't on cloud storage yet.
- Pull-side orchestrator creates a placeholder dict, queues the binaries
  via TransferManager, and clears the unavailable flag on completion.
- Toast copy branches by transfer kind so dict uploads don't read
  "Book uploaded".

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

* fix(sync): boot pull and binary download path

- Defer the boot pull until TransferManager is initialized so download
  enqueues aren't dropped.
- Auto-persist the local dict store after applyRemoteDictionary; otherwise
  the next loadCustomDictionaries wipes the in-memory rows.
- Boot pull passes since=null so a device whose cursor advanced past
  unpersisted rows can still recover.
- Skip pulling when not authenticated instead of logging
  "SyncError: Not authenticated" on every boot of a signed-out device.
- downloadReplicaFile resolves the destination against the kind's base
  dir; binaries previously landed at the literal lfp and openFile then
  failed with "File not found".

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

* refactor(sync): per-page useReplicaPull hook

Lifts the boot-time pull out of EnvContext into a hook each page mounts
for the kinds it needs: useReplicaPull({ kinds: ['dictionary'] }).
Library page and the shared Reader component opt in. The hook fires 10s
after page load (so feature mounts hydrate first), dedups per-kind
across navigation, and releases the slot on failure so a later mount
can retry. Future kinds plug into the hook's per-kind switch.

Also closes two refresh-loop bugs:

- Hydrate the dict store from settings BEFORE the apply loop, so the
  auto-persist doesn't clobber persisted rows that the in-memory store
  hadn't yet read. Library-page refresh was the visible victim.
- Skip the download queue when every manifest file is already on disk
  under the resolved bundle dir. Refreshing is a no-op; partial-
  download recovery still queues because some files would be missing.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:39:38 +02:00

175 lines
5.8 KiB
TypeScript

import { FileSystem } from '@/types/system';
import { ReadSettings, SystemSettings } from '@/types/settings';
import { DEFAULT_HIGHLIGHT_COLORS, UserHighlightColor, ViewSettings } from '@/types/book';
import { v4 as uuidv4 } from 'uuid';
import {
DEFAULT_BOOK_LAYOUT,
DEFAULT_BOOK_STYLE,
DEFAULT_BOOK_FONT,
DEFAULT_BOOK_LANGUAGE,
DEFAULT_VIEW_CONFIG,
DEFAULT_READSETTINGS,
SYSTEM_SETTINGS_VERSION,
DEFAULT_TTS_CONFIG,
DEFAULT_MOBILE_VIEW_SETTINGS,
DEFAULT_SYSTEM_SETTINGS,
DEFAULT_CJK_VIEW_SETTINGS,
DEFAULT_MOBILE_READSETTINGS,
DEFAULT_SCREEN_CONFIG,
DEFAULT_TRANSLATOR_CONFIG,
SETTINGS_FILENAME,
DEFAULT_MOBILE_SYSTEM_SETTINGS,
DEFAULT_ANNOTATOR_CONFIG,
DEFAULT_EINK_VIEW_SETTINGS,
DEFAULT_VIEW_SETTINGS_CONFIG,
} from './constants';
import { DEFAULT_AI_SETTINGS } from './ai/constants';
import { getTargetLang, isCJKEnv } from '@/utils/misc';
import { safeLoadJSON, safeSaveJSON } from './persistence';
export interface Context {
fs: FileSystem;
isMobile: boolean;
isEink: boolean;
isAppDataSandbox: boolean;
}
export function getDefaultViewSettings(ctx: Context): ViewSettings {
return {
...DEFAULT_BOOK_LAYOUT,
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...DEFAULT_BOOK_LANGUAGE,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...DEFAULT_SCREEN_CONFIG,
...DEFAULT_ANNOTATOR_CONFIG,
...DEFAULT_VIEW_SETTINGS_CONFIG,
...(ctx.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}),
...(ctx.isEink ? DEFAULT_EINK_VIEW_SETTINGS : {}),
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
...{ ...DEFAULT_TRANSLATOR_CONFIG, translateTargetLang: getTargetLang() },
};
}
/**
* Normalize highlight color prefs into the current shape:
* - `userHighlightColors` becomes `UserHighlightColor[]`. Legacy `string[]` entries
* are lifted into `{ hex }`. A legacy `highlightColorLabels` map (shipped only in
* draft builds of this feature) is folded in: hex entries attach to matching user
* colors, named entries move into `defaultHighlightLabels`.
*/
export function migrateHighlightColorPrefs(read: ReadSettings): void {
const rawUser = (read.userHighlightColors ?? []) as unknown[];
const userColors: UserHighlightColor[] = rawUser
.map((entry) => {
if (typeof entry === 'string') {
return { hex: entry.trim().toLowerCase() };
}
if (entry && typeof entry === 'object' && 'hex' in entry) {
const { hex, label } = entry as UserHighlightColor;
return {
hex: typeof hex === 'string' ? hex.trim().toLowerCase() : '',
...(label?.trim() ? { label: label.trim() } : {}),
};
}
return { hex: '' };
})
.filter((entry) => entry.hex.startsWith('#'));
read.defaultHighlightLabels = { ...(read.defaultHighlightLabels ?? {}) };
const legacyLabels = (read as unknown as { highlightColorLabels?: unknown }).highlightColorLabels;
if (legacyLabels && typeof legacyLabels === 'object') {
const labels = legacyLabels as Record<string, unknown>;
for (const name of DEFAULT_HIGHLIGHT_COLORS) {
const value = labels[name];
if (typeof value === 'string' && value.trim() && !read.defaultHighlightLabels[name]) {
read.defaultHighlightLabels[name] = value.trim();
}
}
for (const entry of userColors) {
if (entry.label) continue;
const value = labels[entry.hex];
if (typeof value === 'string' && value.trim()) {
entry.label = value.trim();
}
}
delete (read as unknown as { highlightColorLabels?: unknown }).highlightColorLabels;
}
read.userHighlightColors = userColors;
}
export async function loadSettings(ctx: Context): Promise<SystemSettings> {
const defaultSettings: SystemSettings = {
...DEFAULT_SYSTEM_SETTINGS,
...(ctx.isMobile ? DEFAULT_MOBILE_SYSTEM_SETTINGS : {}),
version: SYSTEM_SETTINGS_VERSION,
localBooksDir: await ctx.fs.getPrefix('Books'),
koreaderSyncDeviceId: uuidv4(),
globalReadSettings: {
...DEFAULT_READSETTINGS,
...(ctx.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}),
},
globalViewSettings: getDefaultViewSettings(ctx),
} as SystemSettings;
let settings = await safeLoadJSON<SystemSettings>(
ctx.fs,
SETTINGS_FILENAME,
'Settings',
defaultSettings,
);
const version = settings.version ?? 0;
if (ctx.isAppDataSandbox || version < SYSTEM_SETTINGS_VERSION) {
settings.version = SYSTEM_SETTINGS_VERSION;
}
settings = {
...DEFAULT_SYSTEM_SETTINGS,
...(ctx.isMobile ? DEFAULT_MOBILE_SYSTEM_SETTINGS : {}),
...settings,
};
settings.globalReadSettings = {
...DEFAULT_READSETTINGS,
...(ctx.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}),
...settings.globalReadSettings,
};
migrateHighlightColorPrefs(settings.globalReadSettings);
settings.globalViewSettings = {
...getDefaultViewSettings(ctx),
...settings.globalViewSettings,
};
settings.aiSettings = {
...DEFAULT_AI_SETTINGS,
...settings.aiSettings,
};
settings.localBooksDir = await ctx.fs.getPrefix('Books');
// Coerce stale `'wikipedia'` quick-action to `'dictionary'`. The Wikipedia
// annotation tool was removed; Wikipedia is now reachable as a tab inside
// the unified dictionary popup. Without this guard, users who had set the
// quick action to wikipedia would get a no-op.
if ((settings.globalViewSettings.annotationQuickAction as string) === 'wikipedia') {
settings.globalViewSettings.annotationQuickAction = 'dictionary';
}
if (!settings.kosync.deviceId) {
settings.kosync.deviceId = uuidv4();
await saveSettings(ctx.fs, settings);
}
if (!settings.replicaDeviceId) {
settings.replicaDeviceId = uuidv4();
await saveSettings(ctx.fs, settings);
}
return settings;
}
export async function saveSettings(fs: FileSystem, settings: SystemSettings): Promise<void> {
await safeSaveJSON(fs, SETTINGS_FILENAME, 'Settings', settings);
}