feat: add support for importing from a directory recursively, closes #179 (#2642)

This commit is contained in:
Huang Xin
2025-12-08 14:16:57 +08:00
committed by GitHub
parent 11bc7497e8
commit ba3f060cc4
44 changed files with 341 additions and 97 deletions
@@ -1,5 +1,4 @@
import clsx from 'clsx';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { IoFileTray } from 'react-icons/io5';
import { MdRssFeed } from 'react-icons/md';
@@ -9,20 +8,26 @@ import Menu from '@/components/Menu';
interface ImportMenuProps {
setIsDropdownOpen?: (open: boolean) => void;
onImportBooks: () => void;
onImportBooksFromFiles: () => void;
onImportBooksFromDirectory?: () => void;
onOpenCatalogManager: () => void;
}
const ImportMenu: React.FC<ImportMenuProps> = ({
setIsDropdownOpen,
onImportBooks,
onImportBooksFromFiles,
onImportBooksFromDirectory,
onOpenCatalogManager,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const handleImportBooks = () => {
onImportBooks();
const handleImportFromFiles = () => {
onImportBooksFromFiles();
setIsDropdownOpen?.(false);
};
const handleImportFromDirectory = () => {
onImportBooksFromDirectory?.();
setIsDropdownOpen?.(false);
};
@@ -33,17 +38,21 @@ const ImportMenu: React.FC<ImportMenuProps> = ({
return (
<Menu
className={clsx(
'dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow',
appService?.isMobile ? 'no-triangle' : 'dropdown-center',
)}
className={clsx('dropdown-content bg-base-100 rounded-box z-[1] mt-3 p-2 shadow')}
onCancel={() => setIsDropdownOpen?.(false)}
>
<MenuItem
label={_('From Local File')}
Icon={<IoFileTray className='h-5 w-5' />}
onClick={handleImportBooks}
onClick={handleImportFromFiles}
/>
{onImportBooksFromDirectory && (
<MenuItem
label={_('From Directory')}
Icon={<IoFileTray className='h-5 w-5' />}
onClick={handleImportFromDirectory}
/>
)}
<MenuItem
label={_('Online Library')}
Icon={<MdRssFeed className='h-5 w-5' />}
@@ -26,7 +26,8 @@ import ViewMenu from './ViewMenu';
interface LibraryHeaderProps {
isSelectMode: boolean;
isSelectAll: boolean;
onImportBooks: () => void;
onImportBooksFromFiles: () => void;
onImportBooksFromDirectory?: () => void;
onOpenCatalogManager: () => void;
onToggleSelectMode: () => void;
onSelectAll: () => void;
@@ -36,7 +37,8 @@ interface LibraryHeaderProps {
const LibraryHeader: React.FC<LibraryHeaderProps> = ({
isSelectMode,
isSelectAll,
onImportBooks,
onImportBooksFromFiles,
onImportBooksFromDirectory,
onOpenCatalogManager,
onToggleSelectMode,
onSelectAll,
@@ -152,14 +154,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<Dropdown
label={_('Import Books')}
className={clsx(
'exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center',
isMobile ? 'dropdown-end' : 'dropdown-center',
'exclude-title-bar-mousedown dropdown-bottom dropdown-center flex h-6 cursor-pointer justify-center',
)}
buttonClassName='p-0 h-6 min-h-6 w-6 flex items-center justify-center !bg-transparent'
buttonClassName='p-0 h-6 min-h-6 w-6 flex touch-target items-center justify-center !bg-transparent'
toggleButton={<PiPlus role='none' className='m-0.5 h-5 w-5' />}
>
<ImportMenu
onImportBooks={onImportBooks}
onImportBooksFromFiles={onImportBooksFromFiles}
onImportBooksFromDirectory={onImportBooksFromDirectory}
onOpenCatalogManager={onOpenCatalogManager}
/>
</Dropdown>
@@ -7,7 +7,6 @@ import {
RiLoader2Line,
} from 'react-icons/ri';
import { documentDir, join } from '@tauri-apps/api/path';
import { invoke, PermissionState } from '@tauri-apps/api/core';
import { relaunch } from '@tauri-apps/plugin-process';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
@@ -20,6 +19,7 @@ import { formatBytes } from '@/utils/book';
import { getOSPlatform } from '@/utils/misc';
import { getExternalSDCardPath } from '@/utils/bridge';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { requestStoragePermission } from '@/utils/permission';
import Dialog from '@/components/Dialog';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/MenuItem';
@@ -42,10 +42,6 @@ interface MigrationProgress {
currentFile?: string;
}
interface Permissions {
manageStorage: PermissionState;
}
export const MigrateDataWindow = () => {
const _ = useTranslation();
const { appService, envConfig } = useEnv();
@@ -158,13 +154,7 @@ export const MigrateDataWindow = () => {
setErrorMessage('');
if (!dir.includes('Android/data')) {
let permission = await invoke<Permissions>('plugin:native-bridge|checkPermissions');
if (permission.manageStorage !== 'granted') {
permission = await invoke<Permissions>(
'plugin:native-bridge|request_manage_storage_permission',
);
}
if (permission.manageStorage !== 'granted') return;
if (!(await requestStoragePermission())) return;
}
try {
@@ -20,6 +20,7 @@ import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/
import { optInTelemetry, optOutTelemetry } from '@/utils/telemetry';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { setMigrateDataDirDialogVisible } from '@/app/library/components/MigrateDataWindow';
import { requestStoragePermission } from '@/utils/permission';
import { saveSysSettings } from '@/helpers/settings';
import { selectDirectory } from '@/utils/bridge';
import UserAvatar from '@/components/UserAvatar';
@@ -175,13 +176,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
};
const handleSetSavedBookCoverForLockScreen = async () => {
let permission = await invoke<Permissions>('plugin:native-bridge|checkPermissions');
if (permission.manageStorage !== 'granted') {
permission = await invoke<Permissions>(
'plugin:native-bridge|request_manage_storage_permission',
);
}
if (permission.manageStorage !== 'granted' && appService?.distChannel === 'readest') return;
if (!(await requestStoragePermission()) && appService?.distChannel === 'readest') return;
const newValue = settings.savedBookCoverForLockScreen ? '' : 'default';
if (newValue) {
+67 -8
View File
@@ -15,7 +15,7 @@ import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/
import { eventDispatcher } from '@/utils/event';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
import { getFilename } from '@/utils/path';
import { getDirPath, getFilename, joinPaths } from '@/utils/path';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
@@ -38,7 +38,9 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
import { lockScreenOrientation } from '@/utils/bridge';
import { lockScreenOrientation, selectDirectory } from '@/utils/bridge';
import { requestStoragePermission } from '@/utils/permission';
import { SUPPORTED_BOOK_EXTS } from '@/services/constants';
import {
tauriHandleClose,
tauriHandleSetAlwaysOnTop,
@@ -143,7 +145,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setSettingsDialogOpen(true);
},
onOpenBooks: () => {
handleImportBooks();
handleImportBooksFromFiles();
},
});
@@ -366,6 +368,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setLoading(true);
const { library } = useLibraryStore.getState();
const failedImports: Array<{ filename: string; errorMessage: string }> = [];
const successfulImports: string[] = [];
const errorMap: [string, string][] = [
['No chapters detected', _('No chapters detected')],
['Failed to parse EPUB', _('Failed to parse the EPUB file')],
@@ -380,15 +383,25 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
if (!file) return;
try {
const book = await appService?.importBook(file, library);
const { path, basePath } = selectedFile;
if (book && groupId) {
book.groupId = groupId;
book.groupName = getGroupName(groupId);
await updateBook(envConfig, book);
} else if (book && path && basePath) {
const rootPath = getDirPath(basePath);
const groupName = getDirPath(path).replace(rootPath, '').replace(/^\//, '');
book.groupName = groupName;
book.groupId = getGroupId(groupName);
await updateBook(envConfig, book);
}
if (user && book && !book.uploadedAt && settings.autoUpload) {
console.log('Uploading book:', book.title);
handleBookUpload(book, false);
}
if (book) {
successfulImports.push(book.title);
}
} catch (error) {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getFilename(filename);
@@ -417,8 +430,17 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
_('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(filenames),
}) + (errorMessage ? `\n${errorMessage}` : ''),
timeout: 5000,
type: 'error',
});
} else if (successfulImports.length > 0) {
eventDispatcher.dispatch('toast', {
message: _('Successfully imported {{count}} book(s)', {
count: successfulImports.length,
}),
timeout: 2000,
type: 'success',
});
}
setLibrary([...library]);
@@ -582,9 +604,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
await updateBook(envConfig, book);
};
const handleImportBooks = async () => {
const handleImportBooksFromFiles = async () => {
setIsSelectMode(false);
console.log('Importing books...');
console.log('Importing books from files...');
selectFiles({ type: 'books', multiple: true }).then((result) => {
if (result.files.length === 0 || result.error) return;
const groupId = searchParams?.get('group') || '';
@@ -592,6 +614,40 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
});
};
const handleImportBooksFromDirectory = async () => {
if (!appService || !isTauriAppPlatform()) return;
setIsSelectMode(false);
console.log('Importing books from directory...');
let importDirectory: string | undefined = '';
if (appService.isAndroidApp) {
if (!(await requestStoragePermission())) return;
const response = await selectDirectory();
importDirectory = response.path;
} else {
const selectedDir = await appService.selectDirectory?.('read');
importDirectory = selectedDir;
}
if (!importDirectory) {
console.log('No directory selected');
return;
}
const files = await appService.readDirectory(importDirectory, 'None');
const supportedFiles = files.filter((file) => {
const ext = file.path.split('.').pop()?.toLowerCase() || '';
return SUPPORTED_BOOK_EXTS.includes(ext);
});
const toImportFiles = await Promise.all(
supportedFiles.map(async (file) => {
return {
path: await joinPaths(importDirectory, file.path),
basePath: importDirectory,
};
}),
);
importBooks(toImportFiles, undefined);
};
const handleSetSelectMode = (selectMode: boolean) => {
if (selectMode && appService?.hasHaptics) {
impactFeedback('medium');
@@ -656,7 +712,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<LibraryHeader
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
onImportBooks={handleImportBooks}
onImportBooksFromFiles={handleImportBooksFromFiles}
onImportBooksFromDirectory={
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
}
onOpenCatalogManager={() => setShowCatalogManager(true)}
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
onSelectAll={handleSelectAll}
@@ -742,7 +801,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
isSelectNone={isSelectNone}
handleImportBooks={handleImportBooks}
handleImportBooks={handleImportBooksFromFiles}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete('both')}
@@ -764,7 +823,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
'Welcome to your library. You can import your books here and read them anytime.',
)}
</p>
<button className='btn btn-primary rounded-xl' onClick={handleImportBooks}>
<button className='btn btn-primary rounded-xl' onClick={handleImportBooksFromFiles}>
{_('Import Books')}
</button>
</div>
@@ -18,6 +18,7 @@ export interface SelectedFile {
// For Tauri file
path?: string;
basePath?: string;
}
export interface FileSelectionResult {
+10 -2
View File
@@ -98,6 +98,7 @@ export abstract class BaseAppService implements AppService {
hasScreenBrightness = false;
hasIAP = false;
canCustomizeRootDir = false;
canReadExternalDir = false;
distChannel = 'readest' as DistChannel;
protected CURRENT_MIGRATION_VERSION = 20251124;
@@ -354,7 +355,6 @@ export abstract class BaseAppService implements AppService {
loadedBook.metadata.title = getBaseFilename(filename);
}
} catch (error) {
console.error(error);
throw new Error(`Failed to open the book: ${(error as Error).message || error}`);
}
@@ -405,7 +405,14 @@ export abstract class BaseAppService implements AppService {
} else if (typeof file === 'string' && isContentURI(file)) {
await this.fs.copyFile(file, getLocalBookFilename(book), 'Books');
} else if (typeof file === 'string' && !isValidURL(file)) {
await this.fs.copyFile(file, getLocalBookFilename(book), 'Books');
try {
// try to copy the file directly first in case of large files to avoid memory issues
// on desktop when reading recursively from selected directory the direct copy will fail
// due to permission issues, then fallback to read and write files
await this.fs.copyFile(file, getLocalBookFilename(book), 'Books');
} catch {
await this.fs.writeFile(getLocalBookFilename(book), 'Books', fileobj);
}
} else {
await this.fs.writeFile(getLocalBookFilename(book), 'Books', fileobj);
}
@@ -441,6 +448,7 @@ export abstract class BaseAppService implements AppService {
return existingBook || book;
} catch (error) {
console.error('Error importing book:', error);
throw error;
}
}
@@ -34,7 +34,7 @@ import {
FileItem,
DistChannel,
} from '@/types/system';
import { getOSPlatform, isContentURI, isValidURL } from '@/utils/misc';
import { getOSPlatform, isContentURI, isFileURI, isValidURL } from '@/utils/misc';
import { getDirPath, getFilename } from '@/utils/path';
import { NativeFile, RemoteFile } from '@/utils/file';
import { copyURIToPath } from '@/utils/bridge';
@@ -212,17 +212,20 @@ export const nativeFileSystem: FileSystem = {
}
return await new NativeFile(dst, fname, baseDir ? baseDir : null).open();
}
} else if (isFileURI(path)) {
return await new NativeFile(fp, fname, baseDir ? baseDir : null).open();
} else {
const prefix = await this.getPrefix(base);
const absolutePath = path.startsWith('/') ? path : prefix ? await join(prefix, path) : null;
if (absolutePath && OS_TYPE !== 'android') {
if (OS_TYPE === 'android') {
// NOTE: RemoteFile is not usable on Android due to a known issue of range request in Android WebView.
// see https://issues.chromium.org/issues/40739128
return await new NativeFile(fp, fname, baseDir ? baseDir : null).open();
} else {
// NOTE: RemoteFile currently performs about 2× faster than NativeFile
// due to an unresolved performance issue in Tauri (see tauri-apps/tauri#9190).
// Once the bug is resolved, we should switch back to using NativeFile.
// RemoteFile is not usable on Android due to unknown issues of range fetch with Android WebView.
const prefix = await this.getPrefix(base);
const absolutePath = prefix ? await join(prefix, path) : path;
return await new RemoteFile(this.getURL(absolutePath), fname).open();
} else {
return await new NativeFile(fp, fname, baseDir ? baseDir : null).open();
}
}
},
@@ -319,10 +322,13 @@ export const nativeFileSystem: FileSystem = {
} else {
const filePath = await join(parent, entry.name);
const relativePath = relative ? await join(relative, entry.name) : entry.name;
const fileInfo = await stat(filePath, baseDir ? { baseDir } : undefined);
const opts = baseDir ? { baseDir } : undefined;
const fileSize = await stat(filePath, opts)
.then((info) => info.size)
.catch(() => 0);
fileList.push({
path: relativePath,
size: fileInfo.size,
size: fileSize,
});
}
}
@@ -374,6 +380,7 @@ export class NativeAppService extends BaseAppService {
// CustomizeRootDir has a blocker on macOS App Store builds due to Security Scoped Resource restrictions.
// See: https://github.com/tauri-apps/tauri/issues/3716
override canCustomizeRootDir = DIST_CHANNEL !== 'appstore';
override canReadExternalDir = DIST_CHANNEL !== 'appstore' && DIST_CHANNEL !== 'playstore';
override distChannel = DIST_CHANNEL;
private execDir?: string = undefined;
+10
View File
@@ -371,6 +371,16 @@ foliate-fxl {
display: block;
}
.touch-target {
position: relative;
}
.touch-target::before {
content: "";
position: absolute;
inset: -12px;
}
.no-context-menu {
-webkit-touch-callout: none;
-webkit-user-select: none;
+1
View File
@@ -66,6 +66,7 @@ export interface AppService {
isPortableApp: boolean;
isDesktopApp: boolean;
canCustomizeRootDir: boolean;
canReadExternalDir: boolean;
distChannel: DistChannel;
init(): Promise<void>;
+5
View File
@@ -1,3 +1,4 @@
import { join } from '@tauri-apps/api/path';
import { isContentURI, isFileURI, isValidURL } from './misc';
export const getFilename = (fileOrUri: string) => {
@@ -28,3 +29,7 @@ export const getDirPath = (filePath: string) => {
parts.pop();
return parts.join('/');
};
export const joinPaths = async (...paths: string[]) => {
return await join(...paths);
};
+15
View File
@@ -0,0 +1,15 @@
import { invoke, PermissionState } from '@tauri-apps/api/core';
interface Permissions {
manageStorage: PermissionState;
}
export const requestStoragePermission = async (): Promise<boolean> => {
let permission = await invoke<Permissions>('plugin:native-bridge|checkPermissions');
if (permission.manageStorage !== 'granted') {
permission = await invoke<Permissions>(
'plugin:native-bridge|request_manage_storage_permission',
);
}
return permission.manageStorage === 'granted';
};