Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/core/src/sync/__tests__/simple-sync.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down Expand Up @@ -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 }));
Expand Down
41 changes: 38 additions & 3 deletions packages/core/src/sync/__tests__/sync-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/sync/local-book-paths.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
17 changes: 16 additions & 1 deletion packages/core/src/sync/simple-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -408,7 +409,8 @@ async function upsertRecord(
record: Record<string, unknown>,
pk: string,
): Promise<void> {
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;

Expand All @@ -435,6 +437,19 @@ async function upsertRecord(
);
}

function localizeSyncedBookRecord(record: Record<string, unknown>): Record<string, unknown> {
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;
Expand Down
35 changes: 21 additions & 14 deletions packages/core/src/sync/sync-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -340,7 +343,7 @@ export async function syncFiles(
let filesDownloadFailed = 0;

const books = await db.select<BookRow>(
"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",
[],
);

Expand All @@ -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
Expand All @@ -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,
};
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1242,10 +1250,11 @@ export async function downloadBookFile(
onProgress?: (progress: { downloaded: number; total: number }) => void,
): Promise<DownloadBookOutcome> {
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();
Expand All @@ -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 });
};
Expand Down Expand Up @@ -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) {
Expand Down