ff605e000d
* feat(library): in-place import with cloud sync and symmetric local delete
Adds an `inPlace` option to importBook so a source file inside a
registered external library folder is referenced directly via
`book.filePath` instead of being copied into Books/<hash>/. Sidecars
(cover, config, nav) still live under Books/<hash>/.
ingestService routes through shouldImportInPlace, which marks an
import in-place when the absolute source path lives under any of
`settings.externalLibraryFolders` and is NOT inside a per-root
`Books/` subtree. The Readest data dir (`customRootDir`) is
intentionally excluded — that directory is Readest's home and
should freely hold hash copies; in-place is for user-registered
roots (Duokan, Calibre, Moon+ Reader, an iCloud mirror, …).
Cloud sync treats in-place books as first-class:
- uploadBook reads bytes from (book.filePath, 'None') when set.
The cloud key is unchanged, so a peer downloading the book
lands it under Books/<hash>/ as a normal hash copy.
- useBooksSync strips `book.filePath` before pushing — it is a
device-local path that is meaningless on any other device.
- ingestService no longer skips upload for in-place books;
autoUpload / forceUpload behave like any other book. Only
transient imports opt out.
- deleteBook 'local'/'both' now physically removes the source
file at book.filePath (base 'None'). Local-delete semantics
are symmetric with hash-copy books: the local copy is gone,
the cloud backup remains, a future pull restores under
Books/<hash>/. removeFile errors are swallowed.
New `SystemSettings.externalLibraryFolders?: string[]` (no UI yet;
registration entrypoint lands in a follow-up). Added to
BACKUP_SETTINGS_BLACKLIST alongside `localBooksDir` /
`customRootDir` so device-local paths don't ride cloud backups.
Tests: cloud-service, ingest-service, and backup-settings suites
cover in-place delete, multi-root matching, per-root `Books/`
guard, and the backup-strip.
* feat(library): one-tap "read in place" toggle in folder import
Surface the in-place / copy choice as a single "Read books in place (don't copy)" checkbox in the Import-from-Folder dialog. When the user opts in, the chosen directory is registered in `settings.externalLibraryFolders` and ingestService's `shouldImportInPlace` will route the books straight to importBook with `inPlace: true` — no copy into Books/<hash>/, sync still works, local delete still removes the source file (the symmetry was set up in the previous in-place commit).
User experience:
- First-time users hit the toggle once per library folder. The choice is also persisted to localStorage so subsequent dialog opens default to whatever they picked last.
- Repeat imports from a folder that's already registered as an external library folder force the toggle ON and disable it, with a help line explaining that imports from this folder are always in-place. The check is exact-string (after path normalization) so registering /Users/me/Duokan only locks the toggle for that exact path — picking /Users/me/Downloads after Duokan still shows the toggle in its normal state.
- URL-ingress / drag-drop replays go through `runFolderImport` without the dialog and default `readInPlace: false`. They still benefit from in-place automatically when the dropped path lives under an already-registered root, because that decision is made by `shouldImportInPlace` based on settings, not by the dialog flag.
Mechanics:
- ImportFromFolderResult gains `readInPlace: boolean`. ImportFromFolderDialog gains an `initialReadInPlace` prop (seeded from the new `readest:lastImportFolderReadInPlace` localStorage key) and an `isRegisteredExternalRoot` predicate it uses to render the locked / unlocked toggle.
- runFolderImport calls a new `registerExternalLibraryFolder` helper that appends the chosen directory to `settings.externalLibraryFolders` and persists settings, but only when `result.readInPlace` is true. `isRegisteredExternalRoot` does the inverse lookup the dialog needs. Both helpers normalize paths the same way `shouldImportInPlace` does so the predicate matches the ingest layer.
- The new feature has no effect for users who never flip the toggle: `externalLibraryFolders` stays empty, the path-prefix check in `shouldImportInPlace` returns false for every import, and books continue to be copied into Books/<hash>/ exactly as before.
Self-healing for externally-removed in-place books:
Once the dialog lets users opt their library into in-place mode, the source file becomes a piece of state Readest doesn't control — another app may rewrite it (e.g. Duokan persisting reading progress into the epub), the user may move it in Finder, or an external drive may unmount between sessions. Previously, clicking such a book would navigate into the reader, fail inside loadBookContent's `fs.openFile(book.filePath, 'None')` with a low-level IO error, flash an "Unable to open book" toast, and auto-bounce back to the library — leaving the stale library record in place so the next tap reproduces the same dance.
BookshelfItem.handleBookClick now probes availability before navigating, but only for purely-local in-place books (`book.filePath && !book.uploadedAt && !book.deletedAt`). If `appService.isBookAvailable` returns false — which for in-place books means the recorded `book.filePath` no longer exists at the OS level — we dispatch `delete-books` for that hash and show an info toast explaining the removal, instead of opening the reader.
Scope is intentionally narrow:
- Cloud-synced books still flow through `makeBookAvailable`'s on-demand download path; missing local copies trigger a re-download, not a deletion.
- Hash-copy books (no `filePath` set) are not probed: a missing Books/<hash>/ file under normal use signals a bug or filesystem corruption, not user intent, and silently dropping the record would hide the real problem.
- The dispatched delete-books event reuses the existing Bookshelf deletion path, so sidecar metadata and selection state are cleaned up the same way as a user-initiated delete. For in-place books that path doesn't touch any file outside Books/<hash>/, so the now-missing source location (or whatever the user did with it externally) is left alone — symmetric with 165f15a6.
* fix(library): centralize book content resolution
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
210 lines
8.6 KiB
TypeScript
210 lines
8.6 KiB
TypeScript
import type { Book, BookLookupIndex } from '@/types/book';
|
|
import type { AppService, OsPlatform } from '@/types/system';
|
|
import type { SystemSettings } from '@/types/settings';
|
|
import { transferManager } from '@/services/transferManager';
|
|
|
|
export interface IngestFileDeps {
|
|
appService: AppService;
|
|
settings: SystemSettings;
|
|
isLoggedIn: boolean;
|
|
/**
|
|
* Pre-resolved absolute path to Readest's own `Books/` directory. When
|
|
* provided, any source file already living under this prefix is excluded
|
|
* from in-place import (it is, by definition, a hash copy we wrote). The
|
|
* caller is expected to resolve it once per batch via
|
|
* `appService.fs.getPrefix('Books')` instead of paying the async cost
|
|
* per ingested file. Omit (or pass null) on contexts where the lookup is
|
|
* unavailable — in-place decisions will then proceed without this guard.
|
|
*/
|
|
appBooksPrefix?: string | null;
|
|
}
|
|
|
|
export interface IngestFileOptions {
|
|
/** A file path (desktop/mobile) or a File object (web). */
|
|
file: File | string;
|
|
/** Current library, used by importBook for dedup. */
|
|
books: Book[];
|
|
/** Pre-built lookup index for O(1) dedup during batch imports. */
|
|
lookupIndex?: BookLookupIndex;
|
|
/** Collection to place the book in. */
|
|
groupId?: string;
|
|
groupName?: string;
|
|
/** Tag parsed from a Send-to-Readest email subject (`#scifi`). */
|
|
subjectTag?: string;
|
|
/** Upload to the cloud even when the user has disabled autoUpload. */
|
|
forceUpload?: boolean;
|
|
/** Transient import (not stored long-term) — never uploaded. */
|
|
transient?: boolean;
|
|
/**
|
|
* Opt out of automatic in-place import even when the source file lives under
|
|
* the user's custom root directory. Forces the legacy behavior of copying
|
|
* the file into Books/<hash>/. Defaults to false.
|
|
*/
|
|
forceCopy?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Decide whether `file` should be imported in-place (read directly from its
|
|
* source location) instead of copied into Books/<hash>/.
|
|
*
|
|
* Conditions (all must hold):
|
|
* - `file` is an absolute path string (not a File / blob / URL / content URI).
|
|
* - The path lives under one of the user's registered in-place roots
|
|
* (`settings.externalLibraryFolders`) — directories the user has
|
|
* explicitly told Readest to read in place. The Readest data location
|
|
* (`customRootDir`) is intentionally NOT an in-place trigger; that
|
|
* directory is Readest's own home and may freely contain hash copies.
|
|
* - The path is NOT inside Readest's own managed books directory
|
|
* (`appBooksPrefix`, e.g. `<AppData>/Books/`). Anything in that subtree
|
|
* is a hash copy under Readest's control, no point marking it in-place.
|
|
* We compare against the actual app data path rather than rejecting any
|
|
* `<root>/Books/` segment — users routinely have unrelated folders named
|
|
* `Books` inside their library roots (Baidu Netdisk's default layout,
|
|
* Calibre exports, etc.) and those must still go in-place.
|
|
* - Caller did not request `transient` (transient already opts out of
|
|
* copying via its own filePath path) or `forceCopy`.
|
|
*
|
|
* Returns false in any other case, including web (File objects), URLs, and
|
|
* relative paths.
|
|
*
|
|
* Known limitation: symlinks are not resolved. A registered root of
|
|
* `/Users/me/Library` will NOT match a file accessed through a sibling
|
|
* symlink like `/Users/me/LibrarySymlink/sample.epub` even when both point
|
|
* at the same on-disk directory. Adding best-effort realpath resolution
|
|
* requires async I/O and a cross-platform `realpath` capability on
|
|
* `FileSystem`, which is out of scope for this change.
|
|
*/
|
|
function shouldImportInPlace(
|
|
file: File | string,
|
|
opts: Pick<IngestFileOptions, 'transient' | 'forceCopy'>,
|
|
inPlaceRoots: string[],
|
|
osPlatform: OsPlatform,
|
|
appBooksPrefix: string | null,
|
|
): boolean {
|
|
if (opts.transient || opts.forceCopy) return false;
|
|
if (typeof file !== 'string') return false;
|
|
if (inPlaceRoots.length === 0) return false;
|
|
|
|
// Absolute path check that works for POSIX and Windows without pulling in
|
|
// node:path (this code also runs in the renderer on web/mobile builds).
|
|
const isWindowsDrive = /^[A-Za-z]:[\\/]/.test(file);
|
|
const isAbs = file.startsWith('/') || isWindowsDrive || file.startsWith('\\\\');
|
|
if (!isAbs) return false;
|
|
|
|
// Reject anything that smells like a URL or content URI. Windows drive
|
|
// letters (`C:\…`) match a "scheme:rest" shape too, so exclude them
|
|
// explicitly — `isWindowsDrive` already vouched for those.
|
|
if (!isWindowsDrive && /^[a-z][a-z0-9+.-]*:/i.test(file)) return false;
|
|
|
|
// macOS (APFS/HFS+ default), iOS, and Windows ship case-insensitive
|
|
// filesystems out of the box, so `/Users/me/Library` and
|
|
// `/users/me/library` must compare equal there. Linux and Android are
|
|
// case-sensitive and stay strict. We do not attempt unicode normalization
|
|
// (NFC/NFD) — APFS handles that at the FS layer and `toLocaleLowerCase`
|
|
// with the wrong locale would introduce its own bugs (e.g. Turkish `İ`).
|
|
const caseInsensitive =
|
|
osPlatform === 'macos' || osPlatform === 'ios' || osPlatform === 'windows';
|
|
const norm = (p: string) => {
|
|
const n = p.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
return caseInsensitive ? n.toLowerCase() : n;
|
|
};
|
|
const target = norm(file);
|
|
|
|
// If the file already lives inside Readest's own managed books directory
|
|
// we never want to "in-place" it: it is, by definition, a hash copy we
|
|
// produced ourselves. Compare against the actual resolved app prefix so
|
|
// unrelated user-owned folders that happen to be named `Books` (very
|
|
// common in cloud-drive layouts like Baidu Netdisk's `Books/` root) are
|
|
// left untouched and imported in-place when they fall under a registered
|
|
// external root.
|
|
if (appBooksPrefix) {
|
|
const appBooks = norm(appBooksPrefix);
|
|
if (appBooks && (target === appBooks || target.startsWith(appBooks + '/'))) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
for (const raw of inPlaceRoots) {
|
|
if (!raw) continue;
|
|
const root = norm(raw);
|
|
if (!root) continue;
|
|
// Guard against root-as-prefix-of-different-dir (`/foo` vs `/foobar`).
|
|
if (target !== root && !target.startsWith(root + '/')) continue;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Channel-agnostic single-file ingestion. Every capture channel — local library
|
|
* import, the /send page, the inbox drainer — calls this so a sent book behaves
|
|
* exactly like a locally-imported one.
|
|
*
|
|
* Persistence (`updateBooks` / `saveLibraryBooks`) and the sync push stay with
|
|
* the caller on purpose: batch importers save once per batch, single-item
|
|
* callers save per item. The shared logic that must NOT diverge — importing,
|
|
* group/tag metadata, the upload decision — lives here.
|
|
*/
|
|
export async function ingestFile(
|
|
opts: IngestFileOptions,
|
|
deps: IngestFileDeps,
|
|
): Promise<Book | null> {
|
|
const { appService, settings, isLoggedIn, appBooksPrefix } = deps;
|
|
|
|
const inPlaceRoots = settings.externalLibraryFolders ?? [];
|
|
const inPlace = shouldImportInPlace(
|
|
opts.file,
|
|
opts,
|
|
inPlaceRoots,
|
|
appService.osPlatform,
|
|
appBooksPrefix ?? null,
|
|
);
|
|
|
|
const book = await appService.importBook(opts.file, opts.books, {
|
|
lookupIndex: opts.lookupIndex,
|
|
transient: opts.transient,
|
|
inPlace,
|
|
});
|
|
if (!book) return null;
|
|
|
|
// 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;
|
|
}
|
|
|
|
const tag = opts.subjectTag?.trim();
|
|
if (tag) {
|
|
const tags = book.tags ?? [];
|
|
if (!tags.includes(tag)) {
|
|
book.tags = [...tags, tag];
|
|
book.updatedAt = Date.now();
|
|
}
|
|
}
|
|
|
|
// Sent books force the upload so they reach the user's other devices even
|
|
// when autoUpload is off; normal library imports honor the setting.
|
|
// Transient imports are never uploaded — they're short-lived previews
|
|
// (e.g. /send view) and shouldn't pollute the user's cloud library.
|
|
// In-place imports (book.filePath set, content under one of the user's
|
|
// external library folders) DO get uploaded: from a backup/sync standpoint
|
|
// they are equivalent to a hash-copy book — only the local storage
|
|
// location differs. uploadBook reads straight from book.filePath in that
|
|
// case; downloads on other devices land in Books/<hash>/ as a normal copy.
|
|
if (
|
|
!opts.transient &&
|
|
isLoggedIn &&
|
|
!book.uploadedAt &&
|
|
(opts.forceUpload || settings.autoUpload)
|
|
) {
|
|
transferManager.queueUpload(book);
|
|
}
|
|
|
|
return book;
|
|
}
|