feat(reader): import Foliate annotations on book open (Linux only), closes #2180 (#3574)

* 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:
Huang Xin
2026-03-21 12:04:46 +08:00
committed by GitHub
parent 7445d5befe
commit 0e8605d298
10 changed files with 565 additions and 6 deletions
@@ -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.
+1 -1
View File
@@ -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;
}
+17
View File
@@ -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,
+1
View File
@@ -360,6 +360,7 @@ export interface BookConfig {
lastSyncedAtConfig?: number;
lastSyncedAtNotes?: number;
foliateImportedAt?: number;
updatedAt: number;
}