forked from akai/readest
cbdc3b8f52
* 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>
175 lines
5.8 KiB
TypeScript
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);
|
|
}
|