Skip to content

Commit bd12a57

Browse files
Merge pull request #4 from ethanwheatthin/bundle
Bundle
2 parents 0af4a55 + c9840d3 commit bd12a57

50 files changed

Lines changed: 3240 additions & 206 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddLibrarySources1708600000000 implements MigrationInterface {
4+
name = 'AddLibrarySources1708600000000';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`
8+
CREATE TABLE IF NOT EXISTS "library_sources" (
9+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
10+
"name" varchar(300) NOT NULL,
11+
"polling_enabled" boolean NOT NULL DEFAULT true,
12+
"polling_interval_seconds" integer NOT NULL DEFAULT 300,
13+
"last_scanned_at" timestamptz,
14+
"created_at" timestamptz NOT NULL DEFAULT now(),
15+
"updated_at" timestamptz NOT NULL DEFAULT now()
16+
)
17+
`);
18+
19+
await queryRunner.query(`
20+
CREATE TABLE IF NOT EXISTS "library_source_paths" (
21+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
22+
"library_source_id" uuid NOT NULL REFERENCES "library_sources"("id") ON DELETE CASCADE,
23+
"path" varchar(1000) NOT NULL,
24+
"last_scanned_at" timestamptz,
25+
"file_count" integer NOT NULL DEFAULT 0,
26+
"created_at" timestamptz NOT NULL DEFAULT now()
27+
)
28+
`);
29+
30+
await queryRunner.query(`
31+
CREATE INDEX IF NOT EXISTS "idx_library_source_paths_source"
32+
ON "library_source_paths" ("library_source_id")
33+
`);
34+
}
35+
36+
public async down(queryRunner: QueryRunner): Promise<void> {
37+
await queryRunner.query(`DROP TABLE IF EXISTS "library_source_paths"`);
38+
await queryRunner.query(`DROP TABLE IF EXISTS "library_sources"`);
39+
}
40+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddCoverImageBlob1709000000000 implements MigrationInterface {
4+
name = 'AddCoverImageBlob1709000000000';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`
8+
ALTER TABLE "book_metadata"
9+
ADD COLUMN IF NOT EXISTS "cover_image" bytea,
10+
ADD COLUMN IF NOT EXISTS "cover_image_type" varchar(50)
11+
`);
12+
}
13+
14+
public async down(queryRunner: QueryRunner): Promise<void> {
15+
await queryRunner.query(`
16+
ALTER TABLE "book_metadata"
17+
DROP COLUMN IF EXISTS "cover_image",
18+
DROP COLUMN IF EXISTS "cover_image_type"
19+
`);
20+
}
21+
}

‎backend/src/data-source.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ import { ReadingGoalEntity } from './entities/ReadingGoal';
1111
import { ReadingGoalCompletedDayEntity } from './entities/ReadingGoalCompletedDay';
1212
import { ShelfEntity } from './entities/Shelf';
1313
import { DocumentFileEntity } from './entities/DocumentFile';
14+
import { LibrarySourceEntity } from './entities/LibrarySource';
15+
import { LibrarySourcePathEntity } from './entities/LibrarySourcePath';
1416
import { InitialSchema1700000000000 } from '../migrations/1700000000000-InitialSchema';
1517
import { AddBookmarkChapter1708523246000 } from '../migrations/1708523246000-AddBookmarkChapter';
18+
import { AddLibrarySources1708600000000 } from '../migrations/1708600000000-AddLibrarySources';
19+
import { AddCoverImageBlob1709000000000 } from '../migrations/1709000000000-AddCoverImageBlob';
1620

1721
dotenv.config();
1822

@@ -36,7 +40,9 @@ export const AppDataSource = new DataSource({
3640
ReadingGoalCompletedDayEntity,
3741
ShelfEntity,
3842
DocumentFileEntity,
43+
LibrarySourceEntity,
44+
LibrarySourcePathEntity,
3945
],
40-
migrations: [InitialSchema1700000000000, AddBookmarkChapter1708523246000],
46+
migrations: [InitialSchema1700000000000, AddBookmarkChapter1708523246000, AddLibrarySources1708600000000, AddCoverImageBlob1709000000000],
4147
subscribers: [],
4248
});

