5ac8564e41
* feat(library): add Import from Folder dialog with format/size filters Replaces the silent "import every supported file recursively" behaviour of the directory import menu item with an explicit dialog that lets users pick which formats to include, set a minimum file size, and choose between mirroring subfolders as nested groups (legacy behaviour) or flattening every match into the current library view. The folder, the chosen Folder Structure radio, the ticked File Formats and the File Size threshold are all persisted in localStorage so re-opening the dialog seeds every field with the user's last choice. Cancelling the dialog does not write to storage so an aborted pick won't pollute the next session. Also hides the native number-input spinner via a small .no-spinner utility in globals.css; on macOS WebKit the spin buttons were drawing over the rounded input border and looked broken. The KB suffix now lives inside the input's bordered shell instead of beside it. Two correctness fixes the dialog flow exposed: * The library importer + ingestService now treat groupId as a tri-state — undefined means "don't touch the existing group", '' means "explicitly the library root", any other string means a specific group. Previously a falsy check in both layers conflated '' with undefined, so re-importing a deduped book under flatten mode silently kept its stale groupId/groupName from the prior keep-as-groups run, making the book reappear in the old subfolder group instead of moving into the library root. New regression tests in ingest-service.test.ts cover both the empty-string case and the omitted case. * Imports of arbitrary user paths (e.g. ~/Downloads) now go through a new allow_paths_in_scopes Tauri command that extends both fs_scope and asset_protocol_scope. The dialog plugin only auto-grants fs_scope, so reads through the asset protocol (RemoteFile / convertFileSrc) used to fail with "asset protocol not configured to allow the path". The shim is invoked after every selectFiles / selectDirectory call and once more at the start of runFolderImport so localStorage-restored paths are also covered. Granted scopes persist across restarts via tauri_plugin_persisted_scope. * fixup(library): harden Import-from-Folder scope grant + RTL/dialog polish Three review fixes on top of the Import-from-Folder feature: * lib.rs: refuse to extend asset_protocol_scope for paths not already in fs_scope. Without this gate, any frontend code (XSS via book content, OPDS HTML, dictionary lookups, or a compromised dependency) could call allow_paths_in_scopes with '/' or '~/.ssh' and gain persistent read access to arbitrary user files via the asset protocol — the grant survives restarts thanks to tauri_plugin_persisted_scope. Mirrors the defensive check in dir_scanner.rs. * ImportFromFolderDialog.tsx: migrate from a custom ModalPortal chassis to the project's shared <Dialog> primitive so eink mode auto-removes shadows, mobile gets the bottom-sheet treatment, RTL direction is applied, and focus management is correct. * ImportFromFolderDialog.tsx: swap directional Tailwind utilities for the logical equivalents (text-start, ps-/pe-, rounded-s-, text-end) per DESIGN.md §2.8 — Arabic/Hebrew users were getting a mirrored number-input row with the KB suffix on the wrong side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * i18n(library): translate Import-from-Folder dialog strings across 33 locales Translates the 13 new strings introduced with the Import-from-Folder dialog (folder picker label, format-filter section, size-threshold input, folder-structure radios, OK button, empty-result toast). All 33 supported locales — including RTL fa/he/ar — are now complete; no __STRING_NOT_TRANSLATED__ placeholders remain in the catalog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
7.7 KiB
TypeScript
213 lines
7.7 KiB
TypeScript
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
|
|
|
// transferManager is a singleton with heavy dependencies; mock it so the test
|
|
// only observes whether ingestFile decided to queue an upload.
|
|
vi.mock('@/services/transferManager', () => ({
|
|
transferManager: { queueUpload: vi.fn() },
|
|
}));
|
|
|
|
import { ingestFile } from '@/services/ingestService';
|
|
import { transferManager } from '@/services/transferManager';
|
|
import type { Book } from '@/types/book';
|
|
import type { AppService } from '@/types/system';
|
|
import type { SystemSettings } from '@/types/settings';
|
|
|
|
function makeBook(overrides: Partial<Book> = {}): Book {
|
|
return {
|
|
hash: 'hash1',
|
|
format: 'EPUB',
|
|
title: 'Test Book',
|
|
author: 'Author',
|
|
createdAt: 1000,
|
|
updatedAt: 2000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeDeps(
|
|
over: { importResult?: Book | null; autoUpload?: boolean; isLoggedIn?: boolean } = {},
|
|
) {
|
|
const importResult = over.importResult === undefined ? makeBook() : over.importResult;
|
|
const importBook = vi.fn().mockResolvedValue(importResult);
|
|
const appService = { importBook } as unknown as AppService;
|
|
const settings = { autoUpload: over.autoUpload ?? false } as SystemSettings;
|
|
return { appService, settings, isLoggedIn: over.isLoggedIn ?? false, importBook };
|
|
}
|
|
|
|
describe('ingestFile', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(transferManager.queueUpload).mockClear();
|
|
});
|
|
|
|
test('returns the imported book', async () => {
|
|
const { appService, settings, isLoggedIn } = makeDeps();
|
|
const book = await ingestFile(
|
|
{ file: 'book.epub', books: [] },
|
|
{ appService, settings, isLoggedIn },
|
|
);
|
|
expect(book?.hash).toBe('hash1');
|
|
});
|
|
|
|
test('returns null when importBook returns null', async () => {
|
|
const { appService, settings, isLoggedIn } = makeDeps({ importResult: null });
|
|
const book = await ingestFile(
|
|
{ file: 'book.epub', books: [] },
|
|
{ appService, settings, isLoggedIn },
|
|
);
|
|
expect(book).toBeNull();
|
|
});
|
|
|
|
test('passes the lookup index through to importBook', async () => {
|
|
const { appService, settings, isLoggedIn, importBook } = makeDeps();
|
|
const lookupIndex = { byHash: new Map(), byMetaHash: new Map() } as never;
|
|
await ingestFile(
|
|
{ file: 'book.epub', books: [], lookupIndex },
|
|
{ appService, settings, isLoggedIn },
|
|
);
|
|
expect(importBook).toHaveBeenCalledWith('book.epub', [], { lookupIndex });
|
|
});
|
|
|
|
test('applies groupId and groupName', async () => {
|
|
const { appService, settings, isLoggedIn } = makeDeps();
|
|
const book = await ingestFile(
|
|
{ file: 'book.epub', books: [], groupId: 'g1', groupName: 'Sci-Fi' },
|
|
{ appService, settings, isLoggedIn },
|
|
);
|
|
expect(book?.groupId).toBe('g1');
|
|
expect(book?.groupName).toBe('Sci-Fi');
|
|
});
|
|
|
|
test('clears the group when groupId is the empty string (flatten-into-root)', async () => {
|
|
// A previously-imported book may already carry a groupId/Name from
|
|
// a prior import. Re-importing with an explicit empty groupId
|
|
// should demote it back to the library root rather than silently
|
|
// keeping the stale group — that behaviour matters for the
|
|
// Import-from-Folder dialog's "flatten" mode.
|
|
const { appService, settings, isLoggedIn } = makeDeps({
|
|
importResult: makeBook({ groupId: 'old', groupName: 'Old/Folder' }),
|
|
});
|
|
const book = await ingestFile(
|
|
{ file: 'book.epub', books: [], groupId: '', groupName: undefined },
|
|
{ appService, settings, isLoggedIn },
|
|
);
|
|
expect(book?.groupId).toBe('');
|
|
expect(book?.groupName).toBeUndefined();
|
|
});
|
|
|
|
test('leaves the group untouched when groupId is omitted', async () => {
|
|
// Sanity check for the tri-state contract: undefined groupId means
|
|
// "don't touch the existing group" (used by the inbox drainer and
|
|
// /send page where the user hasn't picked a destination).
|
|
const { appService, settings, isLoggedIn } = makeDeps({
|
|
importResult: makeBook({ groupId: 'keep', groupName: 'Keep/Me' }),
|
|
});
|
|
const book = await ingestFile(
|
|
{ file: 'book.epub', books: [] },
|
|
{ appService, settings, isLoggedIn },
|
|
);
|
|
expect(book?.groupId).toBe('keep');
|
|
expect(book?.groupName).toBe('Keep/Me');
|
|
});
|
|
|
|
test('applies a subject tag and bumps updatedAt', async () => {
|
|
const { appService, settings, isLoggedIn } = makeDeps();
|
|
const book = await ingestFile(
|
|
{ file: 'book.epub', books: [], subjectTag: 'scifi' },
|
|
{ appService, settings, isLoggedIn },
|
|
);
|
|
expect(book?.tags).toContain('scifi');
|
|
expect(book?.updatedAt).toBeGreaterThan(2000);
|
|
});
|
|
|
|
test('does not duplicate an existing tag or bump updatedAt', async () => {
|
|
const { appService, settings, isLoggedIn } = makeDeps({
|
|
importResult: makeBook({ tags: ['scifi'], updatedAt: 2000 }),
|
|
});
|
|
const book = await ingestFile(
|
|
{ file: 'book.epub', books: [], subjectTag: 'scifi' },
|
|
{ appService, settings, isLoggedIn },
|
|
);
|
|
expect(book?.tags).toEqual(['scifi']);
|
|
expect(book?.updatedAt).toBe(2000);
|
|
});
|
|
|
|
test('forceUpload queues an upload even when autoUpload is off', async () => {
|
|
const { appService, settings, isLoggedIn } = makeDeps({
|
|
autoUpload: false,
|
|
isLoggedIn: true,
|
|
});
|
|
await ingestFile(
|
|
{ file: 'book.epub', books: [], forceUpload: true },
|
|
{ appService, settings, isLoggedIn },
|
|
);
|
|
expect(transferManager.queueUpload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('autoUpload queues an upload without forceUpload', async () => {
|
|
const { appService, settings, isLoggedIn } = makeDeps({
|
|
autoUpload: true,
|
|
isLoggedIn: true,
|
|
});
|
|
await ingestFile({ file: 'book.epub', books: [] }, { appService, settings, isLoggedIn });
|
|
expect(transferManager.queueUpload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('does not queue an upload when neither forceUpload nor autoUpload is set', async () => {
|
|
const { appService, settings, isLoggedIn } = makeDeps({
|
|
autoUpload: false,
|
|
isLoggedIn: true,
|
|
});
|
|
await ingestFile({ file: 'book.epub', books: [] }, { appService, settings, isLoggedIn });
|
|
expect(transferManager.queueUpload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('does not queue an upload when the user is not logged in', async () => {
|
|
const { appService, settings, isLoggedIn } = makeDeps({
|
|
autoUpload: true,
|
|
isLoggedIn: false,
|
|
});
|
|
await ingestFile(
|
|
{ file: 'book.epub', books: [], forceUpload: true },
|
|
{ appService, settings, isLoggedIn },
|
|
);
|
|
expect(transferManager.queueUpload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('never queues an upload for a transient import', async () => {
|
|
const { appService, settings, isLoggedIn } = makeDeps({
|
|
autoUpload: true,
|
|
isLoggedIn: true,
|
|
});
|
|
await ingestFile(
|
|
{ file: 'book.epub', books: [], transient: true, forceUpload: true },
|
|
{ appService, settings, isLoggedIn },
|
|
);
|
|
expect(transferManager.queueUpload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('passes the transient flag through to importBook', async () => {
|
|
const { appService, settings, isLoggedIn, importBook } = makeDeps();
|
|
await ingestFile(
|
|
{ file: 'book.epub', books: [], transient: true },
|
|
{ appService, settings, isLoggedIn },
|
|
);
|
|
expect(importBook).toHaveBeenCalledWith('book.epub', [], {
|
|
lookupIndex: undefined,
|
|
transient: true,
|
|
});
|
|
});
|
|
|
|
test('does not queue an upload when the book is already uploaded', async () => {
|
|
const { appService, settings, isLoggedIn } = makeDeps({
|
|
importResult: makeBook({ uploadedAt: 5000 }),
|
|
autoUpload: true,
|
|
isLoggedIn: true,
|
|
});
|
|
await ingestFile(
|
|
{ file: 'book.epub', books: [], forceUpload: true },
|
|
{ appService, settings, isLoggedIn },
|
|
);
|
|
expect(transferManager.queueUpload).not.toHaveBeenCalled();
|
|
});
|
|
});
|