mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(library): per-library added_at source (imported/file_modified/file_created) (#1239)
* feat(scanner): set book added_at from earliest on-disk file mtime A book's "date added" now reflects when its content files first landed on disk (the earliest mtime across its content-role files) instead of the moment BookOrbit imported it. Cover/metadata/supplement sidecars are excluded. When no content file has a usable mtime, addedAt is left unset so the DB defaultNow() fallback (import time) still applies. Only set at initial book creation; re-scan/restore paths do not touch it. * feat(library): add per-library added_at source with recompute Add an addedAtSource setting to libraries so "date added" can follow the book's files on disk rather than the moment the scanner happened to see them. The setting takes imported (unchanged default), file_modified, or file_created, guarded by a check constraint on the column. Expose a recompute action so the chosen source can be applied to books that are already imported. file_modified runs as a single set-based update over the stored mtimes; file_created has to stat the files, since birthtime is not persisted, so it batches those reads. Leave every existing library on imported so behavior is unchanged unless a user opts in. * feat(library): add added_at source control to the library editor Surface the new addedAtSource setting in the library editor as a radio group, alongside a button that triggers the recompute for the library being edited. Keep the recompute out of the save path so changing the setting stays cheap: the setting alone only affects books imported from then on, and applying it to existing books is an explicit choice. * i18n(library): translate added_at source strings (de/nl/pt/sl) * fix(library): correct create payload, recompute floor and failure logging The library creator posts its whole form, so omitting addedAtSource from CreateLibraryDto made the global whitelist reject every create request with a 400. Declare the field, validate it against the same source list as the update DTO, and persist it so the initial scan uses the requested source rather than silently falling back to imported. Apply the scanner's usable-time floor to the mtime recompute query too. It previously excluded only NULL, so a filesystem reporting epoch 0 would rewrite added_at to 1970 instead of leaving the import time in place. The floor now lives in library.constants rather than being spelled out twice. Wrap the recompute in try/catch so a database or stat failure emits the [fail] log the logging conventions require instead of propagating with only a [start] line recorded. * fix(library): gate recompute on the saved source and label the radios The recompute button stayed enabled while the selected source differed from the stored one. Because the request carries no source, clicking it recomputed against the old stored value while the UI showed the new selection. A hint already said to save first; disable the control as well so the mismatch is not actionable. Give the source list radiogroup semantics with aria-checked so screen readers can tell which option is active, since selection was conveyed by styling alone. * test(library): assert the create DTO under the real validation options The previous comment claimed the case exercised whitelist and forbidNonWhitelisted, but the shared helper calls validate without them, so it only proved @IsIn rejects an unknown value. Drop the misleading comment and add a case that validates with the same options main.ts gives ValidationPipe, so an undeclared addedAtSource fails the way it would in a real request. * docs(library): remove redundant date-added documentation --------- Co-authored-by: Bob Bobber <user@example.com> Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
@@ -570,7 +570,10 @@ describe('Authorization matrix (e2e)', () => {
|
||||
const live = liveRouteLabels(ctx.app);
|
||||
const liveLabels = new Set(live);
|
||||
|
||||
expect(live.filter((label) => !inventoryLabels.has(label) && !baseline.has(label))).toEqual([]);
|
||||
// Fastify combines parameter aliases at shared trie nodes, such as :id|:libraryId.
|
||||
const routeShape = (label: string) => label.replace(/:[\w]+(?:\|:[\w]+)*/g, ':param');
|
||||
const inventoriedShapes = new Set([...inventoryLabels].map(routeShape));
|
||||
expect(live.filter((label) => !inventoriedShapes.has(routeShape(label)) && !baseline.has(label))).toEqual([]);
|
||||
expect([...baseline].filter((label) => inventoryLabels.has(label))).toEqual([]);
|
||||
expect([...baseline].filter((label) => !liveLabels.has(label))).toEqual([]);
|
||||
});
|
||||
@@ -850,6 +853,8 @@ describe('Authorization matrix (e2e)', () => {
|
||||
'GET /libraries/:id',
|
||||
'POST /libraries/:id/books',
|
||||
'GET /libraries/:id/stats',
|
||||
'GET /libraries/:id/recompute-added-at',
|
||||
'POST /libraries/:id/recompute-added-at',
|
||||
'POST /libraries/:id/write-metadata-to-files',
|
||||
'GET /scanner/libraries/:id/scan-history',
|
||||
].sort(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"totalRoutes": 455,
|
||||
"totalRoutes": 457,
|
||||
"byPermission": {
|
||||
"(none)": 168,
|
||||
"Permission.ManageMetadataConfig": 34,
|
||||
@@ -7,7 +7,7 @@
|
||||
"Permission.LibraryEditMetadata": 35,
|
||||
"Permission.BookDockAccess": 17,
|
||||
"Permission.ManageAppSettings": 39,
|
||||
"Permission.ManageLibraries": 17,
|
||||
"Permission.ManageLibraries": 19,
|
||||
"Permission.ManageUsers": 13,
|
||||
"Permission.ManageEmail": 8,
|
||||
"Permission.KoboSync": 7,
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"byLibraryAccess": {
|
||||
"'viewer'": 4,
|
||||
"'editor'": 1
|
||||
"'editor'": 3
|
||||
},
|
||||
"routeChunks": [
|
||||
"routes-01.json",
|
||||
|
||||
@@ -587,6 +587,30 @@
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/library/library.controller.ts",
|
||||
"className": "LibraryController",
|
||||
"methodName": "getAddedAtRecompute",
|
||||
"httpMethod": "GET",
|
||||
"path": "/libraries/:id/recompute-added-at",
|
||||
"permissions": ["Permission.ManageLibraries"],
|
||||
"libraryAccess": ["'editor'"],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/library/library.controller.ts",
|
||||
"className": "LibraryController",
|
||||
"methodName": "recomputeAddedAt",
|
||||
"httpMethod": "POST",
|
||||
"path": "/libraries/:id/recompute-added-at",
|
||||
"permissions": ["Permission.ManageLibraries"],
|
||||
"libraryAccess": ["'editor'"],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/library/library.controller.ts",
|
||||
"className": "LibraryController",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { access, mkdir, readFile } from 'fs/promises';
|
||||
import { access, mkdir, readFile, stat, utimes, writeFile } from 'fs/promises';
|
||||
import { basename, join } from 'path';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import type { AddedAtRecomputeJob } from '@bookorbit/types';
|
||||
|
||||
import * as schema from '../src/db/schema';
|
||||
import { waitForCondition, waitForScanCompletion } from './e2e/app-harness';
|
||||
@@ -208,6 +209,152 @@ describe('Library admin workflows (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =
|
||||
await closeAuthorizationMatrixE2EContext(ctx);
|
||||
});
|
||||
|
||||
describe('date added recompute', () => {
|
||||
async function finishRecompute(libraryId: number): Promise<AddedAtRecomputeJob> {
|
||||
let result: AddedAtRecomputeJob;
|
||||
await waitForCondition(async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/libraries/${libraryId}/recompute-added-at`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
result = response.json() as AddedAtRecomputeJob;
|
||||
expect(result.status).not.toBe('running');
|
||||
}, 15_000);
|
||||
return result!;
|
||||
}
|
||||
|
||||
it('enforces both permission and library access and updates multiple batches with precise database timestamps', async () => {
|
||||
const library = await createLibraryWithFolder(ctx);
|
||||
const url = `/api/v1/libraries/${library.libraryId}/recompute-added-at`;
|
||||
for (const method of ['POST', 'GET'] as const) {
|
||||
expect((await ctx.app.inject({ method, url, headers: authHeader(manager.accessToken) })).statusCode).toBe(403);
|
||||
}
|
||||
await grantLibraryAccess(ctx, manager.userId, library.libraryId, 'viewer');
|
||||
expect((await ctx.app.inject({ method: 'POST', url, headers: authHeader(manager.accessToken) })).statusCode).toBe(403);
|
||||
await grantLibraryAccess(ctx, manager.userId, library.libraryId, 'editor');
|
||||
await grantLibraryAccess(ctx, scopedUser.userId, library.libraryId, 'editor');
|
||||
for (const method of ['POST', 'GET'] as const) {
|
||||
expect((await ctx.app.inject({ method, url, headers: authHeader(scopedUser.accessToken) })).statusCode).toBe(403);
|
||||
}
|
||||
const imported = await ctx.app.inject({ method: 'POST', url, headers: authHeader(manager.accessToken) });
|
||||
expect(imported.statusCode).toBe(400);
|
||||
expect(imported.json().errorCode).toBe('ADDED_AT_SOURCE_IMPORTED');
|
||||
const update = await ctx.app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/libraries/${library.libraryId}`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
payload: { addedAtSource: 'file_modified', icon: 'BookOpen' },
|
||||
});
|
||||
expect(update.statusCode, update.body).toBe(200);
|
||||
expect(update.json().addedAtSource).toBe('file_modified');
|
||||
const inserted = await ctx.db
|
||||
.insert(schema.books)
|
||||
.values(
|
||||
Array.from({ length: 105 }, (_, i) => ({
|
||||
libraryId: library.libraryId,
|
||||
libraryFolderId: library.libraryFolderId,
|
||||
folderPath: join(library.folderPath, String(i)),
|
||||
addedAt: sql`'2026-01-01 00:00:00.000123+00'::timestamptz`,
|
||||
})),
|
||||
)
|
||||
.returning({ id: schema.books.id });
|
||||
const expectedTime = new Date('2019-01-01T00:00:00Z');
|
||||
await ctx.db.insert(schema.bookFiles).values(
|
||||
inserted.slice(0, 104).flatMap(({ id }) => [
|
||||
{
|
||||
bookId: id,
|
||||
libraryFolderId: library.libraryFolderId,
|
||||
absolutePath: join(library.folderPath, `${id}.epub`),
|
||||
ino: BigInt(id),
|
||||
mtime: expectedTime,
|
||||
role: 'content',
|
||||
},
|
||||
{
|
||||
bookId: id,
|
||||
libraryFolderId: library.libraryFolderId,
|
||||
absolutePath: join(library.folderPath, `${id}.pdf`),
|
||||
ino: BigInt(id + 100000),
|
||||
mtime: new Date(0),
|
||||
role: 'content',
|
||||
},
|
||||
{
|
||||
bookId: id,
|
||||
libraryFolderId: library.libraryFolderId,
|
||||
absolutePath: join(library.folderPath, `${id}.jpg`),
|
||||
ino: BigInt(id + 200000),
|
||||
mtime: new Date('2010-01-01'),
|
||||
role: 'cover',
|
||||
},
|
||||
]),
|
||||
);
|
||||
const otherLibrary = await createLibraryWithFolder(ctx);
|
||||
const [other] = await ctx.db
|
||||
.insert(schema.books)
|
||||
.values({ libraryId: otherLibrary.libraryId, libraryFolderId: otherLibrary.libraryFolderId, folderPath: otherLibrary.folderPath })
|
||||
.returning();
|
||||
const started = await ctx.app.inject({ method: 'POST', url, headers: authHeader(manager.accessToken) });
|
||||
expect(started.statusCode).toBe(202);
|
||||
expect(started.json().status).toBe('running');
|
||||
expect(await finishRecompute(library.libraryId)).toMatchObject({ total: 105, processed: 105, updated: 104, skipped: 1, failed: 0 });
|
||||
const [updated] = await ctx.db.select().from(schema.books).where(eq(schema.books.id, inserted[0].id));
|
||||
expect(updated.addedAt).toEqual(expectedTime);
|
||||
const [untouched] = await ctx.db.select().from(schema.books).where(eq(schema.books.id, other.id));
|
||||
expect(untouched.addedAt).toEqual(other.addedAt);
|
||||
expect((await ctx.app.inject({ method: 'POST', url, headers: authHeader(manager.accessToken) })).statusCode).toBe(202);
|
||||
expect(await finishRecompute(library.libraryId)).toMatchObject({ updated: 0, unchanged: 104, skipped: 1 });
|
||||
});
|
||||
|
||||
it('uses real birthtimes and preserves dates when any content file is missing', async () => {
|
||||
const library = await createLibraryWithFolder(ctx);
|
||||
await grantLibraryAccess(ctx, manager.userId, library.libraryId, 'editor');
|
||||
await ctx.db.update(schema.libraries).set({ addedAtSource: 'file_created' }).where(eq(schema.libraries.id, library.libraryId));
|
||||
const original = new Date('2020-01-01T00:00:00Z');
|
||||
const inserted = await ctx.db
|
||||
.insert(schema.books)
|
||||
.values(
|
||||
[0, 1].map((i) => ({
|
||||
libraryId: library.libraryId,
|
||||
libraryFolderId: library.libraryFolderId,
|
||||
folderPath: join(library.folderPath, String(i)),
|
||||
addedAt: original,
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
const present = join(library.folderPath, 'present.epub');
|
||||
await writeFile(present, 'timestamp fixture');
|
||||
const info = await stat(present);
|
||||
await ctx.db.insert(schema.bookFiles).values([
|
||||
{ bookId: inserted[0].id, libraryFolderId: library.libraryFolderId, absolutePath: present, ino: 1n },
|
||||
{ bookId: inserted[1].id, libraryFolderId: library.libraryFolderId, absolutePath: join(library.folderPath, 'missing.epub'), ino: 2n },
|
||||
]);
|
||||
const response = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/libraries/${library.libraryId}/recompute-added-at`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
});
|
||||
expect(response.statusCode).toBe(202);
|
||||
const result = await finishRecompute(library.libraryId);
|
||||
expect(result).toMatchObject({ processed: 2, updated: 1, failed: 1, failureSamples: [{ bookId: inserted[1].id, code: 'file_unavailable' }] });
|
||||
expect(JSON.stringify(result)).not.toContain(library.folderPath);
|
||||
const rows = await ctx.db.select().from(schema.books).where(eq(schema.books.libraryId, library.libraryId)).orderBy(schema.books.id);
|
||||
expect(rows[0].addedAt).toEqual(info.birthtime.getTime() > 1000 ? info.birthtime : info.mtime);
|
||||
expect(rows[1].addedAt).toEqual(original);
|
||||
});
|
||||
|
||||
it('applies a saved file source to new scanner imports', async () => {
|
||||
const library = await createLibraryWithFolder(ctx);
|
||||
await ctx.db.update(schema.libraries).set({ addedAtSource: 'file_modified' }).where(eq(schema.libraries.id, library.libraryId));
|
||||
const path = await createEpubFixture(library.folderPath, 'dated.epub', { title: 'Dated Book' });
|
||||
const timestamp = new Date('2018-06-01T00:00:00Z');
|
||||
await utimes(path, timestamp, timestamp);
|
||||
await startLibraryScanAs(ctx, ctx.adminToken, library.libraryId);
|
||||
const [book] = await ctx.db.select().from(schema.books).where(eq(schema.books.libraryId, library.libraryId));
|
||||
expect(book.addedAt).toEqual(timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('path and prescan', () => {
|
||||
it('lists valid directories, hides blocked or hidden entries, and returns prescan details for valid, overlapping, and missing paths', async () => {
|
||||
const overlapLibraryName = `library-overlap-${randomUUID()}`;
|
||||
|
||||
Reference in New Issue
Block a user