diff --git a/packages/core/src/sync/__tests__/simple-sync.integration.test.ts b/packages/core/src/sync/__tests__/simple-sync.integration.test.ts index d23b931e..cc5ec465 100644 --- a/packages/core/src/sync/__tests__/simple-sync.integration.test.ts +++ b/packages/core/src/sync/__tests__/simple-sync.integration.test.ts @@ -380,6 +380,7 @@ function sortRow(row: Row): Row { const { is_vectorized: _isVectorized, vectorize_progress: _vectorizeProgress, + sync_status: _syncStatus, ...syncedRow } = row; return Object.fromEntries(Object.entries(syncedRow).sort(([a], [b]) => a.localeCompare(b))); @@ -518,6 +519,33 @@ describe("simple sync convergence", () => { expect(target.get("highlights", "hl-1")).toBeTruthy(); }); + it("localizes synced book file paths and keeps remote books downloadable", async () => { + const target = new FakeSyncDb(); + dbMocks.currentDb = target; + dbMocks.currentDeviceId = "device-local"; + + const result = await applyChanges({ + deviceId: "device-remote", + timestamp: now, + since: 0, + tables: { + books: { + records: [ + bookRow({ + file_path: "file:///data/user/0/com.readany.app/files/books/source-device.epub", + sync_status: "local", + }), + ], + deletedIds: [], + }, + }, + }); + + expect(result).toEqual({ applied: 1, skipped: 0 }); + expect(target.get("books", "book-1")?.file_path).toBe("books/book-1.epub"); + expect(target.get("books", "book-1")?.sync_status).toBe("remote"); + }); + it("keeps a newer local record when an older remote tombstone arrives", async () => { const target = new FakeSyncDb(); target.insert("books", bookRow({ updated_at: 2500 })); diff --git a/packages/core/src/sync/__tests__/sync-files.test.ts b/packages/core/src/sync/__tests__/sync-files.test.ts index c88dcc29..b53c045c 100644 --- a/packages/core/src/sync/__tests__/sync-files.test.ts +++ b/packages/core/src/sync/__tests__/sync-files.test.ts @@ -26,9 +26,11 @@ vi.mock("../sync-adapter", () => ({ const mockSelect = vi.fn(); const mockSetBookSyncStatus = vi.fn(); +const mockUpdateBook = vi.fn(); vi.mock("../../db/database", () => ({ getDB: vi.fn(async () => ({ select: mockSelect })), setBookSyncStatus: mockSetBookSyncStatus, + updateBook: mockUpdateBook, })); const { syncFiles, downloadBookFile } = await import("../sync-files"); @@ -847,7 +849,10 @@ describe("sync-files", () => { `${REMOTE_BOOKS_ROOT}/Test Book-book-1/Test Book.epub`, ); expect(mockAdapter.writeFileBytes).toHaveBeenCalled(); - expect(mockSetBookSyncStatus).toHaveBeenCalledWith("book-1", "local"); + expect(mockUpdateBook).toHaveBeenCalledWith("book-1", { + filePath: "books/book-1.epub", + syncStatus: "local", + }); }); it("downloads on-demand via direct file transfer when available", async () => { @@ -871,7 +876,34 @@ describe("sync-files", () => { ); expect(mockAdapter.writeFileBytes).not.toHaveBeenCalled(); expect(backend.get).not.toHaveBeenCalled(); - expect(mockSetBookSyncStatus).toHaveBeenCalledWith("book-1", "local"); + expect(mockUpdateBook).toHaveBeenCalledWith("book-1", { + filePath: "books/book-1.epub", + syncStatus: "local", + }); + }); + + it("downloads source-device absolute paths into the local managed book path", async () => { + mockSelect.mockResolvedValue([{ id: "book-1", title: "Test Book" }]); + + const backend = createMockBackend({ + getFileToPath: vi.fn().mockResolvedValue(undefined), + }); + + const result = await downloadBookFile( + backend, + "book-1", + "file:///data/user/0/com.readany.app/files/books/source-device-copy.epub", + ); + + expect(result).toBe("ok"); + expect(mockAdapter.copyFile).toHaveBeenCalledWith( + expect.stringMatching(/^\/tmp\/readany-transfer-.*\.epub$/), + "/appdata/books/book-1.epub", + ); + expect(mockUpdateBook).toHaveBeenCalledWith("book-1", { + filePath: "books/book-1.epub", + syncStatus: "local", + }); }); it("falls back to the legacy path when the new path is missing", async () => { @@ -894,7 +926,10 @@ describe("sync-files", () => { expect(result).toBe("ok"); expect(getMock).toHaveBeenCalledWith(`${REMOTE_FILES}/book-1.epub`); - expect(mockSetBookSyncStatus).toHaveBeenCalledWith("book-1", "local"); + expect(mockUpdateBook).toHaveBeenCalledWith("book-1", { + filePath: "books/book-1.epub", + syncStatus: "local", + }); }); it("returns 'not-found' and marks book as remote when neither path has the file", async () => { diff --git a/packages/core/src/sync/local-book-paths.ts b/packages/core/src/sync/local-book-paths.ts new file mode 100644 index 00000000..5ab2dd3d --- /dev/null +++ b/packages/core/src/sync/local-book-paths.ts @@ -0,0 +1,39 @@ +const DEFAULT_BOOK_EXTENSION = "epub"; +const DEFAULT_COVER_EXTENSION = "jpg"; + +function normalizeExtension(value: unknown): string { + if (typeof value !== "string") return ""; + const trimmed = value.trim().toLowerCase().replace(/^\./, ""); + return /^[a-z0-9]+$/.test(trimmed) ? trimmed : ""; +} + +export function getPathExtension(path: unknown): string { + if (typeof path !== "string") return ""; + const cleanPath = path.split(/[?#]/, 1)[0].replace(/\\/g, "/"); + const leaf = cleanPath.slice(cleanPath.lastIndexOf("/") + 1); + const dot = leaf.lastIndexOf("."); + return dot >= 0 ? normalizeExtension(leaf.slice(dot + 1)) : ""; +} + +export function canonicalBookFilePath( + bookId: unknown, + filePath: unknown, + format?: unknown, +): string { + const id = String(bookId || "").trim(); + const ext = getPathExtension(filePath) || normalizeExtension(format) || DEFAULT_BOOK_EXTENSION; + return id ? `books/${id}.${ext}` : ""; +} + +export function canonicalBookCoverPath(bookId: unknown, coverUrl: unknown): string | null { + if (coverUrl == null || coverUrl === "") return null; + if (typeof coverUrl === "string" && /^https?:\/\//i.test(coverUrl)) { + return coverUrl; + } + + const id = String(bookId || "").trim(); + if (!id) return typeof coverUrl === "string" ? coverUrl : null; + + const ext = getPathExtension(coverUrl) || DEFAULT_COVER_EXTENSION; + return `covers/${id}.${ext}`; +} diff --git a/packages/core/src/sync/simple-sync.ts b/packages/core/src/sync/simple-sync.ts index 42c2d97b..32599744 100644 --- a/packages/core/src/sync/simple-sync.ts +++ b/packages/core/src/sync/simple-sync.ts @@ -17,6 +17,7 @@ import { } from "../db/database"; import { runSerializedDbTask } from "../db/write-retry"; import { getPlatformService } from "../services/platform"; +import { canonicalBookCoverPath, canonicalBookFilePath } from "./local-book-paths"; import type { ISyncBackend } from "./sync-backend"; import type { SyncFilesOptions } from "./sync-files"; import type { SyncProgress } from "./sync-types"; @@ -408,7 +409,8 @@ async function upsertRecord( record: Record, pk: string, ): Promise { - const filteredRecord = await filterRecordToExistingColumns(db, table, record); + const localRecord = table === "books" ? localizeSyncedBookRecord(record) : record; + const filteredRecord = await filterRecordToExistingColumns(db, table, localRecord); const columns = Object.keys(filteredRecord); if (columns.length === 0 || !columns.includes(pk)) return; @@ -435,6 +437,19 @@ async function upsertRecord( ); } +function localizeSyncedBookRecord(record: Record): Record { + const id = record.id; + const filePath = canonicalBookFilePath(id, record.file_path, record.format); + if (!filePath) return record; + + return { + ...record, + file_path: filePath, + cover_url: canonicalBookCoverPath(id, record.cover_url), + sync_status: "remote", + }; +} + function normalizeDeletedAt(value: unknown): number | null | undefined { if (value === undefined) return undefined; if (value === null) return null; diff --git a/packages/core/src/sync/sync-files.ts b/packages/core/src/sync/sync-files.ts index c97bd0f3..561ce2c3 100644 --- a/packages/core/src/sync/sync-files.ts +++ b/packages/core/src/sync/sync-files.ts @@ -10,6 +10,7 @@ */ import { getDB } from "../db/database"; +import { canonicalBookFilePath } from "./local-book-paths"; import { getSyncAdapter } from "./sync-adapter"; import type { ISyncBackend, RemoteFile } from "./sync-backend"; import { @@ -151,9 +152,11 @@ async function downloadRemoteFileToPath( type BookRow = { id: string; file_path: string; + format: string; file_hash: string; cover_url: string; title: string; + sync_status: string; }; type BookInfo = { @@ -340,7 +343,7 @@ export async function syncFiles( let filesDownloadFailed = 0; const books = await db.select( - "SELECT id, file_path, file_hash, cover_url, title FROM books WHERE deleted_at IS NULL", + "SELECT id, file_path, format, file_hash, cover_url, title, sync_status FROM books WHERE deleted_at IS NULL", [], ); @@ -349,13 +352,10 @@ export async function syncFiles( // --- Compute per-book info --- const bookInfos: BookInfo[] = books.map((book) => { - const fileExt = book.file_path ? getExt(book.file_path) || "epub" : ""; + const canonicalFilePath = canonicalBookFilePath(book.id, book.file_path, book.format); + const fileExt = canonicalFilePath ? getExt(canonicalFilePath) || "epub" : ""; const coverExt = book.cover_url ? getExt(book.cover_url) || "jpg" : ""; - const localFilePath = book.file_path - ? isAbsoluteOrProtocolPath(book.file_path) - ? book.file_path - : adapter.joinPath(appDataDir, book.file_path) - : ""; + const localFilePath = canonicalFilePath ? adapter.joinPath(appDataDir, canonicalFilePath) : ""; const localCoverPath = book.cover_url ? isAbsoluteOrProtocolPath(book.cover_url) ? book.cover_url @@ -373,7 +373,7 @@ export async function syncFiles( remoteCoverPath: coverExt ? buildBookRemoteCover(book, coverExt) : "", legacyRemoteFileName: fileExt ? `${book.id}.${fileExt}` : "", legacyRemoteCoverName: coverExt ? `${book.id}.${coverExt}` : "", - hasFile: !!book.file_path, + hasFile: !!canonicalFilePath, hasCover: !!book.cover_url, }; }); @@ -440,6 +440,14 @@ export async function syncFiles( const localExists = localExistsMap.get(info.localFilePath) ?? false; const remoteExists = migration.fileAtNew; + if (localExists && book.sync_status && book.sync_status !== "local") { + try { + await setBookSyncStatus(book.id, "local"); + } catch (e) { + console.warn(`[Sync] Failed to mark existing local book as local: ${e}`); + } + } + if (!disableUploads && localExists && (forceUploadAll || !remoteExists)) { const task = buildUploadFileTask(backend, info); const sizeBytes = localSizeMap.get(info.localFilePath) ?? null; @@ -1242,10 +1250,11 @@ export async function downloadBookFile( onProgress?: (progress: { downloaded: number; total: number }) => void, ): Promise { const adapter = getSyncAdapter(); - const { setBookSyncStatus } = await import("../db/database"); + const { setBookSyncStatus, updateBook } = await import("../db/database"); try { - const ext = getExt(filePath) || "epub"; + const localRelativePath = canonicalBookFilePath(bookId, filePath); + const ext = getExt(localRelativePath) || "epub"; // Resolve book title for new-path computation. const db = await getDB(); @@ -1270,9 +1279,7 @@ export async function downloadBookFile( }; const appDataDir = await adapter.getAppDataDir(); - const localPath = isAbsoluteOrProtocolPath(filePath) - ? filePath - : adapter.joinPath(appDataDir, filePath); + const localPath = adapter.joinPath(appDataDir, localRelativePath); const reportDownloadProgress = (loaded: number, total: number) => { if (total > 0) onProgress?.({ downloaded: loaded, total }); }; @@ -1361,7 +1368,7 @@ export async function downloadBookFile( } onProgress?.({ downloaded: 100, total: 100 }); - await setBookSyncStatus(bookId, "local"); + await updateBook(bookId, { filePath: localRelativePath, syncStatus: "local" }); console.log(`[Sync] ✓ Book ${bookId} downloaded and marked as local`); return "ok"; } catch (e) {