Files
readest/apps/readest-app/src/services/constants.ts
T
Huang Xin 3b348c8f35 feat(sync): CRDT replica sync foundation (#4075)
* 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>
2026-05-06 15:50:15 +02:00

901 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]),
);