feat(library): rebuild the folder picker as a tree with a live plan

Pick folders from a lazily loaded tree with find-by-name and typed paths, beside a plan showing
the books each organization mode would import, overlaps with other libraries, and folders no
library reads yet. New ManageLibraries /path routes walk folders within entry and time bounds.
Fix the scanner filing a disc-named library folder such as /mnt/disk1 under the folder above it,
and move books already filed there onto their primary file on the next scan.
This commit is contained in:
neon
2026-10-24 04:04:00 +00:00
parent d29b06c6e8
commit faee78370d
58 changed files with 6389 additions and 642 deletions
@@ -1,5 +1,5 @@
{
"totalRoutes": 712,
"totalRoutes": 719,
"byPermission": {
"(none)": 298,
"Permission.ManageMetadataConfig": 47,
@@ -7,7 +7,7 @@
"Permission.LibraryEditMetadata": 54,
"Permission.BookDockAccess": 17,
"Permission.ManageAppSettings": 62,
"Permission.ManageLibraries": 24,
"Permission.ManageLibraries": 31,
"Permission.ManageUsers": 14,
"Permission.ManageEmail": 8,
"Permission.KoboSync": 13,
@@ -443,6 +443,90 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/path/path.controller.ts",
"className": "PathController",
"methodName": "listFolder",
"httpMethod": "GET",
"path": "/path/listing",
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/path/path.controller.ts",
"className": "PathController",
"methodName": "summarize",
"httpMethod": "POST",
"path": "/path/summaries",
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/path/path.controller.ts",
"className": "PathController",
"methodName": "inspect",
"httpMethod": "GET",
"path": "/path/inspect",
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/path/path.controller.ts",
"className": "PathController",
"methodName": "search",
"httpMethod": "GET",
"path": "/path/search",
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/path/path.controller.ts",
"className": "PathController",
"methodName": "places",
"httpMethod": "GET",
"path": "/path/places",
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/path/path.controller.ts",
"className": "PathController",
"methodName": "unclaimed",
"httpMethod": "GET",
"path": "/path/unclaimed",
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/path/path.controller.ts",
"className": "PathController",
"methodName": "createFolder",
"httpMethod": "POST",
"path": "/path",
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/reader/cbz/cbz.controller.ts",
"className": "CbzController",
@@ -269,7 +269,6 @@
"POST /migration/sources/:id/path-mappings/validate",
"POST /migration/sources/:id/validate",
"POST /migration/sources/test",
"POST /path",
"POST /readwise/validate-token",
"POST /server-fonts/upload",
"POST /smart-scopes/:id/books/jump-buckets",
+69
View File
@@ -1,6 +1,10 @@
import { mkdir, writeFile } from 'fs/promises';
import { dirname, join } from 'path';
import { eq } from 'drizzle-orm';
import { bookFiles, books } from '../src/db/schema';
import type { FixtureEntry } from './e2e/scanner/scanner-fixture-builder';
import { createFixtureTree, file } from './e2e/scanner/scanner-fixture-builder';
import {
@@ -460,6 +464,71 @@ describe('Scanner structures (e2e)', () => {
}
});
describe('a library folder named like a disc folder', () => {
// unRAID mounts its drives as /mnt/disk1; such a folder was once flattened into the folder above it.
const entries = [file('disk1/Dune.epub'), file('disk1/Emma.epub'), file('disk1/Saga/Saga 001.cbz')];
const expected = (library: string) =>
[join(library, 'Dune.epub'), join(library, 'Emma.epub'), join(library, 'Saga')].map((folderPath) => ({ folderPath, status: 'present' }));
it(
'reads its loose files as books of their own, inside the library',
async () => {
if (!context) throw new Error('E2E context not initialized');
const fixture = await createFixtureTree(entries, 'scanner-e2e-disc-named-root-');
try {
const library = join(fixture.rootPath, 'disk1');
const { libraryId } = await seedLibrary(context.db, { rootPath: library, mode: 'book_per_folder' });
await waitForScanCompletion(context.db, await triggerLibraryScan(context, libraryId));
const state = await loadLibraryBookState(context.db, libraryId);
expect(state.map(({ folderPath, status }) => ({ folderPath, status })).sort((a, b) => a.folderPath.localeCompare(b.folderPath))).toEqual(
expected(library),
);
await assertNoIntegrityViolations(context.db);
} finally {
await fixture.cleanup();
}
},
SCENARIO_TIMEOUT_MS,
);
it(
'regroups a library scanned while it was flattened, keeping the old book on its primary file',
async () => {
if (!context) throw new Error('E2E context not initialized');
const fixture = await createFixtureTree(entries, 'scanner-e2e-disc-named-root-repair-');
try {
const library = join(fixture.rootPath, 'disk1');
const { libraryId } = await seedLibrary(context.db, { rootPath: library, mode: 'book_per_folder' });
await waitForScanCompletion(context.db, await triggerLibraryScan(context, libraryId));
// Rebuild what the flattening scan stored: one book above the library holding every loose file.
const rows = await context.db.select({ id: books.id, folderPath: books.folderPath }).from(books).where(eq(books.libraryId, libraryId));
const dune = rows.find((row) => row.folderPath === join(library, 'Dune.epub'))!;
const emma = rows.find((row) => row.folderPath === join(library, 'Emma.epub'))!;
await context.db.update(bookFiles).set({ bookId: dune.id }).where(eq(bookFiles.bookId, emma.id));
await context.db.delete(books).where(eq(books.id, emma.id));
await context.db.update(books).set({ folderPath: fixture.rootPath }).where(eq(books.id, dune.id));
await waitForScanCompletion(context.db, await triggerLibraryScan(context, libraryId));
const state = await loadLibraryBookState(context.db, libraryId);
expect(state.map(({ folderPath, status }) => ({ folderPath, status })).sort((a, b) => a.folderPath.localeCompare(b.folderPath))).toEqual(
expected(library),
);
const [kept] = await context.db.select({ folderPath: books.folderPath }).from(books).where(eq(books.id, dune.id));
expect(kept?.folderPath).toBe(join(library, 'Dune.epub'));
expect(state.find((book) => book.folderPath === join(library, 'Emma.epub'))?.filePaths).toEqual([join(library, 'Emma.epub')]);
await assertNoIntegrityViolations(context.db);
} finally {
await fixture.cleanup();
}
},
SCENARIO_TIMEOUT_MS,
);
});
describe('organizationMode=book_per_file', () => {
for (const scenario of bookPerFileScenarios) {
it(