feat(library): add Import from Folder dialog with format/size filters (#4229)

* feat(library): add Import from Folder dialog with format/size filters

Replaces the silent "import every supported file recursively" behaviour of the directory import menu item with an explicit dialog that lets users pick which formats to include, set a minimum file size, and choose between mirroring subfolders as nested groups (legacy behaviour) or flattening every match into the current library view.

The folder, the chosen Folder Structure radio, the ticked File Formats and the File Size threshold are all persisted in localStorage so re-opening the dialog seeds every field with the user's last choice. Cancelling the dialog does not write to storage so an aborted pick won't pollute the next session.

Also hides the native number-input spinner via a small .no-spinner utility in globals.css; on macOS WebKit the spin buttons were drawing over the rounded input border and looked broken. The KB suffix now lives inside the input's bordered shell instead of beside it.

Two correctness fixes the dialog flow exposed:

* The library importer + ingestService now treat groupId as a tri-state — undefined means "don't touch the existing group", '' means "explicitly the library root", any other string means a specific group. Previously a falsy check in both layers conflated '' with undefined, so re-importing a deduped book under flatten mode silently kept its stale groupId/groupName from the prior keep-as-groups run, making the book reappear in the old subfolder group instead of moving into the library root. New regression tests in ingest-service.test.ts cover both the empty-string case and the omitted case.

* Imports of arbitrary user paths (e.g. ~/Downloads) now go through a new allow_paths_in_scopes Tauri command that extends both fs_scope and asset_protocol_scope. The dialog plugin only auto-grants fs_scope, so reads through the asset protocol (RemoteFile / convertFileSrc) used to fail with "asset protocol not configured to allow the path". The shim is invoked after every selectFiles / selectDirectory call and once more at the start of runFolderImport so localStorage-restored paths are also covered. Granted scopes persist across restarts via tauri_plugin_persisted_scope.

* fixup(library): harden Import-from-Folder scope grant + RTL/dialog polish

Three review fixes on top of the Import-from-Folder feature:

* lib.rs: refuse to extend asset_protocol_scope for paths not already
  in fs_scope. Without this gate, any frontend code (XSS via book
  content, OPDS HTML, dictionary lookups, or a compromised dependency)
  could call allow_paths_in_scopes with '/' or '~/.ssh' and gain
  persistent read access to arbitrary user files via the asset
  protocol — the grant survives restarts thanks to
  tauri_plugin_persisted_scope. Mirrors the defensive check in
  dir_scanner.rs.

* ImportFromFolderDialog.tsx: migrate from a custom ModalPortal chassis
  to the project's shared <Dialog> primitive so eink mode auto-removes
  shadows, mobile gets the bottom-sheet treatment, RTL direction is
  applied, and focus management is correct.

* ImportFromFolderDialog.tsx: swap directional Tailwind utilities for
  the logical equivalents (text-start, ps-/pe-, rounded-s-, text-end)
  per DESIGN.md §2.8 — Arabic/Hebrew users were getting a mirrored
  number-input row with the KB suffix on the wrong side.

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

* i18n(library): translate Import-from-Folder dialog strings across 33 locales

Translates the 13 new strings introduced with the Import-from-Folder
dialog (folder picker label, format-filter section, size-threshold
input, folder-structure radios, OK button, empty-result toast). All
33 supported locales — including RTL fa/he/ar — are now complete; no
__STRING_NOT_TRANSLATED__ placeholders remain in the catalog.

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

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
loveheaven
2026-05-20 20:51:53 +08:00
committed by GitHub
parent ded64159b6
commit 5ac8564e41
41 changed files with 1173 additions and 60 deletions
@@ -77,6 +77,38 @@ describe('ingestFile', () => {
expect(book?.groupName).toBe('Sci-Fi');
});
test('clears the group when groupId is the empty string (flatten-into-root)', async () => {
// A previously-imported book may already carry a groupId/Name from
// a prior import. Re-importing with an explicit empty groupId
// should demote it back to the library root rather than silently
// keeping the stale group — that behaviour matters for the
// Import-from-Folder dialog's "flatten" mode.
const { appService, settings, isLoggedIn } = makeDeps({
importResult: makeBook({ groupId: 'old', groupName: 'Old/Folder' }),
});
const book = await ingestFile(
{ file: 'book.epub', books: [], groupId: '', groupName: undefined },
{ appService, settings, isLoggedIn },
);
expect(book?.groupId).toBe('');
expect(book?.groupName).toBeUndefined();
});
test('leaves the group untouched when groupId is omitted', async () => {
// Sanity check for the tri-state contract: undefined groupId means
// "don't touch the existing group" (used by the inbox drainer and
// /send page where the user hasn't picked a destination).
const { appService, settings, isLoggedIn } = makeDeps({
importResult: makeBook({ groupId: 'keep', groupName: 'Keep/Me' }),
});
const book = await ingestFile(
{ file: 'book.epub', books: [] },
{ appService, settings, isLoggedIn },
);
expect(book?.groupId).toBe('keep');
expect(book?.groupName).toBe('Keep/Me');
});
test('applies a subject tag and bumps updatedAt', async () => {
const { appService, settings, isLoggedIn } = makeDeps();
const book = await ingestFile(
@@ -0,0 +1,365 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import { MdFolderOpen } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import Dialog from '@/components/Dialog';
/**
* Per-extension grouping presented to the user. Each card is a single
* checkbox in the dialog whose underlying value is one or more entries
* from {@link import('@/services/constants').SUPPORTED_BOOK_EXTS}. The
* label is what the user sees; `exts` is what the importer filters on.
*
* Keep order roughly aligned with the design mockup so the muscle memory
* of users coming from the prior screenshot still works (EPUB and PDF
* first, common eBook formats in the middle, archives at the end).
*/
export interface FormatGroup {
id: string;
label: string;
/** lower-case extensions without the leading dot. */
exts: string[];
}
export const DEFAULT_FORMAT_GROUPS: FormatGroup[] = [
{ id: 'epub', label: 'EPUB', exts: ['epub'] },
{ id: 'pdf', label: 'PDF', exts: ['pdf'] },
{ id: 'mobi', label: 'MOBI/AZW/AZW3', exts: ['mobi', 'azw', 'azw3'] },
{ id: 'fb2', label: 'FB2', exts: ['fb2'] },
{ id: 'cbz', label: 'CBZ/ZIP', exts: ['cbz', 'zip'] },
{ id: 'txt', label: 'TXT', exts: ['txt'] },
];
export interface ImportFromFolderResult {
directory: string;
/** Lower-case file extensions (without the leading dot) to include. */
extensions: string[];
/**
* IDs of the {@link FormatGroup}s the user ticked. Forwarded so the
* caller can persist the user's selection at the group level (rather
* than having to reverse-engineer it from {@link extensions}).
*/
selectedGroupIds: string[];
/**
* Minimum file size in KB — files strictly smaller than `minSizeKB *
* 1024` bytes are skipped by the importer. `0` keeps everything.
* Stored in KB (not bytes) because that matches the dialog's input
* and the persistence format; callers that need bytes should
* multiply by 1024 themselves.
*/
minSizeKB: number;
/**
* When `false` (default), each first-level subfolder under
* {@link directory} becomes its own library group, mirroring the
* folder structure. When `true`, every matching file is dropped
* directly into the library root without creating any groups.
*/
flatten: boolean;
}
interface ImportFromFolderDialogProps {
/** Initial directory shown in the path field; the user can change it. */
initialDirectory: string;
/**
* Initial value for the folder-structure radios. Persisted by the
* caller across dialog opens so users don't have to re-pick the same
* mode every time. Defaults to `'keep'` when omitted.
*/
initialFolderMode?: 'keep' | 'flatten';
/**
* Initial set of {@link FormatGroup.id}s to mark as checked. Persisted
* by the caller. Falls back to a sensible "EPUB + PDF" default when
* omitted (or when the persisted set is empty, since no boxes ticked
* would block the OK button immediately on dialog open).
*/
initialSelectedGroupIds?: string[];
/**
* Initial value for the "File size larger than" input, in KB.
* Defaults to 20 KB when omitted.
*/
initialMinSizeKB?: number;
/**
* Pop the platform's native folder picker and return the chosen path,
* or `undefined` when the user cancels. Required because folder
* pickers vary between desktop (Tauri dialog) and Android (SAF) and
* the dialog itself shouldn't reach into platform code.
*/
onPickDirectory: () => Promise<string | undefined>;
onCancel: () => void;
onConfirm: (result: ImportFromFolderResult) => void;
}
const DEFAULT_SELECTED_GROUP_IDS = ['epub', 'pdf'];
const DEFAULT_MIN_SIZE_KB = 20;
/**
* Folder import dialog: lets the user pick a directory, choose which
* book formats to include, and skip files below a size threshold. The
* caller is responsible for the actual scan & import — we just collect
* the user's intent and hand it back via {@link onConfirm}.
*
* Renders inside the project's shared `<Dialog>` primitive so it picks
* up the standard chassis (modal-box, eink-aware borders, mobile bottom
* sheet, RTL direction, focus management) instead of reimplementing
* them locally.
*/
const ImportFromFolderDialog: React.FC<ImportFromFolderDialogProps> = ({
initialDirectory,
initialFolderMode = 'keep',
initialSelectedGroupIds,
initialMinSizeKB,
onPickDirectory,
onCancel,
onConfirm,
}) => {
const _ = useTranslation();
const [directory, setDirectory] = useState(initialDirectory);
// EPUB + PDF default to selected; this matches the screenshot the
// feature was modelled on and reflects the two formats new users are
// overwhelmingly most likely to have on disk. A persisted empty set
// is treated as "use defaults" so the OK button isn't disabled on
// dialog open.
const [selectedGroups, setSelectedGroups] = useState<Set<string>>(() => {
const valid = (initialSelectedGroupIds ?? []).filter((id) =>
DEFAULT_FORMAT_GROUPS.some((g) => g.id === id),
);
return new Set(valid.length > 0 ? valid : DEFAULT_SELECTED_GROUP_IDS);
});
const [minSizeKB, setMinSizeKB] = useState<number>(
Number.isFinite(initialMinSizeKB) && (initialMinSizeKB as number) >= 0
? (initialMinSizeKB as number)
: DEFAULT_MIN_SIZE_KB,
);
// `keep` mirrors folders into nested groups (legacy behaviour);
// `flatten` drops every book straight into the current library
// root regardless of where it lived on disk. The caller seeds this
// from localStorage so the user's last choice is restored.
const [folderMode, setFolderMode] = useState<'keep' | 'flatten'>(initialFolderMode);
const [picking, setPicking] = useState(false);
// Enter to confirm. Escape is handled by <Dialog> via onClose.
useKeyDownActions({
onConfirm: () => {
// Block the Enter shortcut while a folder pick is in flight so
// we don't dispatch a confirm with a stale directory.
if (picking) return;
handleConfirm();
},
});
const toggleGroup = (id: string) => {
setSelectedGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const handlePickDirectory = async () => {
if (picking) return;
setPicking(true);
try {
const picked = await onPickDirectory();
if (picked) setDirectory(picked);
} finally {
setPicking(false);
}
};
const handleConfirm = () => {
if (!directory) return;
const exts: string[] = [];
const selectedIds: string[] = [];
for (const g of DEFAULT_FORMAT_GROUPS) {
if (selectedGroups.has(g.id)) {
exts.push(...g.exts);
selectedIds.push(g.id);
}
}
if (exts.length === 0) return;
const safeMinSizeKB = Math.max(0, Math.floor(minSizeKB));
onConfirm({
directory,
extensions: exts,
selectedGroupIds: selectedIds,
minSizeKB: safeMinSizeKB,
flatten: folderMode === 'flatten',
});
};
const confirmDisabled = !directory || selectedGroups.size === 0;
return (
<Dialog
isOpen
title={_('Import Books')}
onClose={onCancel}
boxClassName='sm:min-w-[480px] sm:max-w-[480px] sm:h-auto sm:max-h-[90%]'
contentClassName='!px-6 !py-2'
>
<div className='flex flex-col gap-4 pt-2'>
{/* Directory row — clickable input that pops the native folder
picker. We render it as a real <button> so screen readers and
keyboard navigation work, but style it as an input row so the
visual matches the original screenshot's design. */}
<div className='flex flex-col gap-1.5'>
<span className='text-base-content/70 text-xs'>{_('Folder')}</span>
<button
type='button'
onClick={handlePickDirectory}
disabled={picking}
className={clsx(
'eink-bordered flex w-full items-center gap-2 rounded-lg px-3 py-2.5',
'text-start text-sm transition-colors duration-150',
'border-base-300 bg-base-200/40 hover:bg-base-200/70',
'focus-visible:ring-primary/40 focus-visible:outline-none focus-visible:ring-2',
picking && 'opacity-60',
)}
title={directory || _('Choose a folder')}
aria-label={_('Choose a folder')}
>
<MdFolderOpen className='text-base-content/70 h-5 w-5 flex-shrink-0' />
<span className={clsx('min-w-0 flex-1 truncate', !directory && 'text-base-content/50')}>
{directory || _('Choose a folder')}
</span>
</button>
</div>
{/* Format checkboxes — laid out as a 2-column grid so 6 entries
fit in three rows on phones without horizontal scrolling. */}
<div className='flex flex-col gap-1.5'>
<span className='text-base-content/70 text-xs'>{_('File Formats')}</span>
<div className='grid grid-cols-2 gap-x-3 gap-y-2'>
{DEFAULT_FORMAT_GROUPS.map((group) => {
const checked = selectedGroups.has(group.id);
return (
<label
key={group.id}
className={clsx(
'flex cursor-pointer items-center gap-2',
'rounded-md px-1 py-1 text-sm',
'hover:bg-base-200/50',
)}
>
<input
type='checkbox'
className='checkbox checkbox-sm'
checked={checked}
onChange={() => toggleGroup(group.id)}
/>
<span className='select-none'>{group.label}</span>
</label>
);
})}
</div>
</div>
{/* Min-size filter — number input with the KB suffix nested
inside the field so it reads as a single unit. The native
number-spinner arrows are hidden because they overlapped
the rounded input border and looked broken on macOS / WebKit. */}
<div className='flex items-center justify-between gap-3'>
<span className='text-sm'>{_('File size larger than')}</span>
<div
className={clsx(
'eink-bordered flex items-center',
'border-base-300 bg-base-200/40',
'h-9 w-24 rounded-lg',
'focus-within:ring-primary/40 focus-within:ring-2',
)}
>
<input
type='number'
inputMode='numeric'
min={0}
step={1}
value={Number.isFinite(minSizeKB) ? minSizeKB : 0}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
setMinSizeKB(Number.isFinite(v) && v >= 0 ? v : 0);
}}
className={clsx(
'no-spinner',
'h-full min-w-0 flex-1 rounded-s-lg bg-transparent',
'ps-2 pe-1 text-end text-sm',
'focus:outline-none',
)}
aria-label={_('Minimum file size (KB)')}
/>
<span className='text-base-content/70 select-none pe-2 text-xs'>{_('KB')}</span>
</div>
</div>
{/* Folder-structure mode — radios let the user choose between
mirroring subfolders as nested library groups (legacy) or
flattening everything straight into the library. */}
<div className='flex flex-col gap-1.5' role='radiogroup' aria-label={_('Folder Structure')}>
<span className='text-base-content/70 text-xs'>{_('Folder Structure')}</span>
<label
className={clsx(
'flex cursor-pointer items-start gap-2 rounded-md px-1 py-1 text-sm',
'hover:bg-base-200/50',
)}
>
<input
type='radio'
name='import-folder-mode'
className='radio radio-sm mt-0.5'
checked={folderMode === 'keep'}
onChange={() => setFolderMode('keep')}
/>
<span className='select-none'>
<span className='block'>{_('Create groups from subfolders')}</span>
<span className='text-base-content/60 block text-xs'>
{_('Each first-level subfolder becomes a library group.')}
</span>
</span>
</label>
<label
className={clsx(
'flex cursor-pointer items-start gap-2 rounded-md px-1 py-1 text-sm',
'hover:bg-base-200/50',
)}
>
<input
type='radio'
name='import-folder-mode'
className='radio radio-sm mt-0.5'
checked={folderMode === 'flatten'}
onChange={() => setFolderMode('flatten')}
/>
<span className='select-none'>
<span className='block'>{_('Import all into library')}</span>
<span className='text-base-content/60 block text-xs'>
{_('Recursively add every matching file directly to the library.')}
</span>
</span>
</label>
</div>
<div className='mt-1 flex justify-end gap-2 pb-2'>
<button type='button' className='btn btn-ghost btn-sm' onClick={onCancel}>
{_('Cancel')}
</button>
<button
type='button'
className={clsx('btn btn-primary btn-sm', confirmDisabled && 'btn-disabled')}
disabled={confirmDisabled}
onClick={handleConfirm}
>
{_('OK')}
</button>
</div>
</div>
</Dialog>
);
};
export default ImportFromFolderDialog;
+206 -25
View File
@@ -81,6 +81,9 @@ import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
import LibraryEmptyState from './components/LibraryEmptyState';
import GroupHeader from './components/GroupHeader';
import ImportFromFolderDialog, {
ImportFromFolderResult,
} from './components/ImportFromFolderDialog';
import useShortcuts from '@/hooks/useShortcuts';
import { useReplicaPull } from '@/hooks/useReplicaPull';
import { useCustomFonts } from '@/hooks/useCustomFonts';
@@ -89,6 +92,31 @@ import SettingsDialog from '@/components/settings/SettingsDialog';
import ModalPortal from '@/components/ModalPortal';
import TransferQueuePanel from './components/TransferQueuePanel';
/**
* Key used to persist the last directory the user imported books from.
* Stored in localStorage so re-opening the dialog (even across app
* restarts) seeds the path field with their previous choice — this
* mirrors the behaviour of native file pickers on most desktop OSes.
*/
const LAST_IMPORT_FOLDER_KEY = 'readest:lastImportFolder';
/**
* Key used to persist the user's last "Folder Structure" choice
* ('keep' vs 'flatten'). Restored as the default radio selection on
* the next dialog open.
*/
const LAST_IMPORT_FOLDER_MODE_KEY = 'readest:lastImportFolderMode';
/**
* Key used to persist the comma-separated list of FormatGroup ids the
* user last ticked, e.g. "epub,pdf". Empty / missing falls back to the
* dialog's built-in default ("epub,pdf").
*/
const LAST_IMPORT_FOLDER_FORMATS_KEY = 'readest:lastImportFolderFormats';
/**
* Key used to persist the last "File size larger than" threshold (KB).
* Stored as a stringified non-negative integer.
*/
const LAST_IMPORT_FOLDER_MIN_SIZE_KEY = 'readest:lastImportFolderMinSizeKB';
const LibraryPageWithSearchParams = () => {
const searchParams = useSearchParams();
return <LibraryPageContent searchParams={searchParams} />;
@@ -141,6 +169,16 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const [isSelectAll, setIsSelectAll] = useState(false);
const [isSelectNone, setIsSelectNone] = useState(false);
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
// "Import from folder" dialog state. Held as a small object rather
// than a boolean because we need a default starting directory to seed
// the path field, and we want the dialog to remain mounted long
// enough for the platform's folder picker to overlay it.
const [importFromFolderState, setImportFromFolderState] = useState<{
initialDirectory: string;
initialFolderMode: 'keep' | 'flatten';
initialSelectedGroupIds?: string[];
initialMinSizeKB?: number;
} | null>(null);
const [currentGroupPath, setCurrentGroupPath] = useState<string | undefined>(undefined);
const [currentSeriesAuthorGroup, setCurrentSeriesAuthorGroup] = useState<{
groupBy: typeof LibraryGroupByType.Series | typeof LibraryGroupByType.Author;
@@ -595,9 +633,18 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
if (!appService) return null;
try {
const { path, basePath } = selectedFile;
// `groupId` is treated as a tri-state:
// - undefined → caller didn't specify; derive grouping from
// basePath (Import-from-Folder "keep" mode).
// - '' (empty) → caller explicitly wants the library root.
// - any string → caller explicitly wants that group.
// Distinguishing '' from undefined matters for re-imports of an
// already-known book: without it, a falsy check would silently
// keep the existingBook's stale groupId/groupName from a prior
// import instead of moving the book to the root.
let resolvedGroupId = groupId;
let resolvedGroupName = groupId ? getGroupName(groupId) : undefined;
if (!resolvedGroupId && path && basePath) {
let resolvedGroupName = groupId !== undefined ? getGroupName(groupId) : undefined;
if (resolvedGroupId === undefined && path && basePath) {
const rootPath = getDirPath(basePath);
resolvedGroupName = getDirPath(path).replace(rootPath, '').replace(/^\//, '');
resolvedGroupId = getGroupId(resolvedGroupName);
@@ -841,36 +888,133 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
if (!appService || !isTauriAppPlatform()) return;
setIsSelectMode(false);
console.log('Importing books from directory...');
let importDirectory: string | undefined = dirPath;
if (!importDirectory) {
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');
// When a path is supplied (e.g. URL ingress / drag-drop replay) we
// honour the legacy "import everything" behaviour without opening
// the dialog. Manual menu invocations always go through the dialog
// so users can pick formats and a size threshold before scanning.
if (dirPath) {
await runFolderImport({
directory: dirPath,
extensions: SUPPORTED_BOOK_EXTS.slice(),
// The non-dialog path is invoked by URL ingress / drag-drop
// replay, where the user never picked any filter — keep the
// synthetic values minimal and non-restrictive.
selectedGroupIds: [],
minSizeKB: 0,
flatten: false,
});
return;
}
const files = await appService.readDirectory(importDirectory, 'None');
const supportedFiles = files.filter((file) => {
// Restore both the last-used folder and the last folder-structure
// mode from localStorage. Anything else (or first-time use) falls
// back to the dialog's built-in defaults.
const ls = typeof window !== 'undefined' ? window.localStorage : null;
const storedDirectory = ls?.getItem(LAST_IMPORT_FOLDER_KEY) || '';
const storedMode = ls?.getItem(LAST_IMPORT_FOLDER_MODE_KEY);
const storedFormats = ls?.getItem(LAST_IMPORT_FOLDER_FORMATS_KEY);
const storedMinSize = ls?.getItem(LAST_IMPORT_FOLDER_MIN_SIZE_KEY);
const parsedFormats = storedFormats
? storedFormats
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined;
const parsedMinSize =
storedMinSize !== null && storedMinSize !== undefined
? Number.parseInt(storedMinSize, 10)
: undefined;
setImportFromFolderState({
initialDirectory: storedDirectory,
initialFolderMode: storedMode === 'flatten' ? 'flatten' : 'keep',
initialSelectedGroupIds: parsedFormats,
initialMinSizeKB:
parsedMinSize !== undefined && Number.isFinite(parsedMinSize) && parsedMinSize >= 0
? parsedMinSize
: undefined,
});
};
/**
* Pop the platform's native folder picker. Wrapped here (rather than
* inlined into the dialog) so the same Android-permission / Tauri
* dialog dance is shared between the dialog's "change folder" button
* and any future programmatic import paths.
*/
const pickImportDirectory = async (): Promise<string | undefined> => {
if (!appService) return undefined;
if (appService.isAndroidApp) {
if (!(await requestStoragePermission())) return undefined;
const response = await selectDirectory();
return response.path || undefined;
}
return (await appService.selectDirectory?.('read')) || undefined;
};
/**
* Recursively scan {@link result.directory}, keep files matching one
* of {@link result.extensions} that are at least
* {@link result.minSizeKB} KB, and feed them through {@link importBooks}.
*
* Two cooperating signals carry "where should the imported books
* end up" downstream:
* 1. Each {@link SelectedFile}'s `basePath` — when present,
* {@link importBooks}' `processFile` derives a nested groupName
* relative to it (`<sub>` / `<sub>/<deeper>`).
* 2. The `groupId` argument passed to {@link importBooks} —
* tri-state per the comment in `processFile`. An explicit
* string (including '') wins over basePath-derived grouping.
*
* The two flatten/keep modes use these signals as follows:
* - keep → omit basePath? no, *include* basePath; pass
* groupId=undefined so basePath wins.
* - flatten → omit basePath AND pass an explicit groupId equal to
* the user's currently-viewed group ('' = root). The
* omitted basePath alone wouldn't be enough on a
* re-import, since deduped books carry stale groupIds
* from prior sessions; the explicit groupId is what
* actually reseats them. Dropping basePath in flatten
* mode is therefore belt-and-suspenders.
*/
const runFolderImport = async (result: ImportFromFolderResult) => {
if (!appService || !result.directory) return;
// Re-grant scopes for the directory before scanning. This matters
// when `result.directory` came from somewhere the dialog plugin
// didn't authorise — typically the persisted "last import folder"
// restored from localStorage when the user just hit OK without
// re-picking. Without this, `RemoteFile` reads through the asset
// protocol later in `importBook` would fail with
// "asset protocol not configured to allow the path".
await appService.allowPathsInScopes?.([result.directory], true);
const exts = result.extensions.map((e) => e.toLowerCase());
const minSizeBytes = Math.max(0, Math.floor(result.minSizeKB)) * 1024;
const files = await appService.readDirectory(result.directory, 'None');
const filtered = files.filter((file) => {
const ext = file.path.split('.').pop()?.toLowerCase() || '';
return SUPPORTED_BOOK_EXTS.includes(ext);
if (!exts.includes(ext)) return false;
if (minSizeBytes > 0 && file.size < minSizeBytes) return false;
return true;
});
const toImportFiles = await Promise.all(
supportedFiles.map(async (file) => {
return {
path: await joinPaths(importDirectory, file.path),
basePath: importDirectory,
};
filtered.map(async (file) => {
const fullPath = await joinPaths(result.directory, file.path);
return result.flatten ? { path: fullPath } : { path: fullPath, basePath: result.directory };
}),
);
importBooks(toImportFiles, undefined);
if (toImportFiles.length === 0) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('No matching books found in the selected folder.'),
});
return;
}
// When flattening, route the books into whichever group the user
// is currently viewing (empty string == library root). When
// preserving structure we leave groupId undefined so importBooks
// derives nested groupNames from each file's basePath.
const targetGroupId = result.flatten ? searchParams?.get('group') || '' : undefined;
importBooks(toImportFiles, targetGroupId);
};
const handleSetSelectMode = (selectMode: boolean) => {
@@ -1058,6 +1202,43 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<BackupWindow onPullLibrary={pullLibrary} />
{isSettingsDialogOpen && <SettingsDialog bookKey={''} />}
{showCatalogManager && <CatalogDialog onClose={handleDismissOPDSDialog} />}
{importFromFolderState && (
<ImportFromFolderDialog
initialDirectory={importFromFolderState.initialDirectory}
initialFolderMode={importFromFolderState.initialFolderMode}
initialSelectedGroupIds={importFromFolderState.initialSelectedGroupIds}
initialMinSizeKB={importFromFolderState.initialMinSizeKB}
onPickDirectory={pickImportDirectory}
onCancel={() => setImportFromFolderState(null)}
onConfirm={(result) => {
setImportFromFolderState(null);
// Remember the folder + filters for next time. Done here
// (rather than inside pickImportDirectory) so we only
// persist values the user actually committed to, not
// ones they cancelled out of.
if (typeof window !== 'undefined') {
if (result.directory) {
window.localStorage.setItem(LAST_IMPORT_FOLDER_KEY, result.directory);
}
window.localStorage.setItem(
LAST_IMPORT_FOLDER_MODE_KEY,
result.flatten ? 'flatten' : 'keep',
);
if (result.selectedGroupIds.length > 0) {
window.localStorage.setItem(
LAST_IMPORT_FOLDER_FORMATS_KEY,
result.selectedGroupIds.join(','),
);
}
window.localStorage.setItem(
LAST_IMPORT_FOLDER_MIN_SIZE_KEY,
String(result.minSizeKB),
);
}
void runFolderImport(result);
}}
/>
)}
<Toast />
</div>
);
@@ -49,7 +49,13 @@ export async function ingestFile(
});
if (!book) return null;
if (opts.groupId) {
// Tri-state: undefined leaves whatever group the existing
// (deduped) book already had untouched; an explicit string —
// including the empty string — replaces it. The empty-string case
// is what library imports use to "demote" a book back to the root
// when the user picks Import-from-Folder → flatten on a previously
// grouped book.
if (opts.groupId !== undefined) {
book.groupId = opts.groupId;
book.groupName = opts.groupName;
}
@@ -546,6 +546,14 @@ export class NativeAppService extends BaseAppService {
multiple: false,
recursive: true,
});
if (selected) {
// Tauri's dialog plugin only auto-grants fs_scope; the asset
// protocol scope still needs an explicit allow before
// RemoteFile / convertFileSrc-based reads can succeed against
// arbitrary user paths. Persisted-scope plugin makes this
// sticky across restarts.
await this.allowPathsInScopes([selected as string], true);
}
return selected as string;
}
@@ -555,7 +563,26 @@ export class NativeAppService extends BaseAppService {
filters: [{ name, extensions }],
});
const files = Array.isArray(selected) ? selected : selected ? [selected] : [];
return OS_TYPE === 'ios' ? files.map((f) => safeDecodePath(f)) : files;
const decoded = OS_TYPE === 'ios' ? files.map((f) => safeDecodePath(f)) : files;
if (decoded.length > 0) {
// See the note in selectDirectory above.
await this.allowPathsInScopes(decoded, false);
}
return decoded;
}
/**
* Best-effort: ask the Rust side to extend `fs_scope` and
* `asset_protocol_scope` to cover the given paths. Errors are logged
* and swallowed because the import path can still succeed via the
* NativeFile fallback even when scope extension fails.
*/
async allowPathsInScopes(paths: string[], isDirectory: boolean): Promise<void> {
try {
await invoke('allow_paths_in_scopes', { paths, isDirectory });
} catch (e) {
console.warn('allow_paths_in_scopes failed:', e);
}
}
async saveFile(
+12
View File
@@ -59,6 +59,18 @@ body {
.text-balance {
text-wrap: balance;
}
/* Hide the native number-input spinner arrows. WebKit on macOS draws
them on top of rounded input borders, which looks broken; Firefox
respects -moz-appearance: textfield. */
.no-spinner {
-moz-appearance: textfield;
}
.no-spinner::-webkit-outer-spin-button,
.no-spinner::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
.full-height {
+9
View File
@@ -118,6 +118,15 @@ export interface AppService {
selectDirectory(mode: SelectDirectoryMode): Promise<string>;
selectFiles(name: string, extensions: string[]): Promise<string[]>;
readDirectory(path: string, base: BaseDir): Promise<FileItem[]>;
/**
* Best-effort: extend the Tauri `fs_scope` and `asset_protocol_scope`
* to cover the given paths. No-op on web. Used after a directory or
* file path is recovered from somewhere other than the native picker
* (e.g. localStorage of the last-used import folder), since the
* dialog plugin only auto-allows `fs_scope` for paths it returned in
* the current session.
*/
allowPathsInScopes?(paths: string[], isDirectory: boolean): Promise<void>;
saveFile(
filename: string,
content: string | ArrayBuffer,