‎backend/src/entities/BookMetadata.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ export class BookMetadataEntity {
3939
@Column({ name: 'cover_url', type: 'text', nullable: true })
4040
coverUrl: string | null;
4141

42+
@Column({ name: 'cover_image', type: 'bytea', nullable: true })
43+
coverImage: Buffer | null;
44+
45+
@Column({ name: 'cover_image_type', type: 'varchar', length: 50, nullable: true })
46+
coverImageType: string | null;
47+
4248
@Column({ type: 'text', nullable: true })
4349
description: string | null;
4450

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {
2+
Entity,
3+
PrimaryGeneratedColumn,
4+
Column,
5+
CreateDateColumn,
6+
UpdateDateColumn,
7+
OneToMany,
8+
} from 'typeorm';
9+
import { LibrarySourcePathEntity } from './LibrarySourcePath';
10+
11+
@Entity('library_sources')
12+
export class LibrarySourceEntity {
13+
@PrimaryGeneratedColumn('uuid')
14+
id: string;
15+
16+
@Column({ type: 'varchar', length: 300 })
17+
name: string;
18+
19+
@Column({ name: 'polling_enabled', type: 'boolean', default: true })
20+
pollingEnabled: boolean;
21+
22+
@Column({ name: 'polling_interval_seconds', type: 'int', default: 300 })
23+
pollingIntervalSeconds: number;
24+
25+
@Column({ name: 'last_scanned_at', type: 'timestamptz', nullable: true })
26+
lastScannedAt: Date | null;
27+
28+
@OneToMany(() => LibrarySourcePathEntity, (p) => p.librarySource, {
29+
cascade: true,
30+
eager: true,
31+
})
32+
paths: LibrarySourcePathEntity[];
33+
34+
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
35+
createdAt: Date;
36+
37+
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
38+
updatedAt: Date;
39+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {
2+
Entity,
3+
PrimaryGeneratedColumn,
4+
Column,
5+
ManyToOne,
6+
JoinColumn,
7+
CreateDateColumn,
8+
} from 'typeorm';
9+
import { LibrarySourceEntity } from './LibrarySource';
10+
11+
@Entity('library_source_paths')
12+
export class LibrarySourcePathEntity {
13+
@PrimaryGeneratedColumn('uuid')
14+
id: string;
15+
16+
/** Absolute directory path on the host filesystem */
17+
@Column({ type: 'varchar', length: 1000 })
18+
path: string;
19+
20+
@Column({ name: 'last_scanned_at', type: 'timestamptz', nullable: true })
21+
lastScannedAt: Date | null;
22+
23+
@Column({ name: 'file_count', type: 'int', default: 0 })
24+
fileCount: number;
25+
26+
@Column({ name: 'library_source_id', type: 'uuid' })
27+
librarySourceId: string;
28+
29+
@ManyToOne(() => LibrarySourceEntity, (s) => s.paths, { onDelete: 'CASCADE' })
30+
@JoinColumn({ name: 'library_source_id' })
31+
librarySource: LibrarySourceEntity;
32+
33+
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
34+
createdAt: Date;
35+
}

‎backend/src/entities/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ export { ReadingGoalEntity } from './ReadingGoal';
88
export { ReadingGoalCompletedDayEntity } from './ReadingGoalCompletedDay';
99
export { ShelfEntity } from './Shelf';
1010
export { DocumentFileEntity } from './DocumentFile';
11+
export { LibrarySourceEntity } from './LibrarySource';
12+
export { LibrarySourcePathEntity } from './LibrarySourcePath';

‎backend/src/helpers/dto-mapper.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ export function toDocumentDTO(entity: DocumentEntity): any {
4141
publisher: entity.metadata.publisher ?? undefined,
4242
publishYear: entity.metadata.publishYear ?? undefined,
4343
isbn: entity.metadata.isbn ?? undefined,
44-
coverUrl: entity.metadata.coverUrl ?? undefined,
44+
// Prefer locally stored cover blob; fall back to external URL
45+
coverUrl: entity.metadata.coverImage
46+
? `/api/documents/${entity.id}/cover`
47+
: (entity.metadata.coverUrl ?? undefined),
4548
description: entity.metadata.description ?? undefined,
4649
pageCount: entity.metadata.pageCount ?? undefined,
4750
subjects: (entity.metadata.subjects ?? []).map((s: any) => s.name),

‎backend/src/routes/documents.ts‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { Router, Request, Response, NextFunction } from 'express';
22
import multer from 'multer';
33
import path from 'path';
44
import fs from 'fs';
5+
import https from 'https';
6+
import http from 'http';
57
import { AppDataSource } from '../data-source';
68
import {
79
DocumentEntity,
@@ -47,6 +49,50 @@ const upload = multer({
4749
limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB
4850
});
4951

52+
// Multer instance for cover image uploads
53+
const coverUpload = multer({
54+
storage: multer.memoryStorage(),
55+
fileFilter: (_req, file, cb) => {
56+
if (file.mimetype.startsWith('image/')) {
57+
cb(null, true);
58+
} else {
59+
cb(new Error('Only image files are allowed for covers'));
60+
}
61+
},
62+
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
63+
});
64+
65+
/**
66+
* Download an image from a URL and return the buffer + content type.
67+
*/
68+
function downloadImage(url: string): Promise<{ buffer: Buffer; contentType: string }> {
69+
return new Promise((resolve, reject) => {
70+
const client = url.startsWith('https') ? https : http;
71+
const request = client.get(url, { timeout: 10000 }, (response) => {
72+
// Follow redirects
73+
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
74+
return downloadImage(response.headers.location).then(resolve).catch(reject);
75+
}
76+
if (response.statusCode !== 200) {
77+
return reject(new Error(`HTTP ${response.statusCode}`));
78+
}
79+
const chunks: Buffer[] = [];
80+
response.on('data', (chunk: Buffer) => chunks.push(chunk));
81+
response.on('end', () => {
82+
const buffer = Buffer.concat(chunks);
83+
const contentType = response.headers['content-type'] || 'image/jpeg';
84+
resolve({ buffer, contentType });
85+
});
86+
response.on('error', reject);
87+
});
88+
request.on('error', reject);
89+
request.on('timeout', () => {
90+
request.destroy();
91+
reject(new Error('Download timeout'));
92+
});
93+
});
94+
}
95+
5096
// Helper: get document repo with full relations
5197
function getDocRepo() {
5298
return AppDataSource.getRepository(DocumentEntity);
@@ -191,6 +237,21 @@ router.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
191237
if (metadata.publishYear !== undefined) metaEntity.publishYear = metadata.publishYear;
192238
if (metadata.isbn !== undefined) metaEntity.isbn = metadata.isbn;
193239
if (metadata.coverUrl !== undefined) metaEntity.coverUrl = metadata.coverUrl;
240+
241+
// If coverUrl is an external URL, download and store as blob
242+
if (metadata.coverUrl && /^https?:\/\//.test(metadata.coverUrl)) {
243+
try {
244+
const { buffer, contentType } = await downloadImage(metadata.coverUrl);
245+
metaEntity.coverImage = buffer;
246+
metaEntity.coverImageType = contentType || 'image/jpeg';
247+
// Clear the external URL since we now have the blob
248+
metaEntity.coverUrl = null;
249+
} catch (downloadErr) {
250+
logger.warn(`Failed to download cover image from ${metadata.coverUrl}:`, downloadErr);
251+
// Keep the external URL as fallback
252+
}
253+
}
254+
194255
if (metadata.description !== undefined) metaEntity.description = metadata.description;
195256
if (metadata.pageCount !== undefined) metaEntity.pageCount = metadata.pageCount;
196257
if (metadata.openLibraryKey !== undefined) metaEntity.openLibraryKey = metadata.openLibraryKey;
@@ -242,6 +303,80 @@ router.delete('/:id', async (req: Request, res: Response, next: NextFunction) =>
242303
}
243304
});
244305

306+
// ===== COVER IMAGE =====
307+
308+
/** GET /api/documents/:id/cover — serve stored cover image */
309+
router.get('/:id/cover', async (req: Request, res: Response, next: NextFunction) => {
310+
try {
311+
const metaRepo = AppDataSource.getRepository(BookMetadataEntity);
312+
const meta = await metaRepo.findOne({ where: { documentId: req.params.id } });
313+
if (!meta || !meta.coverImage) {
314+
return res.status(404).json({ error: { message: 'No cover image stored' } });
315+
}
316+
317+
res.setHeader('Content-Type', meta.coverImageType || 'image/jpeg');
318+
res.setHeader('Cache-Control', 'public, max-age=86400');
319+
res.send(meta.coverImage);
320+
} catch (err) {
321+
next(err);
322+
}
323+
});
324+
325+
/** PUT /api/documents/:id/cover — upload a cover image directly */
326+
router.put('/:id/cover', coverUpload.single('cover'), async (req: Request, res: Response, next: NextFunction) => {
327+
try {
328+
const docRepo = getDocRepo();
329+
const doc = await docRepo.findOne({ where: { id: req.params.id } });
330+
if (!doc) return res.status(404).json({ error: { message: 'Document not found' } });
331+
332+
const metaRepo = AppDataSource.getRepository(BookMetadataEntity);
333+
let meta = await metaRepo.findOne({ where: { documentId: req.params.id } });
334+
if (!meta) {
335+
meta = metaRepo.create({ documentId: req.params.id });
336+
}
337+
338+
if (req.file) {
339+
// Direct file upload
340+
meta.coverImage = req.file.buffer;
341+
meta.coverImageType = req.file.mimetype;
342+
meta.coverUrl = null; // Clear external URL
343+
} else if (req.body.coverUrl && /^https?:\/\//.test(req.body.coverUrl)) {
344+
// URL provided — download and store
345+
const { buffer, contentType } = await downloadImage(req.body.coverUrl);
346+
meta.coverImage = buffer;
347+
meta.coverImageType = contentType;
348+
meta.coverUrl = null;
349+
} else {
350+
return res.status(400).json({ error: { message: 'Provide a cover file or coverUrl' } });
351+
}
352+
353+
await metaRepo.save(meta);
354+
355+
const fullDoc = await loadFullDocument(req.params.id);
356+
res.json(toDocumentDTO(fullDoc!));
357+
} catch (err) {
358+
next(err);
359+
}
360+
});
361+
362+
/** DELETE /api/documents/:id/cover — remove stored cover image */
363+
router.delete('/:id/cover', async (req: Request, res: Response, next: NextFunction) => {
364+
try {
365+
const metaRepo = AppDataSource.getRepository(BookMetadataEntity);
366+
const meta = await metaRepo.findOne({ where: { documentId: req.params.id } });
367+
if (!meta) return res.status(404).json({ error: { message: 'Metadata not found' } });
368+
369+
meta.coverImage = null;
370+
meta.coverImageType = null;
371+
meta.coverUrl = null;
372+
await metaRepo.save(meta);
373+
374+
res.status(204).send();
375+
} catch (err) {
376+
next(err);
377+
}
378+
});
379+
245380
/** GET /api/documents/:id/file — download/stream document file */
246381
router.get('/:id/file', async (req: Request, res: Response, next: NextFunction) => {
247382
try {

0 commit comments

Comments
 (0)