feat(library): auto-import new books from watched folders (#3889) (#4902)

* feat(library): add autoImportFromFolders setting (default off)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(library): add selectNewImportableFiles folder-scan filter

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(library): add useAutoImportFolders trigger hook

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(library): auto-import new books from watched folders on open and focus

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(library): add Auto Import New Books from Folders toggle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(library): don't resurrect deleted books or re-toast bad files on folder auto-import

- Add collectKnownSourcePaths() pure helper that includes soft-deleted books
- importBooks() accepts { silent } option and returns failedPaths
- autoImportFromWatchedFolders uses collectKnownSourcePaths and session-scoped
  autoImportFailedPathsRef to skip already-failed files on subsequent scans

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(library): make folder auto-import a per-folder option in the import dialog

Replace the global autoImportFromFolders toggle (and its standalone Settings
menu entry) with a per-folder opt-in shown as a sub-option of Read in place in
the Import-from-Folder dialog. Store the watched set as settings.autoImportFolders
(a subset of externalLibraryFolders; device-local, backup-blacklisted). The
library rescan now iterates autoImportFolders instead of a global-gated
externalLibraryFolders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-03 19:14:08 +09:00
committed by GitHub
parent 2680614c15
commit c8e2c95335
9 changed files with 572 additions and 5 deletions
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act, cleanup, renderHook } from '@testing-library/react';
// useWindowActiveChanged reads appService.isDesktopApp to choose its
// subscription; false -> the DOM 'visibilitychange' path (jsdom-friendly).
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: { isDesktopApp: false } }),
}));
import { useAutoImportFolders } from '@/app/library/hooks/useAutoImportFolders';
const DEBOUNCE_MS = 800;
// Let the async window-active subscription settle so its listener is attached.
const settle = async () => {
await act(async () => {
await Promise.resolve();
});
};
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.clearAllMocks();
});
describe('useAutoImportFolders', () => {
test('scans once on mount after the debounce window', async () => {
const scan = vi.fn(async () => {});
renderHook(() =>
useAutoImportFolders({ enabled: true, folders: ['/books'], scanAndImport: scan }),
);
await settle();
expect(scan).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(DEBOUNCE_MS);
});
expect(scan).toHaveBeenCalledTimes(1);
expect(scan).toHaveBeenCalledWith(['/books']);
});
test('does not scan when disabled', async () => {
const scan = vi.fn(async () => {});
renderHook(() =>
useAutoImportFolders({ enabled: false, folders: ['/books'], scanAndImport: scan }),
);
await settle();
await act(async () => {
vi.advanceTimersByTime(DEBOUNCE_MS);
});
expect(scan).not.toHaveBeenCalled();
});
test('does not scan when there are no folders', async () => {
const scan = vi.fn(async () => {});
renderHook(() => useAutoImportFolders({ enabled: true, folders: [], scanAndImport: scan }));
await settle();
await act(async () => {
vi.advanceTimersByTime(DEBOUNCE_MS);
});
expect(scan).not.toHaveBeenCalled();
});
test('re-scans when the app becomes visible again', async () => {
const scan = vi.fn(async () => {});
renderHook(() =>
useAutoImportFolders({ enabled: true, folders: ['/books'], scanAndImport: scan }),
);
await settle();
await act(async () => {
vi.advanceTimersByTime(DEBOUNCE_MS);
});
expect(scan).toHaveBeenCalledTimes(1);
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'));
vi.advanceTimersByTime(DEBOUNCE_MS);
});
expect(scan).toHaveBeenCalledTimes(2);
});
test('coalesces triggers while a scan is in flight', async () => {
let resolveScan: () => void = () => {};
const scan = vi.fn(
() =>
new Promise<void>((r) => {
resolveScan = r;
}),
);
renderHook(() =>
useAutoImportFolders({ enabled: true, folders: ['/books'], scanAndImport: scan }),
);
await settle();
await act(async () => {
vi.advanceTimersByTime(DEBOUNCE_MS);
});
expect(scan).toHaveBeenCalledTimes(1); // pending
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'));
vi.advanceTimersByTime(DEBOUNCE_MS);
});
expect(scan).toHaveBeenCalledTimes(1); // still in flight -> no second run
await act(async () => {
resolveScan();
await Promise.resolve();
});
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'));
vi.advanceTimersByTime(DEBOUNCE_MS);
});
expect(scan).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,63 @@
import { describe, expect, test } from 'vitest';
import { collectKnownSourcePaths } from '@/services/bookService';
import type { Book } from '@/types/book';
function makeBook(overrides: Partial<Book> = {}): Book {
return {
hash: 'bookhash',
format: 'EPUB',
title: 'sample',
author: 'Author',
createdAt: 1,
updatedAt: 1,
deletedAt: null,
downloadedAt: 1,
...overrides,
};
}
describe('collectKnownSourcePaths', () => {
test('includes a live in-place book path', () => {
const book = makeBook({ filePath: '/Users/me/Books/sample.epub' });
const result = collectKnownSourcePaths([book]);
expect(result.has('/Users/me/Books/sample.epub')).toBe(true);
});
test('includes a soft-deleted in-place book path (key regression)', () => {
const book = makeBook({ filePath: '/Users/me/Books/deleted.epub', deletedAt: Date.now() });
const result = collectKnownSourcePaths([book]);
expect(result.has('/Users/me/Books/deleted.epub')).toBe(true);
});
test('excludes a book whose filePath is a URL (http)', () => {
const book = makeBook({ filePath: 'http://example.com/book.epub' });
const result = collectKnownSourcePaths([book]);
expect(result.size).toBe(0);
});
test('excludes a book whose filePath is a URL (https)', () => {
const book = makeBook({ filePath: 'https://example.com/book.epub' });
const result = collectKnownSourcePaths([book]);
expect(result.size).toBe(0);
});
test('excludes a book with no filePath', () => {
const book = makeBook();
const result = collectKnownSourcePaths([book]);
expect(result.size).toBe(0);
});
test('normalizes case on osPlatform macos (key is lowercased)', () => {
const book = makeBook({ filePath: '/Users/Me/Books/Sample.EPUB' });
const result = collectKnownSourcePaths([book], 'macos');
expect(result.has('/users/me/books/sample.epub')).toBe(true);
expect(result.has('/Users/Me/Books/Sample.EPUB')).toBe(false);
});
test('preserves case on osPlatform linux', () => {
const book = makeBook({ filePath: '/home/Me/Books/Sample.epub' });
const result = collectKnownSourcePaths([book], 'linux');
expect(result.has('/home/Me/Books/Sample.epub')).toBe(true);
expect(result.has('/home/me/books/sample.epub')).toBe(false);
});
});
@@ -0,0 +1,68 @@
import { describe, expect, test } from 'vitest';
import { selectNewImportableFiles } from '@/services/bookService';
const entry = (fullPath: string, size = 100_000) => ({ fullPath, size });
describe('selectNewImportableFiles', () => {
const opts = (over: Partial<Parameters<typeof selectNewImportableFiles>[1]> = {}) => ({
extensions: ['epub', 'pdf', 'mobi'],
minSizeBytes: 20 * 1024,
existingPaths: new Set<string>(),
osPlatform: 'linux' as const,
...over,
});
test('keeps only supported extensions (case-insensitive)', () => {
const result = selectNewImportableFiles(
[entry('/b/a.EPUB'), entry('/b/c.pdf'), entry('/b/note.txt'), entry('/b/d.png')],
opts(),
);
expect(result.map((e) => e.fullPath)).toEqual(['/b/a.EPUB', '/b/c.pdf']);
});
test('drops files below the min size', () => {
const result = selectNewImportableFiles(
[entry('/b/tiny.epub', 1024), entry('/b/ok.epub', 30_000)],
opts(),
);
expect(result.map((e) => e.fullPath)).toEqual(['/b/ok.epub']);
});
test('drops files already present (exact path)', () => {
const result = selectNewImportableFiles(
[entry('/b/have.epub'), entry('/b/new.epub')],
opts({ existingPaths: new Set(['/b/have.epub']) }),
);
expect(result.map((e) => e.fullPath)).toEqual(['/b/new.epub']);
});
test('matches existing paths case-insensitively on macos/ios/windows', () => {
const result = selectNewImportableFiles(
[entry('/B/Have.EPUB')],
// existingPaths keys are pre-normalized (lowercased) on these platforms
opts({ osPlatform: 'macos', existingPaths: new Set(['/b/have.epub']) }),
);
expect(result).toEqual([]);
});
test('is case-sensitive on linux/android', () => {
const result = selectNewImportableFiles(
[entry('/B/Have.epub')],
opts({ osPlatform: 'linux', existingPaths: new Set(['/b/have.epub']) }),
);
expect(result.map((e) => e.fullPath)).toEqual(['/B/Have.epub']);
});
test('returns [] when every scanned file is already present (quiet path)', () => {
const result = selectNewImportableFiles(
[entry('/b/a.epub'), entry('/b/b.epub')],
opts({ existingPaths: new Set(['/b/a.epub', '/b/b.epub']) }),
);
expect(result).toEqual([]);
});
test('returns all new files when the library is empty', () => {
const result = selectNewImportableFiles([entry('/b/a.epub'), entry('/b/b.pdf')], opts());
expect(result).toHaveLength(2);
});
});
@@ -69,6 +69,14 @@ export interface ImportFromFolderResult {
* behaviour and leaves the registered folder list untouched.
*/
readInPlace: boolean;
/**
* When `true`, keep this folder watched: on every library open and app
* focus, Readest re-scans it and imports any newly-added books. Recorded
* in `settings.autoImportFolders`. Only meaningful together with
* {@link readInPlace} (auto-import reads books in place), so it is forced
* `false` whenever `readInPlace` is off. Defaults to `false`.
*/
autoImport: boolean;
}
interface ImportFromFolderDialogProps {
@@ -98,6 +106,13 @@ interface ImportFromFolderDialogProps {
* the switch again. Defaults to `false`.
*/
initialReadInPlace?: boolean;
/**
* Initial value for the "Auto-import new books from this folder" checkbox.
* The caller seeds it from whether the shown folder is already in
* `settings.autoImportFolders`, so re-opening the dialog on a watched
* folder shows the box already ticked. Defaults to `false`.
*/
initialAutoImport?: boolean;
/**
* Predicate the dialog uses to decide whether the currently-displayed
* folder is already registered as an external library folder. When
@@ -139,6 +154,7 @@ const ImportFromFolderDialog: React.FC<ImportFromFolderDialogProps> = ({
initialSelectedGroupIds,
initialMinSizeKB,
initialReadInPlace = false,
initialAutoImport = false,
isRegisteredExternalRoot,
onPickDirectory,
onCancel,
@@ -175,10 +191,16 @@ const ImportFromFolderDialog: React.FC<ImportFromFolderDialogProps> = ({
// in-place by design (the importer's `shouldImportInPlace` check is
// path-prefix based and ignores any per-import opt-out).
const [readInPlace, setReadInPlace] = useState<boolean>(initialReadInPlace);
// "Auto-import new books from this folder" — a sub-option of "Read in
// place". Auto-import scans the folder on every open/focus and reads its
// books in place, so it is only offered (and only takes effect) when the
// folder is read in place; `effectiveAutoImport` enforces that.
const [autoImport, setAutoImport] = useState<boolean>(initialAutoImport);
const [picking, setPicking] = useState(false);
const readInPlaceLocked = !!directory && (isRegisteredExternalRoot?.(directory) ?? false);
const effectiveReadInPlace = readInPlaceLocked || readInPlace;
const effectiveAutoImport = effectiveReadInPlace && autoImport;
// Enter to confirm, Escape / Android Back to cancel. We must wire
// `onCancel` even though <Dialog> also listens for Back, because
@@ -241,6 +263,7 @@ const ImportFromFolderDialog: React.FC<ImportFromFolderDialogProps> = ({
minSizeKB: safeMinSizeKB,
flatten: folderMode === 'flatten',
readInPlace: effectiveReadInPlace,
autoImport: effectiveAutoImport,
});
};
@@ -379,6 +402,27 @@ const ImportFromFolderDialog: React.FC<ImportFromFolderDialogProps> = ({
</span>
</span>
</label>
{/* Auto-import sub-option. Only shown when the folder is read in
place — auto-import re-scans and reads books straight from the
folder, so it has no meaning for copied imports. */}
{effectiveReadInPlace && (
<label className='ms-6 flex cursor-pointer items-start gap-2 rounded-md px-1 py-1 text-sm hover:bg-base-200/50'>
<input
type='checkbox'
className='checkbox checkbox-sm mt-0.5'
checked={autoImport}
onChange={(e) => setAutoImport(e.target.checked)}
/>
<span className='select-none'>
<span className='block'>{_('Auto-import new books from this folder')}</span>
<span className='text-base-content/60 block text-xs'>
{_(
'When new books are added to this folder, import them automatically the next time Readest opens or returns to the foreground.',
)}
</span>
</span>
</label>
)}
</div>
{/* Folder-structure mode — radios let the user choose between
@@ -0,0 +1,72 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useWindowActiveChanged } from '@/app/reader/hooks/useWindowActiveChanged';
import { debounce } from '@/utils/debounce';
/** Quiet window collapsing the near-simultaneous mount + focus/visibility triggers. */
const AUTO_IMPORT_DEBOUNCE_MS = 800;
export interface UseAutoImportFoldersOptions {
/**
* Master gate. When false the hook never scans. Compose it in the caller
* from the setting toggle, folder count, library-loaded, and platform checks.
*/
enabled: boolean;
/** Absolute paths to re-scan (settings.externalLibraryFolders). */
folders: string[];
/** Scans the folders and imports any new books. Supplied by the library page. */
scanAndImport: (folders: string[]) => Promise<void>;
}
/**
* Re-scans the user's registered external library folders and imports newly
* added books — on mount and whenever the app regains focus (desktop) or
* becomes visible again (mobile). Local-folder counterpart of
* {@link useLibraryFileSync}. Mount once on the library page. This hook only
* decides *when* to run; the scan/dedup/import lives in `scanAndImport`.
*/
export const useAutoImportFolders = ({
enabled,
folders,
scanAndImport,
}: UseAutoImportFoldersOptions) => {
// Read the latest values at fire time so a toggle-off or folder-list change
// between schedule and fire is honoured without rebuilding the debounced fn.
const enabledRef = useRef(enabled);
const foldersRef = useRef(folders);
const scanRef = useRef(scanAndImport);
const runningRef = useRef(false);
useEffect(() => {
enabledRef.current = enabled;
foldersRef.current = folders;
scanRef.current = scanAndImport;
});
const run = useCallback(async () => {
if (!enabledRef.current || runningRef.current) return;
const targets = foldersRef.current;
if (targets.length === 0) return;
runningRef.current = true;
try {
await scanRef.current(targets);
} catch (e) {
console.error('Auto-import folders: scan failed', e);
} finally {
runningRef.current = false;
}
}, []);
const debouncedRun = useMemo(() => debounce(() => void run(), AUTO_IMPORT_DEBOUNCE_MS), [run]);
// Fire on mount and whenever the gate opens or the folder set changes.
const foldersKey = folders.join('\n');
useEffect(() => {
if (enabled && folders.length > 0) debouncedRun();
return () => debouncedRun.cancel();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, foldersKey, debouncedRun]);
// Fire when the app/window regains focus (desktop) or becomes visible (mobile).
useWindowActiveChanged((isActive) => {
if (isActive) debouncedRun();
});
};
+146 -5
View File
@@ -8,7 +8,12 @@ import { ReadonlyURLSearchParams, useSearchParams } from 'next/navigation';
import { Book } from '@/types/book';
import { AppService, DeleteAction } from '@/types/system';
import { buildBookLookupIndex } from '@/services/bookService';
import {
buildBookLookupIndex,
collectKnownSourcePaths,
normalizeFilePathForIndex,
selectNewImportableFiles,
} from '@/services/bookService';
import { navigateToLibrary, navigateToLogin, navigateToReader } from '@/utils/nav';
import { getBookWithUpdatedMetadata, listFormater } from '@/utils/book';
import { getImportErrorMessage } from '@/services/errors';
@@ -37,6 +42,7 @@ import { useUICSS } from '@/hooks/useUICSS';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useLibraryFileSync } from './hooks/useLibraryFileSync';
import { useAutoImportFolders } from './hooks/useAutoImportFolders';
import { useInboxDrainer } from '@/hooks/useInboxDrainer';
import { useOPDSSubscriptions } from '@/hooks/useOPDSSubscriptions';
import { useBookDataStore } from '@/store/bookDataStore';
@@ -104,6 +110,9 @@ import SettingsDialog from '@/components/settings/SettingsDialog';
import ModalPortal from '@/components/ModalPortal';
import TransferQueuePanel from './components/TransferQueuePanel';
/** Skip tiny non-book artifacts during folder auto-scan (matches the manual import dialog default). */
const AUTO_IMPORT_MIN_SIZE_BYTES = 20 * 1024;
/**
* Key used to persist the last directory the user imported books from.
* Stored in localStorage so re-opening the dialog (even across app
@@ -206,6 +215,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
initialSelectedGroupIds?: string[];
initialMinSizeKB?: number;
initialReadInPlace?: boolean;
initialAutoImport?: boolean;
} | null>(null);
const [currentGroupPath, setCurrentGroupPath] = useState<string | undefined>(undefined);
const [currentSeriesAuthorGroup, setCurrentSeriesAuthorGroup] = useState<{
@@ -227,6 +237,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}, []);
const containerRef = useRef<HTMLDivElement | null>(null);
const pageRef = useRef<HTMLDivElement>(null);
// Tracks paths that failed to import in this session so auto-import does not
// re-attempt (and re-toast) them on every subsequent folder scan.
const autoImportFailedPathsRef = useRef<Set<string>>(new Set());
const getScrollKey = (group: string) => `library-scroll-${group || 'all'}`;
@@ -706,7 +719,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [demoBooks, libraryLoaded]);
const importBooks = async (files: SelectedFile[], groupId?: string) => {
const importBooks = async (
files: SelectedFile[],
groupId?: string,
options: { silent?: boolean } = {},
): Promise<{ failedPaths: string[] }> => {
setLoading(true);
const { library } = useLibraryStore.getState();
// Build the lookup index ONCE per import batch so each book lookup is
@@ -720,6 +737,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// importBook can recognize a re-import of the same file.
const lookupIndex = buildBookLookupIndex(library, appService?.osPlatform);
const failedImports: Array<{ filename: string; errorMessage: string }> = [];
const failedPaths: string[] = [];
const successfulImports: string[] = [];
// Readest's own Books/ prefix is resolved once at app init and persisted
@@ -778,6 +796,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
return book;
} catch (error) {
const filename = typeof file === 'string' ? file : file.name;
if (typeof file === 'string') failedPaths.push(file);
const baseFilename = getFilename(filename);
const errorMessage = error instanceof Error ? _(getImportErrorMessage(error.message)) : '';
failedImports.push({ filename: baseFilename, errorMessage });
@@ -806,9 +825,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
pushLibrary();
if (failedImports.length > 1) {
if (!options.silent && failedImports.length > 1) {
setFailedImportsModal(failedImports);
} else if (failedImports.length === 1) {
} else if (!options.silent && failedImports.length === 1) {
const { filename, errorMessage } = failedImports[0]!;
eventDispatcher.dispatch('toast', {
message:
@@ -818,7 +837,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
timeout: 5000,
type: 'error',
});
} else if (successfulImports.length > 0) {
}
// Surface the success toast when books were imported. In silent (auto-import)
// mode failures are suppressed, so show success independently of them; in
// interactive mode keep the original behaviour (only when nothing failed).
if (successfulImports.length > 0 && (options.silent || failedImports.length === 0)) {
eventDispatcher.dispatch('toast', {
message: _('Successfully imported {{count}} book(s)', {
count: successfulImports.length,
@@ -829,8 +852,79 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}
setLoading(false);
return { failedPaths };
};
/**
* Re-scan the given watched folders (the user's `autoImportFolders`) and
* import any newly-added books. Reuses the same in-place import + dedup as
* manual folder import, but stays quiet: unreadable folders are skipped (no
* toast), and `importBooks` runs only when genuinely-new files exist (its
* success toast then fires).
*/
const autoImportFromWatchedFolders = async (folders: string[]) => {
if (!appService || loading) return;
const { library } = useLibraryStore.getState();
const osPlatform = appService.osPlatform;
// Known local source paths — live AND soft-deleted (files the user deleted
// but whose in-place source is still on disk), plus paths that already failed
// to import this session — so we neither resurrect a deleted book nor
// re-parse/re-toast a bad file on every focus.
const existingPaths = collectKnownSourcePaths(library, osPlatform);
for (const key of autoImportFailedPathsRef.current) existingPaths.add(key);
const newFiles: SelectedFile[] = [];
for (const folder of folders) {
try {
await appService.allowPathsInScopes?.([folder], true);
const items = await appService.readDirectory(folder, 'None');
const entries = await Promise.all(
items.map(async (item) => ({
fullPath: await joinPaths(folder, item.path),
size: item.size,
})),
);
const fresh = selectNewImportableFiles(entries, {
extensions: SUPPORTED_BOOK_EXTS,
minSizeBytes: AUTO_IMPORT_MIN_SIZE_BYTES,
existingPaths,
osPlatform,
});
for (const entry of fresh) {
newFiles.push({ path: entry.fullPath });
// Prevent the same file matching again via a later overlapping folder.
const key = normalizeFilePathForIndex(entry.fullPath, osPlatform);
if (key) existingPaths.add(key);
}
} catch (e) {
// One unreadable/temporarily-missing folder must not abort the others
// or nag the user (unlike the manual path, which nudges a re-pick).
console.error('Auto-import: failed to scan folder', folder, e);
}
}
if (newFiles.length > 0) {
const { failedPaths } = await importBooks(newFiles, undefined, { silent: true });
for (const p of failedPaths) {
const key = normalizeFilePathForIndex(p, osPlatform);
if (key) autoImportFailedPathsRef.current.add(key);
}
}
};
// Local-folder counterpart of useLibraryFileSync: re-scan the folders the
// user opted into auto-import (a subset of externalLibraryFolders, chosen
// per-folder in the Import-from-Folder dialog) and import newly-added books
// on library open and app focus. Desktop + Android only (iOS security-scoped
// bookmarks are out of scope).
useAutoImportFolders({
enabled:
(settings.autoImportFolders?.length ?? 0) > 0 &&
libraryLoaded &&
isTauriAppPlatform() &&
!appService?.isIOSApp,
folders: settings.autoImportFolders ?? [],
scanAndImport: autoImportFromWatchedFolders,
});
const updateBookTransferProgress = throttle((bookHash: string, progress: ProgressPayload) => {
if (progress.total === 0) return;
const progressPct = (progress.progress / progress.total) * 100;
@@ -1083,6 +1177,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// by `runFolderImport` itself via the prefix check, so books
// under a registered folder are imported in-place either way.
readInPlace: false,
// Non-dialog path never opts into auto-import.
autoImport: false,
});
return;
}
@@ -1115,6 +1211,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
? parsedMinSize
: undefined,
initialReadInPlace: storedReadInPlace === '1',
initialAutoImport: isAutoImportFolder(storedDirectory),
});
};
@@ -1219,6 +1316,18 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
return roots.some((r) => normalizeRoot(r) === target);
};
/**
* `true` when `directory` is in `settings.autoImportFolders` after path
* normalization. Seeds the dialog's "Auto-import new books from this
* folder" checkbox so re-opening on a watched folder shows it ticked.
*/
const isAutoImportFolder = (directory: string): boolean => {
const target = normalizeRoot(directory);
if (!target) return false;
const roots = settings.autoImportFolders ?? [];
return roots.some((r) => normalizeRoot(r) === target);
};
/**
* Add `directory` to `settings.externalLibraryFolders` (and persist
* settings) so the ingest layer's `shouldImportInPlace` will pick
@@ -1246,6 +1355,32 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}
};
/**
* Add or remove `directory` from `settings.autoImportFolders` (and persist)
* per the user's per-folder "Auto-import new books from this folder" choice.
* A no-op when the folder is already in the desired state. Errors are
* swallowed — the import itself still succeeds; we just won't watch (or stop
* watching) the folder until the next successful settings write.
*/
const setAutoImportFolder = async (directory: string, enabled: boolean): Promise<void> => {
const target = normalizeRoot(directory);
if (!target) return;
const liveSettings = useSettingsStore.getState().settings;
const existing = liveSettings.autoImportFolders ?? [];
const present = existing.some((r) => normalizeRoot(r) === target);
if (enabled === present) return;
const next = enabled
? [...existing, directory]
: existing.filter((r) => normalizeRoot(r) !== target);
const nextSettings = { ...liveSettings, autoImportFolders: next };
setSettings(nextSettings);
try {
await saveSettings(envConfig, nextSettings);
} catch (e) {
console.error('Failed to persist autoImportFolders update:', e);
}
};
/**
* Recursively scan {@link result.directory}, keep files matching one
* of {@link result.extensions} that are at least
@@ -1292,6 +1427,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
if (result.readInPlace) {
await registerExternalLibraryFolder(result.directory);
}
// Opt this folder into (or out of) auto-import per the dialog's per-folder
// checkbox. `result.autoImport` already implies `readInPlace` (the dialog
// gates it), so registration above has run; unchecking removes the folder
// from the watched set while leaving it registered as read-in-place.
await setAutoImportFolder(result.directory, result.autoImport);
// Re-grant scopes for the directory before scanning. This matters
// when `result.directory` came from somewhere the dialog plugin
@@ -1564,6 +1704,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
initialSelectedGroupIds={importFromFolderState.initialSelectedGroupIds}
initialMinSizeKB={importFromFolderState.initialMinSizeKB}
initialReadInPlace={importFromFolderState.initialReadInPlace}
initialAutoImport={importFromFolderState.initialAutoImport}
isRegisteredExternalRoot={isRegisteredExternalRoot}
onPickDirectory={pickImportDirectory}
onCancel={() => setImportFromFolderState(null)}
@@ -37,6 +37,7 @@ export const BACKUP_SETTINGS_BLACKLIST = [
'localBooksDir',
'customRootDir',
'externalLibraryFolders',
'autoImportFolders',
'savedBookCoverForLockScreenPath',
// Per-device identity — restoring causes sync identity / HLC collisions.
'replicaDeviceId',
@@ -90,6 +90,60 @@ export function normalizeFilePathForIndex(path: string, osPlatform?: OsPlatform)
return caseInsensitive ? n.toLowerCase() : n;
}
export interface ScannedFileEntry {
/** Absolute path, already joined with the folder root. */
fullPath: string;
/** File size in bytes. */
size: number;
}
/**
* From a folder scan, keep only entries that (a) match one of `extensions`
* (lowercased, no leading dot), (b) are at least `minSizeBytes`, and (c) are
* NOT already in the library. Membership is tested against `existingPaths`,
* which the caller builds from `buildBookLookupIndex(...).byFilePath.keys()`
* (those keys are already normalized by `normalizeFilePathForIndex`, so we
* normalize each scanned path the same way before comparing). Pure — no I/O.
*/
export function selectNewImportableFiles(
entries: ScannedFileEntry[],
opts: {
extensions: string[];
minSizeBytes: number;
existingPaths: Set<string>;
osPlatform?: OsPlatform;
},
): ScannedFileEntry[] {
const exts = new Set(opts.extensions.map((e) => e.toLowerCase()));
return entries.filter((entry) => {
const ext = entry.fullPath.split('.').pop()?.toLowerCase() ?? '';
if (!exts.has(ext)) return false;
if (opts.minSizeBytes > 0 && entry.size < opts.minSizeBytes) return false;
const key = normalizeFilePathForIndex(entry.fullPath, opts.osPlatform);
return !!key && !opts.existingPaths.has(key);
});
}
/**
* Collect all known local source paths from the library into a normalized set.
*
* Unlike `buildBookLookupIndex(...).byFilePath`, this includes soft-deleted
* books (`deletedAt` set) so that auto-import does not resurrect a book the
* user intentionally removed from their library.
*
* URL-backed entries (remote books) are excluded — only on-disk paths matter.
*/
export function collectKnownSourcePaths(books: Book[], osPlatform?: OsPlatform): Set<string> {
const paths = new Set<string>();
for (const book of books) {
if (book.filePath && !isValidURL(book.filePath)) {
const key = normalizeFilePathForIndex(book.filePath, osPlatform);
if (key) paths.add(key);
}
}
return paths;
}
export interface CoverContext {
fs: FileSystem;
appPlatform: AppPlatform;
+11
View File
@@ -236,6 +236,17 @@ export interface SystemSettings {
* settings backups via `BACKUP_SETTINGS_BLACKLIST`.
*/
externalLibraryFolders?: string[];
/**
* Absolute paths of the external library folders the user has opted into
* auto-import for. On library open and whenever the app regains focus,
* Readest re-scans each of these and imports any newly-added book files.
* A subset of {@link externalLibraryFolders} (auto-import requires the
* folder to be read in place). Set per-folder from the Import-from-Folder
* dialog. Desktop + Android only. Device-local (paths are meaningful only
* on this filesystem) and excluded from cloud settings backups via
* `BACKUP_SETTINGS_BLACKLIST`.
*/
autoImportFolders?: string[];
keepLogin: boolean;
autoUpload: boolean;