From 0e8605d2988a8df4aa20604080174e55af52290d Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 21 Mar 2026 12:04:46 +0800 Subject: [PATCH] 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../.claude/rules/build-constraints.md | 5 - apps/readest-app/AGENTS.md | 2 +- .../src-tauri/capabilities/default.json | 3 + .../services/foliate-import-service.test.ts | 321 ++++++++++++++++++ .../src/services/annotation/index.ts | 2 + .../services/annotation/providers/foliate.ts | 167 +++++++++ .../services/annotation/providers/index.ts | 33 ++ .../src/services/annotation/types.ts | 20 ++ apps/readest-app/src/store/readerStore.ts | 17 + apps/readest-app/src/types/book.ts | 1 + 10 files changed, 565 insertions(+), 6 deletions(-) delete mode 100644 apps/readest-app/.claude/rules/build-constraints.md create mode 100644 apps/readest-app/src/__tests__/services/foliate-import-service.test.ts create mode 100644 apps/readest-app/src/services/annotation/index.ts create mode 100644 apps/readest-app/src/services/annotation/providers/foliate.ts create mode 100644 apps/readest-app/src/services/annotation/providers/index.ts create mode 100644 apps/readest-app/src/services/annotation/types.ts diff --git a/apps/readest-app/.claude/rules/build-constraints.md b/apps/readest-app/.claude/rules/build-constraints.md deleted file mode 100644 index 9b2f2d9f..00000000 --- a/apps/readest-app/.claude/rules/build-constraints.md +++ /dev/null @@ -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. diff --git a/apps/readest-app/AGENTS.md b/apps/readest-app/AGENTS.md index 54afaa05..e0964371 100644 --- a/apps/readest-app/AGENTS.md +++ b/apps/readest-app/AGENTS.md @@ -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 diff --git a/apps/readest-app/src-tauri/capabilities/default.json b/apps/readest-app/src-tauri/capabilities/default.json index edb61468..e6b310ac 100644 --- a/apps/readest-app/src-tauri/capabilities/default.json +++ b/apps/readest-app/src-tauri/capabilities/default.json @@ -80,6 +80,9 @@ { "path": "**/Readest/**/*" }, + { + "path": "**/com.github.johnfactotum.Foliate/**/*" + }, { "path": "**/last-book-cover.png" } diff --git a/apps/readest-app/src/__tests__/services/foliate-import-service.test.ts b/apps/readest-app/src/__tests__/services/foliate-import-service.test.ts new file mode 100644 index 00000000..4776dd7e --- /dev/null +++ b/apps/readest-app/src/__tests__/services/foliate-import-service.test.ts @@ -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 = { + 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 = { + 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'); + }); +}); diff --git a/apps/readest-app/src/services/annotation/index.ts b/apps/readest-app/src/services/annotation/index.ts new file mode 100644 index 00000000..3b256680 --- /dev/null +++ b/apps/readest-app/src/services/annotation/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './providers'; diff --git a/apps/readest-app/src/services/annotation/providers/foliate.ts b/apps/readest-app/src/services/annotation/providers/foliate.ts new file mode 100644 index 00000000..1d09de4e --- /dev/null +++ b/apps/readest-app/src/services/annotation/providers/foliate.ts @@ -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 { + 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 = { 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; + } + }, +}; diff --git a/apps/readest-app/src/services/annotation/providers/index.ts b/apps/readest-app/src/services/annotation/providers/index.ts new file mode 100644 index 00000000..4663254d --- /dev/null +++ b/apps/readest-app/src/services/annotation/providers/index.ts @@ -0,0 +1,33 @@ +import { AnnotationImportProvider } from '../types'; +import { foliateProvider } from './foliate'; + +function createProvider( + 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; +}; diff --git a/apps/readest-app/src/services/annotation/types.ts b/apps/readest-app/src/services/annotation/types.ts new file mode 100644 index 00000000..d011d61b --- /dev/null +++ b/apps/readest-app/src/services/annotation/types.ts @@ -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; +} + +export interface UseAnnotationImportOptions { + provider?: AnnotationProviderName; +} diff --git a/apps/readest-app/src/store/readerStore.ts b/apps/readest-app/src/store/readerStore.ts index e1a1ee2a..54071375 100644 --- a/apps/readest-app/src/store/readerStore.ts +++ b/apps/readest-app/src/store/readerStore.ts @@ -162,6 +162,23 @@ export const useReaderStore = create((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, diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 0225d5fd..f5a146c5 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -360,6 +360,7 @@ export interface BookConfig { lastSyncedAtConfig?: number; lastSyncedAtNotes?: number; + foliateImportedAt?: number; updatedAt: number; }