forked from akai/readest
* 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:
@@ -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();
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user