From 0fba5b705474d96c8cb30e44b3cc75b5e129995e Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 18 May 2026 00:23:46 +0800 Subject: [PATCH] feat(config): version book config schema (#4208) --- apps/readest-app/docs/book-config.md | 57 +++++++++++++++ .../src/__tests__/utils/serializer.test.ts | 73 +++++++++++++++++++ .../readest-app/src/services/backupService.ts | 3 +- apps/readest-app/src/services/bookService.ts | 12 +-- apps/readest-app/src/types/book.ts | 3 + apps/readest-app/src/utils/book.ts | 9 ++- apps/readest-app/src/utils/serializer.ts | 17 ++++- 7 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 apps/readest-app/docs/book-config.md create mode 100644 apps/readest-app/src/__tests__/utils/serializer.test.ts diff --git a/apps/readest-app/docs/book-config.md b/apps/readest-app/docs/book-config.md new file mode 100644 index 00000000..28377bac --- /dev/null +++ b/apps/readest-app/docs/book-config.md @@ -0,0 +1,57 @@ +# Book Config JSON + +Each imported book may have a per-book config file at: + +```text +/config.json +``` + +The file is written under the `Books` storage root and uses the same camelCase +keys as the TypeScript `BookConfig` type in `src/types/book.ts`. + +## Version + +`schemaVersion` identifies the raw `config.json` schema written by Readest. +Current version: + +```json +{ + "schemaVersion": 1 +} +``` + +Configs written before this field existed are treated as legacy configs and are +loaded as the current version. New writes must include `schemaVersion`. + +`schemaVersion` is only for the raw disk JSON file. The cloud sync +`book_configs` table is a normalized sync projection and does not mirror this +field. + +## Stable Fields + +Version 1 documents these fields as the supported integration surface: + +- `schemaVersion`: raw config schema version. +- `bookHash`, `metaHash`: book identity when present. +- `progress`: current page tuple, `[current, total]`. +- `location`: current reading location as CFI. +- `xpointer`: current reading location as XPointer for KOReader interoperability. +- `booknotes`: bookmarks, annotations, and excerpts. +- `rsvpPosition`: RSVP reading position. +- `updatedAt`: last config update timestamp in milliseconds. + +`viewSettings` and `searchConfig` are persisted app state. They are partial +overrides and are merged with defaults when Readest loads the config. + +## Notes and XPointer Fields + +`BookConfig.xpointer` is the current reading location. It was not renamed by +KOReader annotation sync work. + +For notes in `booknotes`, Readest stores note ranges with: + +- `xpointer0`: start XPointer. +- `xpointer1`: end XPointer, when available. + +This distinction matters for integrations reading raw config files: progress +uses `xpointer`, while note ranges use `xpointer0` and `xpointer1`. diff --git a/apps/readest-app/src/__tests__/utils/serializer.test.ts b/apps/readest-app/src/__tests__/utils/serializer.test.ts new file mode 100644 index 00000000..7c53ca30 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/serializer.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + BOOK_CONFIG_SCHEMA_VERSION, + BookConfig, + BookSearchConfig, + ViewSettings, +} from '@/types/book'; +import { deserializeConfig, serializeConfig, serializeRawConfig } from '@/utils/serializer'; + +const globalViewSettings = { + zoomLevel: 100, + scrolled: false, +} as ViewSettings; + +const defaultSearchConfig = { + scope: 'book', + matchCase: false, + matchWholeWords: false, + matchDiacritics: false, +} as BookSearchConfig; + +describe('BookConfig serialization', () => { + it('writes schemaVersion to settings-aware config JSON using camelCase', () => { + const config: BookConfig = { + updatedAt: 123, + rsvpPosition: { cfi: 'epubcfi(/6/4!/4/2)', wordText: 'hello' }, + viewSettings: { zoomLevel: 120 }, + searchConfig: { query: 'alice' }, + }; + + const serialized = serializeConfig(config, globalViewSettings, defaultSearchConfig); + const parsed = JSON.parse(serialized); + + expect(parsed.schemaVersion).toBe(BOOK_CONFIG_SCHEMA_VERSION); + expect(parsed.schema_version).toBeUndefined(); + expect(parsed.rsvpPosition).toEqual({ cfi: 'epubcfi(/6/4!/4/2)', wordText: 'hello' }); + expect(parsed.viewSettings).toEqual({ zoomLevel: 120 }); + expect(parsed.searchConfig).toEqual({ query: 'alice' }); + }); + + it('writes schemaVersion to raw config JSON without mutating the caller object', () => { + const config: Partial = { + updatedAt: 456, + progress: [10, 100], + location: 'epubcfi(/6/8!/4/2)', + }; + + const serialized = serializeRawConfig(config); + const parsed = JSON.parse(serialized); + + expect(parsed.schemaVersion).toBe(BOOK_CONFIG_SCHEMA_VERSION); + expect(parsed.progress).toEqual([10, 100]); + expect(config.schemaVersion).toBeUndefined(); + }); + + it('hydrates legacy config JSON without schemaVersion', () => { + const config = deserializeConfig( + JSON.stringify({ + updatedAt: 789, + location: 'epubcfi(/6/10!/4/2)', + viewSettings: { zoomLevel: 90 }, + searchConfig: { query: 'rabbit' }, + }), + globalViewSettings, + defaultSearchConfig, + ); + + expect(config.schemaVersion).toBe(BOOK_CONFIG_SCHEMA_VERSION); + expect(config.location).toBe('epubcfi(/6/10!/4/2)'); + expect(config.viewSettings?.zoomLevel).toBe(90); + expect(config.searchConfig?.query).toBe('rabbit'); + }); +}); diff --git a/apps/readest-app/src/services/backupService.ts b/apps/readest-app/src/services/backupService.ts index bc636a9c..10dd39e3 100644 --- a/apps/readest-app/src/services/backupService.ts +++ b/apps/readest-app/src/services/backupService.ts @@ -4,6 +4,7 @@ import { EXTS } from '@/libs/document'; import { isTauriAppPlatform } from '@/services/environment'; import { Book, BookConfig, BookNote } from '@/types/book'; import { getLibraryFilename } from '@/utils/book'; +import { stampBookConfigSchema } from '@/utils/serializer'; import { configureZip } from '@/utils/zip'; /** Book file extensions for identifying book files in backup directories. */ @@ -36,7 +37,7 @@ export function mergeBookConfigs( } base.booknotes = [...noteMap.values()]; - return base; + return stampBookConfigSchema(base); } /** diff --git a/apps/readest-app/src/services/bookService.ts b/apps/readest-app/src/services/bookService.ts index 51b51b15..a09239f9 100644 --- a/apps/readest-app/src/services/bookService.ts +++ b/apps/readest-app/src/services/bookService.ts @@ -29,7 +29,7 @@ import { BookDoc, DocumentLoader, EXTS } from '@/libs/document'; import { isPseStreamFileName, openPseStreamBook, parsePseStreamFileName } from './opds/pseStream'; import { DEFAULT_BOOK_SEARCH_CONFIG, DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS } from './constants'; import { isContentURI, isValidURL, makeSafeFilename } from '@/utils/misc'; -import { deserializeConfig, serializeConfig } from '@/utils/serializer'; +import { deserializeConfig, serializeConfig, serializeRawConfig } from '@/utils/serializer'; import { ClosableFile } from '@/utils/file'; import { TxtToEpubConverter } from '@/utils/txt'; import { svg2png } from '@/utils/svg'; @@ -192,7 +192,7 @@ export async function mergeBooks( } base.booknotes = [...noteMap.values()]; - mergedConfigData = JSON.stringify(base); + mergedConfigData = serializeRawConfig(base); } for (const dup of duplicates) { @@ -430,7 +430,7 @@ export async function importBook( const config: Partial = JSON.parse(bestConfigData); config.bookHash = hash; config.metaHash = metaHash; - await fs.writeFile(getConfigFilename(book), 'Books', JSON.stringify(config)); + await fs.writeFile(getConfigFilename(book), 'Books', serializeRawConfig(config)); } else { const oldConfigPath = `${oldBookDir}/config.json`; if (await fs.exists(oldConfigPath, 'Books')) { @@ -438,7 +438,7 @@ export async function importBook( const config: Partial = JSON.parse(configData); config.bookHash = hash; config.metaHash = metaHash; - await fs.writeFile(getConfigFilename(book), 'Books', JSON.stringify(config)); + await fs.writeFile(getConfigFilename(book), 'Books', serializeRawConfig(config)); } else { await saveBookConfigFn(book, INIT_BOOK_CONFIG); } @@ -452,7 +452,7 @@ export async function importBook( const config: Partial = JSON.parse(bestConfigData); config.bookHash = hash; config.metaHash = metaHash; - await fs.writeFile(getConfigFilename(book), 'Books', JSON.stringify(config)); + await fs.writeFile(getConfigFilename(book), 'Books', serializeRawConfig(config)); } // update file links with url or path or content uri @@ -574,7 +574,7 @@ export async function saveBookConfig( }; serializedConfig = serializeConfig(config, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG); } else { - serializedConfig = JSON.stringify(config); + serializedConfig = serializeRawConfig(config); } await fs.writeFile(getConfigFilename(book), 'Books', serializedConfig); } diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index b5ecad78..5761a346 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -396,7 +396,10 @@ export interface BookSearchResult { progress?: number; } +export const BOOK_CONFIG_SCHEMA_VERSION = 1; + export interface BookConfig { + schemaVersion?: number; bookHash?: string; metaHash?: string; progress?: [number, number]; // [current pagenum, total pagenum], 1-based page number diff --git a/apps/readest-app/src/utils/book.ts b/apps/readest-app/src/utils/book.ts index b3af5a7b..7281bb42 100644 --- a/apps/readest-app/src/utils/book.ts +++ b/apps/readest-app/src/utils/book.ts @@ -1,5 +1,11 @@ import { BookMetadata, EXTS } from '@/libs/document'; -import { Book, BookConfig, BookProgress, WritingMode } from '@/types/book'; +import { + Book, + BOOK_CONFIG_SCHEMA_VERSION, + BookConfig, + BookProgress, + WritingMode, +} from '@/types/book'; import { SUPPORTED_LANGS } from '@/services/constants'; import { getLocale, getUserLang, makeSafeFilename } from './misc'; import { getStorageType } from './storage'; @@ -43,6 +49,7 @@ export const isBookFile = (filename: string) => { }; export const INIT_BOOK_CONFIG: BookConfig = { + schemaVersion: BOOK_CONFIG_SCHEMA_VERSION, updatedAt: 0, }; diff --git a/apps/readest-app/src/utils/serializer.ts b/apps/readest-app/src/utils/serializer.ts index f006450c..191009a7 100644 --- a/apps/readest-app/src/utils/serializer.ts +++ b/apps/readest-app/src/utils/serializer.ts @@ -1,4 +1,17 @@ -import { BookConfig, BookSearchConfig, ViewSettings } from '@/types/book'; +import { + BOOK_CONFIG_SCHEMA_VERSION, + BookConfig, + BookSearchConfig, + ViewSettings, +} from '@/types/book'; + +export const stampBookConfigSchema = >(config: T): T => { + return { ...config, schemaVersion: BOOK_CONFIG_SCHEMA_VERSION }; +}; + +export const serializeRawConfig = (config: Partial): string => { + return JSON.stringify(stampBookConfigSchema(config)); +}; export const serializeConfig = ( config: BookConfig, @@ -26,6 +39,7 @@ export const serializeConfig = ( }, {} as Partial, ) as Partial; + config.schemaVersion = BOOK_CONFIG_SCHEMA_VERSION; return JSON.stringify(config); }; @@ -39,6 +53,7 @@ export const deserializeConfig = ( const { viewSettings, searchConfig } = config; config.viewSettings = { ...globalViewSettings, ...viewSettings }; config.searchConfig = { ...defaultSearchConfig, ...searchConfig }; + config.schemaVersion ??= BOOK_CONFIG_SCHEMA_VERSION; config.updatedAt ??= Date.now(); return config; };