mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user