forked from akai/readest
3b348c8f35
* feat(sync): foundation for CRDT-based cross-device replica sync (Phase 1+2)
Adds the primitives and orchestration layer for syncing user-imported
assets (dictionaries, fonts, textures, OPDS catalogs, dict settings)
across devices via a polymorphic `replicas` table with field-level LWW
under HLC ordering. Phase 1 ships the foundation (CRDT, crypto, server
schemas, SQL migrations, push/pull endpoint); Phase 2 adds the adapter
registry, HTTP client, and sync manager. No modifications to existing
book sync — additive only.
Phase 1:
- src/libs/crdt.ts — HlcGenerator (monotonic + remote-absorption +
clock-regression-safe), per-field LWW with deviceId tiebreak,
remove-wins tombstones, reincarnation token revival.
- src/libs/crypto/{derive,encrypt,envelope,passphrase}.ts —
PBKDF2-600k key derivation (OWASP 2024), AES-GCM round-trip,
envelope {c,i,s,alg,h} with SHA-256 sidecar integrity check,
passphrase storage abstraction (web ephemeral; Tauri keychain stub).
- src/libs/replica-schemas.ts — Zod-backed allowlist (dictionary only
in PR 1), 64KiB row cap, 64-field cap, schemaVersion bounds,
filename validator.
- src/libs/replica-sync-server.ts — push batch validation
(auth + allowlist + schema + HLC ±60s skew clamp).
- src/pages/api/sync/replicas.ts — POST/GET endpoint wrapping the
Postgres crdt_merge_replica function via RPC.
- docker/volumes/db/migrations/003_add_replicas.sql — replicas table
+ replica_keys table + RLS.
- docker/volumes/db/migrations/004_crdt_merge_replica_fn.sql — atomic
per-field LWW merge function (forwards-compat preserves unknown
fields).
Phase 2:
- src/services/sync/replicaRegistry.ts — adapter contract
(core + optional BinaryCapability + LifecycleHooks per eng review).
- src/libs/replica-sync-client.ts — HTTP wrapper mapping status codes
to typed SyncError codes.
- src/services/sync/replicaSyncManager.ts — 5s debounced push,
immediate flush on visibilitychange/online, per-kind pull cursor,
remote HLC absorption.
Tests: 125 new (crdt 26, crypto 32, schemas 21, server 16, client 12,
registry 6, manager 12). Full suite 3656 passing, lint clean. Existing
book/config/note sync paths untouched.
Plan: ~/.claude/plans/vivid-orbiting-thimble.md
CEO plan: ~/.gstack/projects/readest-readest/ceo-plans/2026-05-06-replica-sync-cathedral.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): add kind="replica" path through TransferManager (Phase 3)
Adds the replica branch to the existing book-shaped transfer
infrastructure so dictionary (and future kinds) bundles can flow through
the same queue, retry, and progress UI as book uploads.
Existing book transfer paths remain unchanged. The book-side regression
suite (37 tests in transfer-store.test.ts, 37 in transfer-manager.test.ts)
all stay green.
Store (src/store/transferStore.ts):
- TransferItem gains kind: 'book' | 'replica' (default 'book' on legacy
persisted rows), replicaKind, replicaId, replicaFiles, replicaBase.
- New addReplicaTransfer(replicaKind, replicaId, displayTitle, type, opts)
with files + base in opts; auto-computes totalBytes from file sizes.
- New getReplicaTransfer(replicaKind, replicaId, type) lookup.
- getTransferByBookHash filters to kind === 'book' (defensive against
bookHash="" collisions on replica items).
- restoreTransfers fills kind: 'book' for legacy persisted rows.
Manager (src/services/transferManager.ts):
- queueReplicaUpload / queueReplicaDownload / queueReplicaDelete.
- executeTransfer dispatches by kind to the new executeReplicaTransfer
(iterates files, calls appService.uploadReplicaFile per file with
per-file progress aggregation) or the existing executeBookTransfer
(refactored out, byte-identical behavior).
- Dispatches replica-transfer-complete event on success so stores can
react (e.g., commit manifest_jsonb to the replica row).
Storage / cloud (src/libs/storage.ts, src/services/cloudService.ts):
- uploadReplicaFile bypasses the book-only File.name smuggling and
takes an explicit cfp (cloud file path).
- uploadReplicaFileToCloud / downloadReplicaFileFromCloud /
deleteReplicaBundleFromCloud orchestrate per-file operations under
${userId}/Readest/replicas/<kind>/<replicaId>/<filename>.
- replicaCloudKey() centralizes the path-construction rule.
- New CLOUD_REPLICAS_SUBDIR constant.
App service (src/services/appService.ts, src/types/system.ts):
- AppService gains uploadReplicaFile, downloadReplicaFile,
deleteReplicaBundle (file-level operations; orchestration lives in
TransferManager).
Tests: 18 new (12 in transfer-store.test.ts, 5 in transfer-manager.test.ts,
1 fixture). Full suite 3674 passing, lint clean. Existing book regression
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): dictionary replica adapter + bootstrap (Phase 4, partial)
Lands the safe-to-ship foundation of Phase 4 — adapter logic, registry
bootstrap, and SystemSettings hooks for replica sync. The on-disk
migration of legacy customDictionaries (bundleDir → content-hash id),
the live store wiring, and the Settings → Sync UI are deferred to a
follow-up PR so they can land with real-device QA.
Adapter (src/services/sync/adapters/dictionary.ts):
- dictionaryAdapter: kind='dictionary', schemaVersion=1.
- pack/unpack — only synced subset (name, kind, lang, addedAt,
unsupported{,Reason}). bundleDir / files / unavailable / deletedAt
stay per-device or are handled by the tombstone mechanism.
- BinaryCapability.enumerateFiles dispatches by bundle kind:
- mdict: mdx + mdd[] + css[]
- stardict: ifo + idx + dict + syn (skips .idx.offsets / .syn.offsets
sidecars — those are device-local indices)
- dict: dict + index
- slob: single .slob file
- primaryDictionaryFile() picks the anchor file per kind for
partialMD5 hashing.
- computeDictionaryReplicaId(partialMd5, byteSize, sortedFilenames)
produces a deterministic 32-hex content-hash id used at import time.
- 23 tests cover pack/unpack identity, kind dispatch, file enumeration,
id determinism, and per-device-field exclusion.
Bootstrap (src/services/sync/replicaBootstrap.ts):
- bootstrapReplicaAdapters() registers all known adapters once at app
start. Idempotent (safe to call multiple times). Wired into
EnvContext.tsx so the registry populates on app mount.
- 3 tests cover registration, idempotency, and the PR-1 allowlist.
SystemSettings (src/types/settings.ts, src/services/constants.ts):
- +SyncCategory = 'book' | 'progress' | 'note' | 'dictionary' — typed
union for the user-facing sync toggles. 'progress' gates the
existing book-config sync (reading progress); 'note' gates
annotations; 'book' gates book binaries + metadata; 'dictionary'
gates the new replica sync. Future replica kinds extend the union.
- +SYNC_CATEGORIES readonly array for UI iteration.
- +syncCategories: Partial<Record<SyncCategory, boolean>> — per-
category opt-in toggles in DEFAULT_SYSTEM_SETTINGS (default ON for
all four). UI panel ships in the follow-up.
- +lastSyncedAtReplicas: Record<string, string> — per-kind HLC pull
cursors (matches replicaSyncManager's CursorStore contract).
Registry type cleanup (src/services/sync/replicaRegistry.ts):
- BinaryCapability.enumerateFiles return shape: localRelPath → lfp
to match the existing TransferStore.ReplicaTransferFile convention.
Tests: 28 new (23 dict + 3 bootstrap + 2 syncCategories defaults).
Full suite 3702 passing, lint clean. Existing book/config/note sync
paths untouched.
Deferred to PR 1 follow-up (with real-device QA):
- customDictionaryStore migration: rehash legacy uniqueId() bundleDir
to content-hash id; preserve providerOrder mapping; staged
.legacy/<old-id>/ backup.
- Wire customDictionaryStore mutations through replicaSyncManager
(markDirty on add/rename/delete; pull on init).
- Settings → Sync panel: per-category toggles + last-sync timestamps.
- Sync passphrase modal: set / change / forgot flow (lazy first prompt
on encrypted-field push/pull).
- <CloudReplicaRow> in CustomDictionaries.tsx for "Download from cloud
(X MB)" affordance.
- Tauri keychain backend for sync passphrase storage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): replicaSync singleton + content-hash id at dict import (Phase 4b)
Two foundation pieces that everything UI-side will sit on top of, both
purely additive — no live store wiring, no behavior change for existing
dictionary imports.
replicaSync singleton (src/services/sync/replicaSync.ts):
- initReplicaSync({deviceId, cursorStore, hlcStore?, client?}) builds
one ReplicaSyncManager backed by an HlcGenerator with persistence
wrapped around .next()/.observe(). Idempotent (second init returns
the existing instance).
- LocalStorageHlcStore (src/libs/hlc-store.ts) snapshots the HLC
counter under 'readest_replica_hlc' so it survives restart. Falls
back silently when localStorage is unavailable (private mode, SSR);
client re-derives via the existing remote max(updated_at_ts) repair
path. InMemoryHlcStore is the test backend.
- 16 tests (9 hlc-store, 7 replicaSync) covering snapshot persistence,
restore-on-init, and idempotency.
- Wiring into EnvContext for production deferred to the follow-up that
also adds the cursor store backed by useSettingsStore.
Content-hash id at dictionary import
(src/services/dictionaries/contentId.ts):
- computeDictionaryContentId(primaryFile, filenames) wraps
computeDictionaryReplicaId(partialMd5(primary), byteSize,
sortedFilenames) — the cross-device id used as the replica_id when
the dict actually pushes/pulls.
- Wired into all four import paths in dictionaryService.ts:
- stardict primary = .ifo (small text, partialMD5 ≈ full hash)
- mdict primary = .mdx (body)
- dict primary = .dict.dz (gzipped body)
- slob primary = .slob (single-file bundle)
ImportedDictionary gains contentId?: string. Optional for backwards
compat; legacy bundles without contentId are flagged as
"needs rehash before sync" by the upcoming store-wiring follow-up.
- 6 tests cover identity determinism, byteSize sensitivity, filename-
set sensitivity, and order-independence.
Tests: 22 new (9 + 7 + 6). Full suite 3724 passing, lint clean.
Existing dictionary import flow unchanged for users — contentId is an
additional field, not a replacement for the bundleDir-based id.
Deferred to follow-up (with on-device QA):
- Production cursor store backed by useSettingsStore +
appService.saveSettings.
- EnvContext call to initReplicaSync after appService boot.
- customDictionaryStore mutation hooks → replicaSyncManager.markDirty.
- Legacy bundleDir → contentId migration with .legacy/ backup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(sync): land replica-sync design plan in repo
Moves the plan document that drove this PR's foundation work
(`~/.claude/plans/vivid-orbiting-thimble.md`) into the project tree at
`apps/readest-app/.claude/plans/` so reviewers and future contributors
can read it alongside the code without leaving the repo.
The plan went through three review passes — Codex (19 findings, all
absorbed), CEO/scope review (mode SCOPE EXPANSION; encrypted secrets
pulled forward to v1, "private-only forever" posture lock), and eng
review (FULL_REVIEW mode, 16 findings absorbed). The full review trail
lives in the file's `## GSTACK REVIEW REPORT` section at the bottom.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(crdt): point README at in-repo plan path
Now that vivid-orbiting-thimble.md lives at
apps/readest-app/.claude/plans/, the README link should point there
rather than at the home-dir copy that no longer exists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(sync): rename src/libs files to camelCase per project convention
Test files renamed in lockstep. Imports + comment references updated
across cloudService, storage, transferManager, replicaSync,
replicaSyncManager, /api/sync/replicas, and all four test files. No
behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
901 lines
19 KiB
TypeScript
901 lines
19 KiB
TypeScript
import {
|
||
AnnotatorConfig,
|
||
BookFont,
|
||
BookLanguage,
|
||
BookLayout,
|
||
BookSearchConfig,
|
||
BookStyle,
|
||
HighlightColor,
|
||
NoteExportConfig,
|
||
ParagraphModeConfig,
|
||
ReadingRulerColor,
|
||
ScreenConfig,
|
||
TranslatorConfig,
|
||
TTSConfig,
|
||
ViewConfig,
|
||
ViewSettings,
|
||
ViewSettingsConfig,
|
||
} from '@/types/book';
|
||
import {
|
||
HardcoverSettings,
|
||
KOSyncSettings,
|
||
LibraryGroupByType,
|
||
LibrarySortByType,
|
||
ReadSettings,
|
||
ReadwiseSettings,
|
||
SystemSettings,
|
||
} from '@/types/settings';
|
||
import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/quota';
|
||
import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config';
|
||
import { stubTranslation as _ } from '@/utils/misc';
|
||
import { DEFAULT_AI_SETTINGS } from './ai/constants';
|
||
|
||
export const DATA_SUBDIR = 'Readest';
|
||
export const LOCAL_BOOKS_SUBDIR = `${DATA_SUBDIR}/Books`;
|
||
export const CLOUD_BOOKS_SUBDIR = `${DATA_SUBDIR}/Books`;
|
||
export const CLOUD_REPLICAS_SUBDIR = `${DATA_SUBDIR}/Replicas`;
|
||
export const LOCAL_FONTS_SUBDIR = `${DATA_SUBDIR}/Fonts`;
|
||
export const LOCAL_IMAGES_SUBDIR = `${DATA_SUBDIR}/Images`;
|
||
export const LOCAL_DICTIONARIES_SUBDIR = `${DATA_SUBDIR}/Dictionaries`;
|
||
|
||
export const SETTINGS_FILENAME = 'settings.json';
|
||
|
||
export const SUPPORTED_BOOK_EXTS = [
|
||
'epub',
|
||
'mobi',
|
||
'azw',
|
||
'azw3',
|
||
'fb2',
|
||
'zip',
|
||
'cbz',
|
||
'pdf',
|
||
'txt',
|
||
];
|
||
export const BOOK_ACCEPT_FORMATS = SUPPORTED_BOOK_EXTS.map((ext) => `.${ext}`).join(', ');
|
||
export const BOOK_UNGROUPED_NAME = '';
|
||
export const BOOK_UNGROUPED_ID = '';
|
||
|
||
export const SUPPORTED_IMAGE_EXTS = ['png', 'jpg', 'jpeg'];
|
||
export const IMAGE_ACCEPT_FORMATS = SUPPORTED_IMAGE_EXTS.map((ext) => `.${ext}`).join(', ');
|
||
|
||
export const DEFAULT_KOSYNC_SETTINGS = {
|
||
serverUrl: 'https://sync.koreader.rocks/', // https://kosync.ak-team.com:3042/
|
||
username: '',
|
||
userkey: '',
|
||
deviceId: '',
|
||
deviceName: '',
|
||
checksumMethod: 'binary',
|
||
strategy: 'prompt',
|
||
enabled: false,
|
||
} as KOSyncSettings;
|
||
|
||
export const READWISE_API_BASE_URL = 'https://readwise.io/api/v2';
|
||
|
||
export const DEFAULT_READWISE_SETTINGS = {
|
||
enabled: false,
|
||
accessToken: '',
|
||
lastSyncedAt: 0,
|
||
} as ReadwiseSettings;
|
||
|
||
export const DEFAULT_HARDCOVER_SETTINGS = {
|
||
enabled: false,
|
||
accessToken: '',
|
||
lastSyncedAt: 0,
|
||
} as HardcoverSettings;
|
||
|
||
export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||
keepLogin: false,
|
||
autoUpload: true,
|
||
alwaysOnTop: false,
|
||
openBookInNewWindow: true,
|
||
alwaysShowStatusBar: false,
|
||
alwaysInForeground: false,
|
||
autoCheckUpdates: true,
|
||
screenWakeLock: false,
|
||
screenBrightness: -1, // -1~100, -1 for system default
|
||
autoScreenBrightness: true,
|
||
openLastBooks: false,
|
||
lastOpenBooks: [],
|
||
autoImportBooksOnOpen: false,
|
||
telemetryEnabled: true,
|
||
discordRichPresenceEnabled: false,
|
||
libraryViewMode: 'grid',
|
||
librarySortBy: LibrarySortByType.Updated,
|
||
librarySortAscending: false,
|
||
libraryGroupBy: LibraryGroupByType.Group,
|
||
libraryCoverFit: 'crop',
|
||
libraryAutoColumns: true,
|
||
libraryColumns: 6,
|
||
|
||
metadataSeriesCollapsed: false,
|
||
metadataOthersCollapsed: false,
|
||
metadataDescriptionCollapsed: false,
|
||
|
||
customDictionaries: [],
|
||
dictionarySettings: {
|
||
providerOrder: ['builtin:wiktionary', 'builtin:wikipedia'],
|
||
providerEnabled: {
|
||
'builtin:wiktionary': true,
|
||
'builtin:wikipedia': true,
|
||
},
|
||
},
|
||
|
||
kosync: DEFAULT_KOSYNC_SETTINGS,
|
||
readwise: DEFAULT_READWISE_SETTINGS,
|
||
hardcover: DEFAULT_HARDCOVER_SETTINGS,
|
||
aiSettings: DEFAULT_AI_SETTINGS,
|
||
|
||
lastSyncedAtBooks: 0,
|
||
lastSyncedAtConfigs: 0,
|
||
lastSyncedAtNotes: 0,
|
||
lastSyncedAtReplicas: {},
|
||
syncCategories: {
|
||
book: true,
|
||
progress: true,
|
||
note: true,
|
||
dictionary: true,
|
||
},
|
||
};
|
||
|
||
export const DEFAULT_MOBILE_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||
libraryColumns: 3,
|
||
};
|
||
|
||
export const HIGHLIGHT_COLOR_HEX: Record<HighlightColor, string> = {
|
||
red: '#f87171', // red-400
|
||
yellow: '#facc15', // yellow-400
|
||
green: '#4ade80', // green-400
|
||
blue: '#60a5fa', // blue-400
|
||
violet: '#a78bfa', // violet-400
|
||
};
|
||
|
||
export const READING_RULER_COLORS: Record<ReadingRulerColor, string> = {
|
||
transparent: '#00000000',
|
||
yellow: '#facc15',
|
||
green: '#4ade80',
|
||
blue: '#60a5fa',
|
||
rose: '#fb7185',
|
||
};
|
||
|
||
export const DEFAULT_READSETTINGS: ReadSettings = {
|
||
sideBarWidth: '15%',
|
||
isSideBarPinned: true,
|
||
notebookWidth: '25%',
|
||
isNotebookPinned: false,
|
||
notebookActiveTab: 'notes',
|
||
autohideCursor: true,
|
||
translationProvider: 'deepl',
|
||
translateTargetLang: 'EN',
|
||
|
||
customThemes: [],
|
||
highlightStyle: 'highlight',
|
||
highlightStyles: {
|
||
highlight: 'yellow',
|
||
underline: 'green',
|
||
squiggly: 'blue',
|
||
},
|
||
customHighlightColors: HIGHLIGHT_COLOR_HEX,
|
||
userHighlightColors: [],
|
||
defaultHighlightLabels: {},
|
||
customTtsHighlightColors: [],
|
||
};
|
||
|
||
export const DEFAULT_MOBILE_READSETTINGS: Partial<ReadSettings> = {
|
||
sideBarWidth: '25%',
|
||
isSideBarPinned: false,
|
||
};
|
||
|
||
export const DEFAULT_BOOK_FONT: BookFont = {
|
||
serifFont: 'Bitter',
|
||
sansSerifFont: 'Roboto',
|
||
monospaceFont: 'Consolas',
|
||
defaultFont: 'Serif',
|
||
defaultCJKFont: 'LXGW WenKai GB Screen',
|
||
defaultFontSize: 16,
|
||
minimumFontSize: 8,
|
||
fontWeight: 400,
|
||
};
|
||
|
||
export const DEFAULT_BOOK_LAYOUT: BookLayout = {
|
||
marginTopPx: 44,
|
||
marginBottomPx: 44,
|
||
marginLeftPx: 16,
|
||
marginRightPx: 16,
|
||
compactMarginTopPx: 16,
|
||
compactMarginBottomPx: 16,
|
||
compactMarginLeftPx: 16,
|
||
compactMarginRightPx: 16,
|
||
gapPercent: 5,
|
||
scrolled: false,
|
||
noContinuousScroll: false,
|
||
disableClick: false,
|
||
fullscreenClickArea: false,
|
||
swapClickArea: false,
|
||
disableDoubleClick: false,
|
||
volumeKeysToFlip: false,
|
||
maxColumnCount: 2,
|
||
maxInlineSize: getDefaultMaxInlineSize(),
|
||
maxBlockSize: getDefaultMaxBlockSize(),
|
||
writingMode: 'auto',
|
||
vertical: false,
|
||
rtl: false,
|
||
scrollingOverlap: 0,
|
||
allowScript: false,
|
||
hideScrollbar: false,
|
||
};
|
||
|
||
export const DEFAULT_BOOK_LANGUAGE: BookLanguage = {
|
||
replaceQuotationMarks: true,
|
||
convertChineseVariant: 'none',
|
||
};
|
||
|
||
export const DEFAULT_BOOK_STYLE: BookStyle = {
|
||
zoomLevel: 100,
|
||
paragraphMargin: 0.6,
|
||
lineHeight: 1.4,
|
||
wordSpacing: 0,
|
||
letterSpacing: 0,
|
||
textIndent: 0,
|
||
fullJustification: true,
|
||
hyphenation: true,
|
||
theme: 'light',
|
||
backgroundTextureId: 'none',
|
||
backgroundOpacity: 0.6,
|
||
backgroundSize: 'cover',
|
||
highlightOpacity: 0.4,
|
||
codeHighlighting: false,
|
||
codeLanguage: 'auto-detect',
|
||
userStylesheet: '',
|
||
userUIStylesheet: '',
|
||
|
||
overrideFont: false,
|
||
overrideLayout: false,
|
||
overrideColor: false,
|
||
useBookLayout: false,
|
||
|
||
zoomMode: 'fit-page',
|
||
spreadMode: 'auto',
|
||
keepCoverSpread: true,
|
||
invertImgColorInDark: false,
|
||
applyThemeToPDF: false,
|
||
};
|
||
|
||
export const DEFAULT_MOBILE_VIEW_SETTINGS: Partial<ViewSettings> = {
|
||
fullJustification: false,
|
||
animated: true,
|
||
defaultFont: 'Sans-serif',
|
||
marginBottomPx: 16,
|
||
disableDoubleClick: true,
|
||
spreadMode: 'none',
|
||
};
|
||
|
||
export const DEFAULT_CJK_VIEW_SETTINGS: Partial<ViewSettings> = {
|
||
fullJustification: true,
|
||
textIndent: 2,
|
||
paragraphMargin: 1,
|
||
lineHeight: 1.6,
|
||
};
|
||
|
||
export const DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS: Partial<ViewSettings> = {
|
||
overrideColor: false,
|
||
};
|
||
|
||
export const DEFAULT_EINK_VIEW_SETTINGS: Partial<ViewSettings> = {
|
||
isEink: true,
|
||
animated: false,
|
||
volumeKeysToFlip: true,
|
||
};
|
||
|
||
export const DEFAULT_PARAGRAPH_MODE_CONFIG: ParagraphModeConfig = {
|
||
enabled: false,
|
||
};
|
||
|
||
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
|
||
sideBarTab: 'toc',
|
||
uiLanguage: '',
|
||
sortedTOC: false,
|
||
|
||
doubleBorder: false,
|
||
borderColor: 'red',
|
||
|
||
showHeader: true,
|
||
showFooter: true,
|
||
showBarsOnScroll: false,
|
||
showRemainingTime: false,
|
||
showRemainingPages: false,
|
||
showProgressInfo: true,
|
||
showCurrentTime: false,
|
||
showCurrentBatteryStatus: false,
|
||
showBatteryPercentage: true,
|
||
use24HourClock: false,
|
||
tapToToggleFooter: false,
|
||
showMarginsOnScroll: false,
|
||
showPaginationButtons: false,
|
||
progressStyle: 'fraction',
|
||
progressInfoMode: 'all',
|
||
|
||
animated: false,
|
||
isEink: false,
|
||
isColorEink: false,
|
||
|
||
paragraphMode: DEFAULT_PARAGRAPH_MODE_CONFIG,
|
||
|
||
readingRulerEnabled: false,
|
||
readingRulerLines: 2,
|
||
readingRulerPosition: 33,
|
||
readingRulerOpacity: 0.5,
|
||
readingRulerColor: 'transparent',
|
||
};
|
||
|
||
export const DEFAULT_TTS_CONFIG: TTSConfig = {
|
||
ttsRate: 1.3,
|
||
ttsVoice: '',
|
||
ttsLocation: '',
|
||
showTTSBar: false,
|
||
ttsHighlightOptions: { style: 'highlight', color: '#808080' },
|
||
ttsMediaMetadata: 'sentence',
|
||
};
|
||
|
||
export const DEFAULT_TRANSLATOR_CONFIG: TranslatorConfig = {
|
||
translationEnabled: false,
|
||
translationProvider: 'deepl',
|
||
translateTargetLang: '',
|
||
showTranslateSource: true,
|
||
ttsReadAloudText: 'both',
|
||
};
|
||
|
||
export const DEFAULT_NOTE_EXPORT_CONFIG: NoteExportConfig = {
|
||
includeTitle: true,
|
||
includeAuthor: true,
|
||
includeDate: true,
|
||
includeChapterTitles: true,
|
||
includeQuotes: true,
|
||
includeNotes: true,
|
||
includePageNumber: true,
|
||
includeTimestamp: false,
|
||
includeChapterSeparator: false,
|
||
noteSeparator: '\n\n',
|
||
useCustomTemplate: false,
|
||
customTemplate: '',
|
||
exportAsPlainText: false,
|
||
};
|
||
|
||
export const DEFAULT_ANNOTATOR_CONFIG: AnnotatorConfig = {
|
||
enableAnnotationQuickActions: true,
|
||
annotationQuickAction: null,
|
||
copyToNotebook: false,
|
||
noteExportConfig: DEFAULT_NOTE_EXPORT_CONFIG,
|
||
};
|
||
|
||
export const DEFAULT_SCREEN_CONFIG: ScreenConfig = {
|
||
screenOrientation: 'auto',
|
||
};
|
||
|
||
export const DEFAULT_BOOK_SEARCH_CONFIG: BookSearchConfig = {
|
||
scope: 'book',
|
||
matchCase: false,
|
||
matchWholeWords: false,
|
||
matchDiacritics: false,
|
||
};
|
||
|
||
export const DEFAULT_VIEW_SETTINGS_CONFIG: ViewSettingsConfig = {
|
||
isGlobal: true,
|
||
};
|
||
|
||
export const SYSTEM_SETTINGS_VERSION = 1;
|
||
|
||
export const SERIF_FONTS = [
|
||
'Bitter',
|
||
'Literata',
|
||
'Merriweather',
|
||
'Roboto Slab',
|
||
'Vollkorn',
|
||
'PT Serif',
|
||
'Georgia',
|
||
'Times New Roman',
|
||
];
|
||
|
||
export const NON_FREE_FONTS = ['Georgia', 'Times New Roman'];
|
||
|
||
export const CJK_SERIF_FONTS = [
|
||
_('LXGW WenKai GB Screen'),
|
||
_('LXGW WenKai TC'),
|
||
_('GuanKiapTsingKhai-T'),
|
||
_('Source Han Serif CN'),
|
||
_('Huiwen-MinchoGBK'),
|
||
_('KingHwa_OldSong'),
|
||
];
|
||
|
||
export const CJK_SANS_SERIF_FONTS = ['Noto Sans SC', 'Noto Sans TC'];
|
||
|
||
export const SANS_SERIF_FONTS = ['Roboto', 'Noto Sans', 'Open Sans', 'PT Sans', 'Helvetica'];
|
||
|
||
export const MONOSPACE_FONTS = [
|
||
'Fira Code',
|
||
'Consolas',
|
||
'Courier New',
|
||
'Lucida Console',
|
||
'PT Mono',
|
||
];
|
||
|
||
export const FALLBACK_FONTS = ['MiSans L3'];
|
||
|
||
export const WINDOWS_FONTS = [
|
||
'Arial',
|
||
'Arial Black',
|
||
'Bahnschrift',
|
||
'Calibri',
|
||
'Cambria',
|
||
'Cambria Math',
|
||
'Candara',
|
||
'Comic Sans MS',
|
||
'Consolas',
|
||
'Constantia',
|
||
'Corbel',
|
||
'Courier New',
|
||
'Ebrima',
|
||
'FangSong',
|
||
'Franklin Gothic Medium',
|
||
'Gabriola',
|
||
'Gadugi',
|
||
'Georgia',
|
||
'Heiti',
|
||
'HoloLens MDL2 Assets',
|
||
'Impact',
|
||
'Ink Free',
|
||
'Javanese Text',
|
||
'KaiTi',
|
||
'Leelawadee UI',
|
||
'Lucida Console',
|
||
'Lucida Sans Unicode',
|
||
'LXGW WenKai GB Screen',
|
||
'LXGW WenKai TC',
|
||
'Malgun Gothic',
|
||
'Marlett',
|
||
'Microsoft Himalaya',
|
||
'Microsoft JhengHei',
|
||
'Microsoft New Tai Lue',
|
||
'Microsoft PhagsPa',
|
||
'Microsoft Sans Serif',
|
||
'Microsoft Tai Le',
|
||
'Microsoft YaHei',
|
||
'Microsoft Yi Baiti',
|
||
'MingLiU',
|
||
'MingLiU-ExtB',
|
||
'Mongolian Baiti',
|
||
'MS Gothic',
|
||
'MS Mincho',
|
||
'MV Boli',
|
||
'Myanmar Text',
|
||
'Nirmala UI',
|
||
'Noto Serif JP',
|
||
'NSimSun',
|
||
'Palatino Linotype',
|
||
'PMingLiU',
|
||
'Segoe MDL2 Assets',
|
||
'Segoe Print',
|
||
'Segoe Script',
|
||
'Segoe UI',
|
||
'Segoe UI Historic',
|
||
'Segoe UI Emoji',
|
||
'Segoe UI Symbol',
|
||
'SimHei',
|
||
'SimSun',
|
||
'SimSun-ExtB',
|
||
'Sitka',
|
||
'Sylfaen',
|
||
'Tahoma',
|
||
'Times New Roman',
|
||
'Trebuchet MS',
|
||
'Verdana',
|
||
'XiHeiti',
|
||
'Yu Gothic',
|
||
'Yu Mincho',
|
||
];
|
||
|
||
export const MACOS_FONTS = [
|
||
'American Typewriter',
|
||
'Andale Mono',
|
||
'Arial',
|
||
'Arial Black',
|
||
'Arial Narrow',
|
||
'Arial Rounded MT Bold',
|
||
'Arial Unicode MS',
|
||
'Avenir',
|
||
'Avenir Next',
|
||
'Avenir Next Condensed',
|
||
'Baskerville',
|
||
'BiauKai',
|
||
'Big Caslon',
|
||
'Bodoni 72',
|
||
'Bodoni 72 Oldstyle',
|
||
'Bodoni 72 Smallcaps',
|
||
'Bradley Hand',
|
||
'Brush Script MT',
|
||
'Chalkboard',
|
||
'Chalkboard SE',
|
||
'Chalkduster',
|
||
'Charter',
|
||
'Cochin',
|
||
'Comic Sans MS',
|
||
'Copperplate',
|
||
'Courier',
|
||
'Courier New',
|
||
'Didot',
|
||
'DIN Alternate',
|
||
'DIN Condensed',
|
||
'FangSong',
|
||
'Futura',
|
||
'Geneva',
|
||
'Georgia',
|
||
'Gill Sans',
|
||
'Heiti SC',
|
||
'Heiti TC',
|
||
'Helvetica',
|
||
'Helvetica Neue',
|
||
'Herculanum',
|
||
'Hiragino Sans',
|
||
'Hiragino Mincho',
|
||
'Hoefler Text',
|
||
'Impact',
|
||
'Kaiti SC',
|
||
'Kaiti TC',
|
||
'Kozuka Gothic Pro',
|
||
'Kozuka Mincho Pro',
|
||
'Lucida Grande',
|
||
'Luminari',
|
||
'LXGW WenKai GB Screen',
|
||
'LXGW WenKai TC',
|
||
'Marker Felt',
|
||
'Menlo',
|
||
'Microsoft Sans Serif',
|
||
'Monaco',
|
||
'Noteworthy',
|
||
'Noto Serif JP',
|
||
'Optima',
|
||
'Palatino',
|
||
'Papyrus',
|
||
'PingFang HK',
|
||
'PingFang SC',
|
||
'PingFang TC',
|
||
'Phosphate',
|
||
'Rockwell',
|
||
'Savoye LET',
|
||
'SignPainter',
|
||
'Skia',
|
||
'Snell Roundhand',
|
||
'Songti SC',
|
||
'Songti TC',
|
||
'STFangsong',
|
||
'STKaiti',
|
||
'STSong',
|
||
'STXihei',
|
||
'Tahoma',
|
||
'Times',
|
||
'Times New Roman',
|
||
'Trattatello',
|
||
'Trebuchet MS',
|
||
'Verdana',
|
||
'XiHeiti',
|
||
'Yu Mincho',
|
||
'Zapfino',
|
||
];
|
||
|
||
export const LINUX_FONTS = [
|
||
'Arial',
|
||
'Cantarell',
|
||
'Comic Sans MS',
|
||
'Courier New',
|
||
'DejaVu Sans',
|
||
'DejaVu Sans Mono',
|
||
'DejaVu Serif',
|
||
'Droid Sans',
|
||
'Droid Sans Mono',
|
||
'FangSong',
|
||
'FreeMono',
|
||
'FreeSans',
|
||
'FreeSerif',
|
||
'Georgia',
|
||
'Heiti',
|
||
'Impact',
|
||
'Kaiti',
|
||
'Liberation Mono',
|
||
'Liberation Sans',
|
||
'Liberation Serif',
|
||
'LXGW WenKai GB Screen',
|
||
'LXGW WenKai TC',
|
||
'Noto Mono',
|
||
'Noto Sans',
|
||
'Noto Sans JP',
|
||
'Noto Sans CJK SC',
|
||
'Noto Sans CJK TC',
|
||
'Noto Serif',
|
||
'Noto Serif JP',
|
||
'Noto Serif CJK SC',
|
||
'Noto Serif CJK TC',
|
||
'Open Sans',
|
||
'Poppins',
|
||
'Sazanami Gothic',
|
||
'Sazanami Mincho',
|
||
'Source Han Sans',
|
||
'Source Han Serif',
|
||
'Times New Roman',
|
||
'Ubuntu',
|
||
'Ubuntu Mono',
|
||
'WenQuanYi Micro Hei',
|
||
'WenQuanYi Zen Hei',
|
||
'XiHeiti',
|
||
];
|
||
|
||
export const IOS_FONTS = [
|
||
'Avenir',
|
||
'Avenir Next',
|
||
'Courier',
|
||
'Courier New',
|
||
'FangSong',
|
||
'Georgia',
|
||
'Heiti',
|
||
'Helvetica',
|
||
'Helvetica Neue',
|
||
'Hiragino Mincho',
|
||
'Hiragino Sans',
|
||
'Kaiti',
|
||
'LXGW WenKai GB Screen',
|
||
'LXGW WenKai TC',
|
||
'Palatino',
|
||
'PingFang SC',
|
||
'PingFang TC',
|
||
'San Francisco',
|
||
'SF Pro Display',
|
||
'SF Pro Rounded',
|
||
'SF Pro Text',
|
||
'Songti',
|
||
'Times New Roman',
|
||
'Verdana',
|
||
'XiHeiti',
|
||
];
|
||
|
||
export const ANDROID_FONTS = [
|
||
'Arial',
|
||
'Droid Sans',
|
||
'Droid Serif',
|
||
'FangSong',
|
||
'FZLanTingHei',
|
||
'Georgia',
|
||
'Heiti',
|
||
'Kaiti',
|
||
'LXGW WenKai GB Screen',
|
||
'LXGW WenKai TC',
|
||
'Noto Sans',
|
||
'Noto Sans CJK',
|
||
'Noto Sans JP',
|
||
'Noto Serif',
|
||
'Noto Serif CJK',
|
||
'Noto Serif JP',
|
||
'PingFang SC',
|
||
'Roboto',
|
||
'Source Han Sans',
|
||
'Source Han Serif',
|
||
'STHeiti',
|
||
'STSong',
|
||
'Tahoma',
|
||
'Verdana',
|
||
'XiHeiti',
|
||
];
|
||
|
||
export const CJK_EXCLUDE_PATTENS = new RegExp(
|
||
['AlBayan', 'STIX', 'Kailasa', 'ITCTT', 'Luminari', 'Myanmar'].join('|'),
|
||
'i',
|
||
);
|
||
export const CJK_FONTS_PATTENS = new RegExp(
|
||
[
|
||
'CJK',
|
||
'TC$',
|
||
'SC$',
|
||
'HK',
|
||
'JP',
|
||
'TW',
|
||
'Sim',
|
||
'Kai',
|
||
'Hei',
|
||
'Yan',
|
||
'Min',
|
||
'Khai',
|
||
'Yuan',
|
||
'Song',
|
||
'Ming',
|
||
'FZ',
|
||
'Huiwen',
|
||
'KingHwa',
|
||
'FangZheng',
|
||
'WenQuanYi',
|
||
'PingFang',
|
||
'Hiragino',
|
||
'Meiryo',
|
||
'Source\\s?Han',
|
||
'Yu\\s?Gothic',
|
||
'Yu\\s?Mincho',
|
||
'Mincho',
|
||
'Nanum',
|
||
'Malgun',
|
||
'Gulim',
|
||
'Dotum',
|
||
'Batang',
|
||
'Gungsuh',
|
||
'OPPO sans',
|
||
'MiSans',
|
||
'Fallback',
|
||
].join('|'),
|
||
'i',
|
||
);
|
||
|
||
export const BOOK_IDS_SEPARATOR = '+';
|
||
|
||
export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web';
|
||
|
||
export const READEST_WEB_BASE_URL = 'https://web.readest.com';
|
||
export const READEST_NODE_BASE_URL = 'https://node.readest.com';
|
||
|
||
export const SHARE_BASE_URL = `${READEST_WEB_BASE_URL}/s`;
|
||
export const SHARE_EXPIRATION_DAYS = [1, 3, 7] as const;
|
||
export const SHARE_DEFAULT_EXPIRATION_DAYS = 3;
|
||
export const SHARE_MAX_PER_USER = 50;
|
||
export const SHARE_TOKEN_LENGTH = 22;
|
||
export const SHARE_PRESIGN_TTL_SECONDS = 300;
|
||
export const SHARE_CFI_MAX_LENGTH = 512;
|
||
|
||
const LATEST_DOWNLOAD_BASE_URL = 'https://download.readest.com/releases';
|
||
|
||
export const READEST_UPDATER_FILE = `${LATEST_DOWNLOAD_BASE_URL}/latest.json`;
|
||
|
||
export const READEST_CHANGELOG_FILE = `${LATEST_DOWNLOAD_BASE_URL}/release-notes.json`;
|
||
|
||
export const READEST_PUBLIC_STORAGE_BASE_URL = 'https://storage.readest.com';
|
||
|
||
export const READEST_OPDS_USER_AGENT = 'Readest/1.0 (OPDS Browser)';
|
||
|
||
export const SYNC_PROGRESS_INTERVAL_SEC = 3;
|
||
export const SYNC_NOTES_INTERVAL_SEC = 5;
|
||
export const SYNC_BOOKS_INTERVAL_SEC = 5;
|
||
export const CHECK_UPDATE_INTERVAL_SEC = 24 * 60 * 60;
|
||
|
||
export const MAX_ZOOM_LEVEL = 500;
|
||
export const MIN_ZOOM_LEVEL = 50;
|
||
export const ZOOM_STEP = 10;
|
||
|
||
export const SHOW_UNREAD_STATUS_BADGE = false;
|
||
|
||
export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
|
||
free: 500 * 1024 * 1024,
|
||
plus: 5 * 1024 * 1024 * 1024,
|
||
pro: 20 * 1024 * 1024 * 1024,
|
||
purchase: 0,
|
||
};
|
||
|
||
export const DEFAULT_DAILY_TRANSLATION_QUOTA: UserDailyTranslationQuota = {
|
||
free: 10 * 1024,
|
||
plus: 100 * 1024,
|
||
pro: 500 * 1024,
|
||
purchase: 0,
|
||
};
|
||
|
||
export const DOUBLE_CLICK_INTERVAL_THRESHOLD_MS = 250;
|
||
export const DISABLE_DOUBLE_CLICK_ON_MOBILE = true;
|
||
export const LONG_HOLD_THRESHOLD = 500;
|
||
|
||
export const SIZE_PER_LOC = 1500;
|
||
export const SIZE_PER_TIME_UNIT = 1600;
|
||
|
||
export const CUSTOM_THEME_TEMPLATES = [
|
||
{
|
||
light: {
|
||
fg: '#2b2b2b',
|
||
bg: '#f3f3f3',
|
||
primary: '#3c5a72',
|
||
},
|
||
dark: {
|
||
fg: '#d0d0d0',
|
||
bg: '#1a1c1f',
|
||
primary: '#486e8a',
|
||
},
|
||
},
|
||
{
|
||
light: {
|
||
fg: '#3f2f3c',
|
||
bg: '#f5ecf8',
|
||
primary: '#7b5291',
|
||
},
|
||
dark: {
|
||
fg: '#d6cadd',
|
||
bg: '#3a2c3d',
|
||
primary: '#bda0cc',
|
||
},
|
||
},
|
||
{
|
||
light: {
|
||
fg: '#2b2b2b',
|
||
bg: '#defcd9',
|
||
primary: '#00796b',
|
||
},
|
||
dark: {
|
||
fg: '#c8e6c9',
|
||
bg: '#273c33',
|
||
primary: '#26a69a',
|
||
},
|
||
},
|
||
];
|
||
|
||
export const MIGHT_BE_RTL_LANGS = [
|
||
'zh',
|
||
'ja',
|
||
'ko',
|
||
'ar',
|
||
'he',
|
||
'fa',
|
||
'ur',
|
||
'dv',
|
||
'ps',
|
||
'sd',
|
||
'yi',
|
||
'',
|
||
];
|
||
|
||
export const TRANSLATED_LANGS = {
|
||
en: 'English',
|
||
fr: 'Français',
|
||
de: 'Deutsch',
|
||
nl: 'Nederlands',
|
||
it: 'Italiano',
|
||
ja: '日本語',
|
||
ko: '한국어',
|
||
es: 'Español',
|
||
pt: 'Português',
|
||
'pt-BR': 'Português (Brasil)',
|
||
ru: 'Русский',
|
||
he: 'עברית',
|
||
ar: 'العربية',
|
||
fa: 'فارسی',
|
||
el: 'Ελληνικά',
|
||
uk: 'Українська',
|
||
pl: 'Polski',
|
||
sl: 'Slovenščina',
|
||
tr: 'Türkçe',
|
||
hi: 'हिन्दी',
|
||
id: 'Indonesia',
|
||
vi: 'Tiếng Việt',
|
||
th: 'ภาษาไทย',
|
||
ms: 'Melayu',
|
||
bo: 'བོད་སྐད་',
|
||
bn: 'বাংলা',
|
||
ta: 'தமிழ்',
|
||
si: 'සිංහල',
|
||
'zh-CN': '简体中文',
|
||
'zh-TW': '正體中文',
|
||
ro: 'Română',
|
||
hu: 'Magyar',
|
||
uz: 'Oʻzbek',
|
||
};
|
||
|
||
export const TRANSLATOR_LANGS: Record<string, string> = {
|
||
...TRANSLATED_LANGS,
|
||
nb: 'Bokmål',
|
||
sv: 'Svenska',
|
||
fi: 'Suomi',
|
||
da: 'Dansk',
|
||
cs: 'Čeština',
|
||
km: 'ខ្មែរ',
|
||
ro: 'Română',
|
||
bg: 'Български',
|
||
hr: 'Hrvatski',
|
||
lt: 'Lietuvių',
|
||
sl: 'Slovenščina',
|
||
sk: 'Slovenčina',
|
||
fa: 'فارسی',
|
||
};
|
||
|
||
export const SUPPORTED_LANGS: Record<string, string> = { ...TRANSLATED_LANGS, zh: '中文' };
|
||
|
||
export const SUPPORTED_LANGNAMES: Record<string, string> = Object.fromEntries(
|
||
Object.entries(SUPPORTED_LANGS).map(([code, name]) => [name, code]),
|
||
);
|