forked from akai/readest
feat(config): version book config schema (#4208)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user