* feat(reader): import Foliate annotations on book open (Linux only), closes #2180 Automatically imports annotations, bookmarks, and reading progress from Foliate's data files when opening a book on Linux. Uses foliateImportedAt flag to prevent re-importing on subsequent opens. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(annotation): move Foliate import into annotation provider pattern Restructure annotation import as a multi-provider service following the translators pattern. Foliate becomes a provider under services/annotation/providers/, making it easy to add more import sources. Each provider controls its own availability check and skip-if-imported guard. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
## Build Constraints
|
||||
|
||||
- No optional chaining (`?.`) in build output — verified by `check:optional-chaining`.
|
||||
- No lookbehind regex in build output — verified by `check:lookbehind-regex`.
|
||||
- Run `pnpm build-check` (builds both targets + runs all checks) before submitting.
|
||||
@@ -58,7 +58,7 @@ Platform-specific code lives in `src-tauri/src/{macos,windows,android,ios}/`. Cu
|
||||
|
||||
## Project Rules
|
||||
|
||||
Rules are in `.claude/rules/`: test-first, typescript, build-constraints, verification.
|
||||
Rules are in `.claude/rules/`: test-first, typescript, verification.
|
||||
|
||||
### i18n
|
||||
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
{
|
||||
"path": "**/Readest/**/*"
|
||||
},
|
||||
{
|
||||
"path": "**/com.github.johnfactotum.Foliate/**/*"
|
||||
},
|
||||
{
|
||||
"path": "**/last-book-cover.png"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
getFoliateDataPath,
|
||||
mapFoliateColor,
|
||||
convertFoliateAnnotation,
|
||||
convertFoliateBookmark,
|
||||
convertFoliateData,
|
||||
parseFoliateData,
|
||||
FoliateAnnotation,
|
||||
FoliateData,
|
||||
} from '@/services/annotation/providers/foliate';
|
||||
import { mergeBookConfigs } from '@/services/backupService';
|
||||
import { BookConfig, BookNote } from '@/types/book';
|
||||
|
||||
const BOOK_HASH = 'abc123';
|
||||
|
||||
// Freeze Date.now for deterministic tests
|
||||
const NOW = 1700000000000;
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(NOW);
|
||||
});
|
||||
|
||||
describe('getFoliateDataPath', () => {
|
||||
it('should construct path with encoded identifier', () => {
|
||||
const result = getFoliateDataPath('/home/user/.local/share', 'urn:isbn:9780123456789');
|
||||
expect(result).toBe(
|
||||
'/home/user/.local/share/com.github.johnfactotum.Foliate/urn%3Aisbn%3A9780123456789.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle identifiers with special characters', () => {
|
||||
const result = getFoliateDataPath('/data', 'http://example.com/book?id=1&v=2');
|
||||
expect(result).toBe(
|
||||
'/data/com.github.johnfactotum.Foliate/http%3A%2F%2Fexample.com%2Fbook%3Fid%3D1%26v%3D2.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle simple identifier', () => {
|
||||
const result = getFoliateDataPath('/data', 'simple-id');
|
||||
expect(result).toBe('/data/com.github.johnfactotum.Foliate/simple-id.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapFoliateColor', () => {
|
||||
it('should map yellow to highlight/yellow', () => {
|
||||
expect(mapFoliateColor('yellow')).toEqual({ style: 'highlight', color: 'yellow' });
|
||||
});
|
||||
|
||||
it('should map orange to highlight/yellow', () => {
|
||||
expect(mapFoliateColor('orange')).toEqual({ style: 'highlight', color: 'yellow' });
|
||||
});
|
||||
|
||||
it('should map red to highlight/red', () => {
|
||||
expect(mapFoliateColor('red')).toEqual({ style: 'highlight', color: 'red' });
|
||||
});
|
||||
|
||||
it('should map magenta to highlight/violet', () => {
|
||||
expect(mapFoliateColor('magenta')).toEqual({ style: 'highlight', color: 'violet' });
|
||||
});
|
||||
|
||||
it('should map aqua to highlight/blue', () => {
|
||||
expect(mapFoliateColor('aqua')).toEqual({ style: 'highlight', color: 'blue' });
|
||||
});
|
||||
|
||||
it('should map lime to highlight/green', () => {
|
||||
expect(mapFoliateColor('lime')).toEqual({ style: 'highlight', color: 'green' });
|
||||
});
|
||||
|
||||
it('should map underline to underline/red', () => {
|
||||
expect(mapFoliateColor('underline')).toEqual({ style: 'underline', color: 'red' });
|
||||
});
|
||||
|
||||
it('should map squiggly to squiggly/red', () => {
|
||||
expect(mapFoliateColor('squiggly')).toEqual({ style: 'squiggly', color: 'red' });
|
||||
});
|
||||
|
||||
it('should map strikethrough to highlight/red', () => {
|
||||
expect(mapFoliateColor('strikethrough')).toEqual({ style: 'highlight', color: 'red' });
|
||||
});
|
||||
|
||||
it('should default undefined to highlight/yellow', () => {
|
||||
expect(mapFoliateColor(undefined)).toEqual({ style: 'highlight', color: 'yellow' });
|
||||
});
|
||||
|
||||
it('should pass through custom hex color', () => {
|
||||
expect(mapFoliateColor('#ff5500')).toEqual({ style: 'highlight', color: '#ff5500' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertFoliateAnnotation', () => {
|
||||
it('should convert a full annotation', () => {
|
||||
const annotation: FoliateAnnotation = {
|
||||
value: 'epubcfi(/6/4!/4/2,/1:0,/1:10)',
|
||||
text: 'highlighted text',
|
||||
color: 'aqua',
|
||||
note: 'my note',
|
||||
created: '2024-01-15T10:30:00Z',
|
||||
modified: '2024-01-16T12:00:00Z',
|
||||
};
|
||||
const result = convertFoliateAnnotation(BOOK_HASH, annotation);
|
||||
|
||||
expect(result.type).toBe('annotation');
|
||||
expect(result.cfi).toBe('epubcfi(/6/4!/4/2,/1:0,/1:10)');
|
||||
expect(result.text).toBe('highlighted text');
|
||||
expect(result.style).toBe('highlight');
|
||||
expect(result.color).toBe('blue');
|
||||
expect(result.note).toBe('my note');
|
||||
expect(result.createdAt).toBe(new Date('2024-01-15T10:30:00Z').getTime());
|
||||
expect(result.updatedAt).toBe(new Date('2024-01-16T12:00:00Z').getTime());
|
||||
expect(result.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should produce stable IDs for the same CFI', () => {
|
||||
const annotation: FoliateAnnotation = { value: 'epubcfi(/6/4!/4/2,/1:0,/1:10)' };
|
||||
const first = convertFoliateAnnotation(BOOK_HASH, annotation);
|
||||
const second = convertFoliateAnnotation(BOOK_HASH, annotation);
|
||||
expect(first.id).toBe(second.id);
|
||||
});
|
||||
|
||||
it('should produce different IDs for different CFIs', () => {
|
||||
const a = convertFoliateAnnotation(BOOK_HASH, { value: 'cfi-a' });
|
||||
const b = convertFoliateAnnotation(BOOK_HASH, { value: 'cfi-b' });
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
|
||||
it('should handle missing optional fields', () => {
|
||||
const annotation: FoliateAnnotation = {
|
||||
value: 'epubcfi(/6/4)',
|
||||
};
|
||||
const result = convertFoliateAnnotation(BOOK_HASH, annotation);
|
||||
|
||||
expect(result.text).toBe('');
|
||||
expect(result.note).toBe('');
|
||||
expect(result.style).toBe('highlight');
|
||||
expect(result.color).toBe('yellow');
|
||||
expect(result.createdAt).toBe(NOW);
|
||||
expect(result.updatedAt).toBe(NOW);
|
||||
});
|
||||
|
||||
it('should fall back to Date.now() for invalid dates', () => {
|
||||
const annotation: FoliateAnnotation = {
|
||||
value: 'cfi',
|
||||
created: 'not-a-date',
|
||||
modified: 'also-invalid',
|
||||
};
|
||||
const result = convertFoliateAnnotation(BOOK_HASH, annotation);
|
||||
|
||||
expect(result.createdAt).toBe(NOW);
|
||||
expect(result.updatedAt).toBe(NOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertFoliateBookmark', () => {
|
||||
it('should create a bookmark-type note', () => {
|
||||
const result = convertFoliateBookmark(BOOK_HASH, 'epubcfi(/6/8!/4/2)');
|
||||
|
||||
expect(result.type).toBe('bookmark');
|
||||
expect(result.cfi).toBe('epubcfi(/6/8!/4/2)');
|
||||
expect(result.note).toBe('');
|
||||
expect(result.createdAt).toBe(NOW);
|
||||
expect(result.updatedAt).toBe(NOW);
|
||||
expect(result.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should produce stable IDs for the same CFI', () => {
|
||||
const first = convertFoliateBookmark(BOOK_HASH, 'epubcfi(/6/8!/4/2)');
|
||||
const second = convertFoliateBookmark(BOOK_HASH, 'epubcfi(/6/8!/4/2)');
|
||||
expect(first.id).toBe(second.id);
|
||||
});
|
||||
|
||||
it('should produce different IDs from annotations with the same CFI', () => {
|
||||
const bookmark = convertFoliateBookmark(BOOK_HASH, 'cfi-same');
|
||||
const annotation = convertFoliateAnnotation(BOOK_HASH, { value: 'cfi-same' });
|
||||
expect(bookmark.id).not.toBe(annotation.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertFoliateData', () => {
|
||||
it('should convert full data with annotations and bookmarks', () => {
|
||||
const data: FoliateData = {
|
||||
annotations: [
|
||||
{ value: 'cfi-1', text: 'text1', color: 'yellow' },
|
||||
{ value: 'cfi-2', text: 'text2', color: 'red', note: 'note2' },
|
||||
],
|
||||
bookmarks: ['cfi-bm-1', 'cfi-bm-2'],
|
||||
progress: [42, 100],
|
||||
lastLocation: 'cfi-last',
|
||||
};
|
||||
const result = convertFoliateData(BOOK_HASH, data);
|
||||
|
||||
expect(result.booknotes).toHaveLength(4);
|
||||
expect(result.booknotes!.filter((n) => n.type === 'annotation')).toHaveLength(2);
|
||||
expect(result.booknotes!.filter((n) => n.type === 'bookmark')).toHaveLength(2);
|
||||
expect(result.progress).toEqual([42, 100]);
|
||||
expect(result.location).toBe('cfi-last');
|
||||
});
|
||||
|
||||
it('should handle empty arrays', () => {
|
||||
const data: FoliateData = {
|
||||
annotations: [],
|
||||
bookmarks: [],
|
||||
};
|
||||
const result = convertFoliateData(BOOK_HASH, data);
|
||||
|
||||
expect(result.booknotes).toEqual([]);
|
||||
expect(result.progress).toBeUndefined();
|
||||
expect(result.location).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle missing fields', () => {
|
||||
const data: FoliateData = {};
|
||||
const result = convertFoliateData(BOOK_HASH, data);
|
||||
|
||||
expect(result.booknotes).toEqual([]);
|
||||
expect(result.progress).toBeUndefined();
|
||||
expect(result.location).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle only bookmarks', () => {
|
||||
const data: FoliateData = { bookmarks: ['cfi-1'] };
|
||||
const result = convertFoliateData(BOOK_HASH, data);
|
||||
|
||||
expect(result.booknotes).toHaveLength(1);
|
||||
expect(result.booknotes![0]!.type).toBe('bookmark');
|
||||
});
|
||||
|
||||
it('should handle only progress', () => {
|
||||
const data: FoliateData = { progress: [10, 200] };
|
||||
const result = convertFoliateData(BOOK_HASH, data);
|
||||
|
||||
expect(result.booknotes).toEqual([]);
|
||||
expect(result.progress).toEqual([10, 200]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFoliateData', () => {
|
||||
it('should parse valid JSON', () => {
|
||||
const json = JSON.stringify({
|
||||
annotations: [{ value: 'cfi-1', text: 'hello' }],
|
||||
bookmarks: ['cfi-2'],
|
||||
progress: [5, 100],
|
||||
});
|
||||
const result = parseFoliateData(json);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.annotations).toHaveLength(1);
|
||||
expect(result!.bookmarks).toHaveLength(1);
|
||||
expect(result!.progress).toEqual([5, 100]);
|
||||
});
|
||||
|
||||
it('should return null for invalid JSON', () => {
|
||||
expect(parseFoliateData('not json')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-object JSON (array)', () => {
|
||||
expect(parseFoliateData('[1, 2, 3]')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-object JSON (string)', () => {
|
||||
expect(parseFoliateData('"hello"')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for null JSON', () => {
|
||||
expect(parseFoliateData('null')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const result = parseFoliateData('{}');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration: merge converted Foliate data with existing config', () => {
|
||||
it('should merge Foliate notes with existing config notes', () => {
|
||||
const existingNote: BookNote = {
|
||||
id: 'existing-1',
|
||||
type: 'annotation',
|
||||
cfi: 'existing-cfi',
|
||||
note: 'existing note',
|
||||
createdAt: 100,
|
||||
updatedAt: 100,
|
||||
};
|
||||
const currentConfig: Partial<BookConfig> = {
|
||||
progress: [50, 200],
|
||||
booknotes: [existingNote],
|
||||
updatedAt: 500,
|
||||
};
|
||||
|
||||
const foliateData: FoliateData = {
|
||||
annotations: [{ value: 'foliate-cfi', text: 'foliate text', color: 'lime' }],
|
||||
bookmarks: ['bookmark-cfi'],
|
||||
progress: [30, 200],
|
||||
};
|
||||
const converted = convertFoliateData(BOOK_HASH, foliateData);
|
||||
const merged = mergeBookConfigs(currentConfig, converted);
|
||||
|
||||
// Should keep higher progress from current (50 > 30)
|
||||
expect(merged.progress).toEqual([50, 200]);
|
||||
// Should have all notes: 1 existing + 1 annotation + 1 bookmark = 3
|
||||
expect(merged.booknotes).toHaveLength(3);
|
||||
expect(merged.booknotes!.find((n) => n.id === 'existing-1')).toBeDefined();
|
||||
expect(merged.booknotes!.filter((n) => n.type === 'bookmark')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should use Foliate progress when higher', () => {
|
||||
const currentConfig: Partial<BookConfig> = {
|
||||
progress: [10, 200],
|
||||
updatedAt: 500,
|
||||
};
|
||||
const foliateData: FoliateData = {
|
||||
progress: [80, 200],
|
||||
lastLocation: 'foliate-loc',
|
||||
};
|
||||
const converted = convertFoliateData(BOOK_HASH, foliateData);
|
||||
const merged = mergeBookConfigs(currentConfig, converted);
|
||||
|
||||
expect(merged.progress).toEqual([80, 200]);
|
||||
expect(merged.location).toBe('foliate-loc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export * from './providers';
|
||||
@@ -0,0 +1,167 @@
|
||||
import { BookConfig, BookNote, HighlightColor, HighlightStyle } from '@/types/book';
|
||||
import { mergeBookConfigs } from '@/services/backupService';
|
||||
import { md5 } from 'js-md5';
|
||||
import { AnnotationImportProvider } from '../types';
|
||||
|
||||
/** Shape of a single Foliate annotation entry. */
|
||||
export interface FoliateAnnotation {
|
||||
value: string;
|
||||
text?: string;
|
||||
color?: string;
|
||||
note?: string;
|
||||
created?: string;
|
||||
modified?: string;
|
||||
}
|
||||
|
||||
/** Shape of Foliate's per-book JSON data file. */
|
||||
export interface FoliateData {
|
||||
annotations?: FoliateAnnotation[];
|
||||
bookmarks?: string[];
|
||||
progress?: [number, number];
|
||||
lastLocation?: string;
|
||||
}
|
||||
|
||||
/** Build the absolute path to a Foliate data file for the given identifier. */
|
||||
export function getFoliateDataPath(dataDir: string, identifier: string): string {
|
||||
return `${dataDir}/com.github.johnfactotum.Foliate/${encodeURIComponent(identifier)}.json`;
|
||||
}
|
||||
|
||||
/** Map a Foliate color string to Readest highlight style and color. */
|
||||
export function mapFoliateColor(color: string | undefined): {
|
||||
style: HighlightStyle;
|
||||
color: HighlightColor;
|
||||
} {
|
||||
switch (color) {
|
||||
case 'yellow':
|
||||
case 'orange':
|
||||
return { style: 'highlight', color: 'yellow' };
|
||||
case 'red':
|
||||
return { style: 'highlight', color: 'red' };
|
||||
case 'magenta':
|
||||
return { style: 'highlight', color: 'violet' };
|
||||
case 'aqua':
|
||||
return { style: 'highlight', color: 'blue' };
|
||||
case 'lime':
|
||||
return { style: 'highlight', color: 'green' };
|
||||
case 'underline':
|
||||
return { style: 'underline', color: 'red' };
|
||||
case 'squiggly':
|
||||
return { style: 'squiggly', color: 'red' };
|
||||
case 'strikethrough':
|
||||
return { style: 'highlight', color: 'red' };
|
||||
case undefined:
|
||||
return { style: 'highlight', color: 'yellow' };
|
||||
default:
|
||||
// Custom hex color
|
||||
return { style: 'highlight', color };
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse an ISO 8601 date string to a timestamp, falling back to Date.now(). */
|
||||
function parseDate(dateStr: string | undefined): number {
|
||||
if (!dateStr) return Date.now();
|
||||
const ts = new Date(dateStr).getTime();
|
||||
return Number.isNaN(ts) ? Date.now() : ts;
|
||||
}
|
||||
|
||||
/** Generate a stable ID for a Foliate-imported note so re-imports deduplicate. */
|
||||
function foliateNoteId(hash: string, type: string, cfi: string): string {
|
||||
return md5(`foliate:${hash}:${type}:${cfi}`).slice(0, 7);
|
||||
}
|
||||
|
||||
/** Convert a single Foliate annotation to a BookNote. */
|
||||
export function convertFoliateAnnotation(hash: string, annotation: FoliateAnnotation): BookNote {
|
||||
const { style, color } = mapFoliateColor(annotation.color);
|
||||
const created = parseDate(annotation.created);
|
||||
const modified = parseDate(annotation.modified);
|
||||
return {
|
||||
id: foliateNoteId(hash, 'annotation', annotation.value),
|
||||
type: 'annotation',
|
||||
cfi: annotation.value,
|
||||
text: annotation.text ?? '',
|
||||
style,
|
||||
color,
|
||||
note: annotation.note ?? '',
|
||||
createdAt: created,
|
||||
updatedAt: modified,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a Foliate bookmark CFI to a BookNote. */
|
||||
export function convertFoliateBookmark(hash: string, cfi: string): BookNote {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: foliateNoteId(hash, 'bookmark', cfi),
|
||||
type: 'bookmark',
|
||||
cfi,
|
||||
note: '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert the full Foliate data structure to a partial BookConfig. */
|
||||
export function convertFoliateData(hash: string, data: FoliateData): Partial<BookConfig> {
|
||||
const booknotes: BookNote[] = [];
|
||||
for (const annotation of data.annotations ?? []) {
|
||||
booknotes.push(convertFoliateAnnotation(hash, annotation));
|
||||
}
|
||||
for (const cfi of data.bookmarks ?? []) {
|
||||
booknotes.push(convertFoliateBookmark(hash, cfi));
|
||||
}
|
||||
|
||||
const result: Partial<BookConfig> = { booknotes };
|
||||
|
||||
if (data.progress) {
|
||||
result.progress = data.progress;
|
||||
}
|
||||
if (data.lastLocation) {
|
||||
result.location = data.lastLocation;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Safely parse Foliate JSON data, returning null on failure. */
|
||||
export function parseFoliateData(json: string): FoliateData | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed as FoliateData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const foliateProvider: AnnotationImportProvider = {
|
||||
name: 'foliate',
|
||||
isAvailable: (appService) => appService.isLinuxApp,
|
||||
importAnnotations: async (appService, identifier, config) => {
|
||||
if (config.foliateImportedAt) return config;
|
||||
try {
|
||||
const { dataDir } = await import('@tauri-apps/api/path');
|
||||
const dir = await dataDir();
|
||||
const path = getFoliateDataPath(dir, identifier);
|
||||
|
||||
if (!(await appService.exists(path, 'None'))) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const json = (await appService.readFile(path, 'None', 'text')) as string;
|
||||
const foliateData = parseFoliateData(json);
|
||||
if (!foliateData) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const converted = convertFoliateData(config.bookHash ?? '', foliateData);
|
||||
const merged = mergeBookConfigs(config, converted) as BookConfig;
|
||||
merged.foliateImportedAt = Date.now();
|
||||
return merged;
|
||||
} catch (error) {
|
||||
console.warn('Failed to import Foliate data:', error);
|
||||
return config;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { AnnotationImportProvider } from '../types';
|
||||
import { foliateProvider } from './foliate';
|
||||
|
||||
function createProvider<T extends string>(
|
||||
name: T,
|
||||
implementation: AnnotationImportProvider,
|
||||
): AnnotationImportProvider & { name: T } {
|
||||
if (name !== implementation.name) {
|
||||
throw Error(
|
||||
`Annotation provider name "${name}" does not match implementation name "${implementation.name}"`,
|
||||
);
|
||||
}
|
||||
return implementation as AnnotationImportProvider & { name: T };
|
||||
}
|
||||
|
||||
const foliateAnnotationProvider = createProvider('foliate', foliateProvider);
|
||||
|
||||
const availableProviders = [
|
||||
foliateAnnotationProvider,
|
||||
// Add more annotation import providers here
|
||||
];
|
||||
|
||||
export type AnnotationProviderName = (typeof availableProviders)[number]['name'];
|
||||
|
||||
export const getAnnotationProvider = (
|
||||
name: AnnotationProviderName,
|
||||
): AnnotationImportProvider | undefined => {
|
||||
return availableProviders.find((provider) => provider.name === name);
|
||||
};
|
||||
|
||||
export const getAnnotationProviders = (): AnnotationImportProvider[] => {
|
||||
return availableProviders;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BookConfig } from '@/types/book';
|
||||
import { AppService } from '@/types/system';
|
||||
|
||||
import { AnnotationProviderName } from './providers';
|
||||
|
||||
export interface AnnotationImportProvider {
|
||||
name: string;
|
||||
/** Check whether this provider is applicable on the current platform. */
|
||||
isAvailable: (appService: AppService) => boolean;
|
||||
/** Import annotations for a book, merging with the current config. */
|
||||
importAnnotations: (
|
||||
appService: AppService,
|
||||
identifier: string,
|
||||
config: BookConfig,
|
||||
) => Promise<BookConfig>;
|
||||
}
|
||||
|
||||
export interface UseAnnotationImportOptions {
|
||||
provider?: AnnotationProviderName;
|
||||
}
|
||||
@@ -162,6 +162,23 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
bookDoc = doc.book;
|
||||
}
|
||||
const config = await appService.loadBookConfig(book, settings);
|
||||
// Import annotations from third-party readers on first open
|
||||
if (bookDoc.metadata.identifier) {
|
||||
const { getAnnotationProviders } = await import('@/services/annotation');
|
||||
for (const provider of getAnnotationProviders()) {
|
||||
if (provider.isAvailable(appService)) {
|
||||
const merged = await provider.importAnnotations(
|
||||
appService,
|
||||
bookDoc.metadata.identifier,
|
||||
config,
|
||||
);
|
||||
if (merged !== config) {
|
||||
Object.assign(config, merged);
|
||||
await appService.saveBookConfig(book, config, settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await updateToc(
|
||||
bookDoc,
|
||||
config.viewSettings?.sortedTOC ?? false,
|
||||
|
||||
@@ -360,6 +360,7 @@ export interface BookConfig {
|
||||
|
||||
lastSyncedAtConfig?: number;
|
||||
lastSyncedAtNotes?: number;
|
||||
foliateImportedAt?: number;
|
||||
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user