feat(config): version book config schema (#4208)

This commit is contained in:
Huang Xin
2026-05-18 00:23:46 +08:00
committed by GitHub
parent 1d4b7eed87
commit 0fba5b7054
7 changed files with 165 additions and 9 deletions
+57
View File
@@ -0,0 +1,57 @@
# Book Config JSON
Each imported book may have a per-book config file at:
```text
<bookHash>/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`.
@@ -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<BookConfig> = {
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');
});
});
@@ -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);
}
/**
+6 -6
View File
@@ -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<BookConfig> = 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<BookConfig> = 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<BookConfig> = 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);
}
+3
View File
@@ -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
+8 -1
View File
@@ -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,
};
+16 -1
View File
@@ -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 = <T extends Partial<BookConfig>>(config: T): T => {
return { ...config, schemaVersion: BOOK_CONFIG_SCHEMA_VERSION };
};
export const serializeRawConfig = (config: Partial<BookConfig>): string => {
return JSON.stringify(stampBookConfigSchema(config));
};
export const serializeConfig = (
config: BookConfig,
@@ -26,6 +39,7 @@ export const serializeConfig = (
},
{} as Partial<BookSearchConfig>,
) as Partial<BookSearchConfig>;
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;
};