diff --git a/apps/readest-app/src/__tests__/hooks/useAutoImportFolders.test.tsx b/apps/readest-app/src/__tests__/hooks/useAutoImportFolders.test.tsx new file mode 100644 index 00000000..d4b478ca --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useAutoImportFolders.test.tsx @@ -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((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); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/collect-known-source-paths.test.ts b/apps/readest-app/src/__tests__/services/collect-known-source-paths.test.ts new file mode 100644 index 00000000..95005c48 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/collect-known-source-paths.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/select-new-importable-files.test.ts b/apps/readest-app/src/__tests__/services/select-new-importable-files.test.ts new file mode 100644 index 00000000..1bbde0f4 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/select-new-importable-files.test.ts @@ -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[1]> = {}) => ({ + extensions: ['epub', 'pdf', 'mobi'], + minSizeBytes: 20 * 1024, + existingPaths: new Set(), + 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); + }); +}); diff --git a/apps/readest-app/src/app/library/components/ImportFromFolderDialog.tsx b/apps/readest-app/src/app/library/components/ImportFromFolderDialog.tsx index e1f831c3..7e3c6827 100644 --- a/apps/readest-app/src/app/library/components/ImportFromFolderDialog.tsx +++ b/apps/readest-app/src/app/library/components/ImportFromFolderDialog.tsx @@ -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 = ({ initialSelectedGroupIds, initialMinSizeKB, initialReadInPlace = false, + initialAutoImport = false, isRegisteredExternalRoot, onPickDirectory, onCancel, @@ -175,10 +191,16 @@ const ImportFromFolderDialog: React.FC = ({ // in-place by design (the importer's `shouldImportInPlace` check is // path-prefix based and ignores any per-import opt-out). const [readInPlace, setReadInPlace] = useState(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(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 also listens for Back, because @@ -241,6 +263,7 @@ const ImportFromFolderDialog: React.FC = ({ minSizeKB: safeMinSizeKB, flatten: folderMode === 'flatten', readInPlace: effectiveReadInPlace, + autoImport: effectiveAutoImport, }); }; @@ -379,6 +402,27 @@ const ImportFromFolderDialog: React.FC = ({ + {/* 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 && ( + + )} {/* Folder-structure mode — radios let the user choose between diff --git a/apps/readest-app/src/app/library/hooks/useAutoImportFolders.ts b/apps/readest-app/src/app/library/hooks/useAutoImportFolders.ts new file mode 100644 index 00000000..50e635f7 --- /dev/null +++ b/apps/readest-app/src/app/library/hooks/useAutoImportFolders.ts @@ -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; +} + +/** + * 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(); + }); +}; diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index a225fb0f..5efee874 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -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(undefined); const [currentSeriesAuthorGroup, setCurrentSeriesAuthorGroup] = useState<{ @@ -227,6 +237,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP }, []); const containerRef = useRef(null); const pageRef = useRef(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>(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 => { + 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)} diff --git a/apps/readest-app/src/services/backupService.ts b/apps/readest-app/src/services/backupService.ts index 4b0ca662..68b38352 100644 --- a/apps/readest-app/src/services/backupService.ts +++ b/apps/readest-app/src/services/backupService.ts @@ -37,6 +37,7 @@ export const BACKUP_SETTINGS_BLACKLIST = [ 'localBooksDir', 'customRootDir', 'externalLibraryFolders', + 'autoImportFolders', 'savedBookCoverForLockScreenPath', // Per-device identity — restoring causes sync identity / HLC collisions. 'replicaDeviceId', diff --git a/apps/readest-app/src/services/bookService.ts b/apps/readest-app/src/services/bookService.ts index 0a10b520..dda15606 100644 --- a/apps/readest-app/src/services/bookService.ts +++ b/apps/readest-app/src/services/bookService.ts @@ -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; + 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 { + const paths = new Set(); + 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; diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index eb38ec9d..086371d9 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -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;