Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ New syncs won't create duplicates. If you still see duplicates, you most likely

### Breaking Changes

Plugin version 0.3.0 required. Update it before syncing.
Plugin version 0.3.1 required. Update it before syncing.

KOReader cover sync: after import, the server returns `missing_cover_md5` and the plugin uploads PNG covers for those books when it can resolve a filepath via reading history.

---

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ The KoInsight plugin syncs your reading statistics from KOReader to KoInsight.
- ⚠️ Make sure your KOReader device has network access to the server.
1. Click **Sync** in the KoInsight plugin menu.

After a successful sync, the server may ask the plugin to upload **book covers** that KoInsight does not have yet. The plugin then looks up each book’s file path from **KOReader reading history**, extracts a cover (from the CoverBrowser cache when available, otherwise by opening the document), and sends it to the server. Books that never appear in history cannot be matched to a file on disk, so open them at least once before syncing if you want their covers uploaded.

Reload the KoInsight web dashboard. If everything went well (🤞), your data should appear.

### Manual Upload: `statistics.sqlite`
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/books/books-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export class BooksRepository {
return db<Book>('book').where({ id }).first();
}

static async getByMd5(md5: Book['md5']): Promise<Book | undefined> {
return db<Book>('book').where({ md5 }).first();
}

static async insert(book: Partial<Book>): Promise<number[]> {
return db<Book>('book').insert(book);
}
Expand Down
19 changes: 17 additions & 2 deletions apps/server/src/books/covers/covers-service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { Book } from '@koinsight/common/types';
import { existsSync, mkdirSync, promises, rename, rmSync } from 'fs';
import { existsSync, mkdirSync, promises, rmSync } from 'fs';
import path from 'path';
import { appConfig } from '../../config';

export class CoversService {
/** Subset of md5 values that have no cover file on disk (single directory read). */
static async filterMd5WithoutCover(md5s: string[]): Promise<string[]> {
if (md5s.length === 0) {
return [];
}
let files: string[];
try {
files = await promises.readdir(appConfig.coversPath);
} catch {
return [...new Set(md5s)];
}
const unique = [...new Set(md5s)];
return unique.filter((md5) => !files.some((f) => f.startsWith(md5)));
}

static async get(book: Book): Promise<string | null> {
const files = await promises.readdir(appConfig.coversPath);
const file = files.find((f) => f.startsWith(book.md5));
Expand Down Expand Up @@ -33,6 +48,6 @@ export class CoversService {
const extension = path.extname(file.originalname) || '';
const newFilename = `${book.md5}${extension}`;
const newPath = path.join(path.dirname(file.path), newFilename);
await rename(file.path, newPath, () => {});
await promises.rename(file.path, newPath);
}
}
176 changes: 174 additions & 2 deletions apps/server/src/koplugin/koplugin-router.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import express from 'express';
import { mkdirSync, writeFileSync } from 'fs';
import path from 'path';
import request from 'supertest';
import { appConfig } from '../config';
import { createDevice } from '../db/factories/device-factory';
import { fakeKoReaderAnnotation } from '../db/factories/koreader-annotation-factory';
import { db } from '../knex';
Expand Down Expand Up @@ -100,7 +103,8 @@ describe('koplugin-router', () => {
});

expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Upload successful' });
expect(response.body.message).toBe('Upload successful');
expect(response.body.missing_cover_md5).toContain(bookMd5);

const book = await db('book').where({ md5: bookMd5 }).first();
expect(book).toEqual(
Expand Down Expand Up @@ -172,7 +176,8 @@ describe('koplugin-router', () => {
});

expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Upload successful' });
expect(response.body.message).toBe('Upload successful');
expect(response.body.missing_cover_md5).toContain(bookMd5);

const book = await db('book').where({ md5: bookMd5 }).first();
expect(book).toEqual(
Expand Down Expand Up @@ -214,6 +219,173 @@ describe('koplugin-router', () => {
expect(response.status).toBe(400);
expect(response.body.error).toContain('Unsupported plugin version');
});

it('omits md5 from missing_cover_md5 when a cover file already exists', async () => {
const bookMd5 = 'cover_exists_md5_12';
const device = await createDevice(db);

mkdirSync(appConfig.coversPath, { recursive: true });
writeFileSync(path.join(appConfig.coversPath, `${bookMd5}.jpg`), Buffer.from([0xff, 0xd8, 0xff]));

const response = await request(app)
.post('/koplugin/import')
.send({
version: REQUIRED_PLUGIN_VERSION,
books: [
{
md5: bookMd5,
title: 'Has Cover On Disk',
authors: 'Author',
language: 'en',
pages: 50,
total_read_time: 0,
total_read_pages: 0,
},
],
stats: [
{
book_md5: bookMd5,
device_id: device.id,
start_time: 3000,
duration: 30,
page: 1,
total_pages: 50,
},
],
annotations: {},
});

expect(response.status).toBe(200);
expect(response.body.missing_cover_md5).not.toContain(bookMd5);
});
});

describe('POST /koplugin/books/:md5/cover', () => {
it('uploads a cover image for a book', async () => {
const bookMd5 = 'plugin_cover_upload_md5';
const device = await createDevice(db);

await request(app)
.post('/koplugin/import')
.send({
version: REQUIRED_PLUGIN_VERSION,
books: [
{
md5: bookMd5,
title: 'Cover Upload Book',
authors: 'Author',
language: 'en',
pages: 10,
total_read_time: 0,
total_read_pages: 0,
},
],
stats: [
{
book_md5: bookMd5,
device_id: device.id,
start_time: 4000,
duration: 10,
page: 1,
total_pages: 10,
},
],
annotations: {},
});

const pngBytes = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);

const response = await request(app)
.post(`/koplugin/books/${encodeURIComponent(bookMd5)}/cover`)
.field('version', REQUIRED_PLUGIN_VERSION)
.attach('file', pngBytes, 'cover.png');

expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Cover updated' });

mkdirSync(appConfig.coversPath, { recursive: true });
const importAgain = await request(app)
.post('/koplugin/import')
.send({
version: REQUIRED_PLUGIN_VERSION,
books: [
{
md5: bookMd5,
title: 'Cover Upload Book',
authors: 'Author',
language: 'en',
pages: 10,
total_read_time: 0,
total_read_pages: 0,
},
],
stats: [],
annotations: {},
});
expect(importAgain.body.missing_cover_md5).not.toContain(bookMd5);
});

it('returns 404 when book md5 is unknown', async () => {
const pngBytes = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);

const response = await request(app)
.post('/koplugin/books/unknown_md5_xyz/cover')
.field('version', REQUIRED_PLUGIN_VERSION)
.attach('file', pngBytes, 'cover.png');

expect(response.status).toBe(404);
});

it('returns 400 when plugin version is wrong', async () => {
const bookMd5 = 'version_check_cover_md5';
const device = await createDevice(db);

await request(app)
.post('/koplugin/import')
.send({
version: REQUIRED_PLUGIN_VERSION,
books: [
{
md5: bookMd5,
title: 'V Book',
authors: 'A',
language: 'en',
pages: 5,
total_read_time: 0,
total_read_pages: 0,
},
],
stats: [
{
book_md5: bookMd5,
device_id: device.id,
start_time: 5000,
duration: 5,
page: 1,
total_pages: 5,
},
],
annotations: {},
});

const pngBytes = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);

const response = await request(app)
.post(`/koplugin/books/${bookMd5}/cover`)
.field('version', '0.1.0')
.attach('file', pngBytes, 'cover.png');

expect(response.status).toBe(400);
});
});

describe('GET /koplugin/health', () => {
Expand Down
97 changes: 95 additions & 2 deletions apps/server/src/koplugin/koplugin-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ import { Device } from '@koinsight/common/types/device';
import { PageStat } from '@koinsight/common/types/page-stat';
import archiver from 'archiver';
import { NextFunction, Request, Response, Router } from 'express';
import { existsSync, mkdirSync, unlink } from 'fs';
import multer from 'multer';
import path from 'path';
import { BooksRepository } from '../books/books-repository';
import { CoversService } from '../books/covers/covers-service';
import { appConfig } from '../config';
import { DeviceRepository } from '../devices/device-repository';
import { UploadService } from '../upload/upload-service';

// Router for KoInsight koreader plugin
const router = Router();

export const REQUIRED_PLUGIN_VERSION = '0.3.0';
export const REQUIRED_PLUGIN_VERSION = '0.3.1';

const rejectOldPluginVersion = (req: Request, res: Response, next: NextFunction) => {
const { version } = req.body;
Expand All @@ -26,6 +31,25 @@ const rejectOldPluginVersion = (req: Request, res: Response, next: NextFunction)
next();
};

const pluginCoverUpload = multer({
dest: appConfig.coversPath,
fileFilter: (_req, file, cb) => {
const allowedExtensions = ['.png', '.jpg', '.jpeg', '.gif'];
if (
file.mimetype === 'application/octet-stream' ||
allowedExtensions.some((ext) => file.originalname.toLowerCase().endsWith(ext))
) {
cb(null, true);
} else {
cb(new Error(`Only ${allowedExtensions.join(', ')} files are allowed`));
}
},
limits: { fileSize: 10 * 1024 * 1024 },
}).fields([
{ name: 'file', maxCount: 1 },
{ name: 'version', maxCount: 1 },
]);

router.post('/device', rejectOldPluginVersion, async (req, res) => {
const { id, model } = req.body;

Expand Down Expand Up @@ -65,13 +89,82 @@ router.post('/import', rejectOldPluginVersion, async (req, res) => {
);

await UploadService.uploadStatisticData(koreaderBooks, newPageStats, annotations, deviceId);
res.status(200).json({ message: 'Upload successful' });

const bookList = Array.isArray(koreaderBooks) ? koreaderBooks : [];
const importedMd5s = bookList.map((b) => b.md5).filter(Boolean) as string[];
const missing_cover_md5 = await CoversService.filterMd5WithoutCover(importedMd5s);

res.status(200).json({
message: 'Upload successful',
missing_cover_md5,
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error importing data' });
}
});

router.post('/books/:md5/cover', (req, res, next) => {
if (!existsSync(appConfig.coversPath)) {
mkdirSync(appConfig.coversPath, { recursive: true });
}
next();
}, (req, res, next) => {
pluginCoverUpload(req, res, (err) => {
if (err) {
console.error('Cover upload parse error:', err);
res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid upload' });
return;
}
next();
});
}, async (req: Request, res: Response) => {
const files = req.files as { file?: Express.Multer.File[] };
const file = files?.file?.[0];
const version = req.body?.version;

const cleanupTemp = () => {
if (file?.path) {
try {
unlink(file.path, () => {});
} catch {
// ignore
}
}
};

if (!version || version !== REQUIRED_PLUGIN_VERSION) {
cleanupTemp();
res.status(400).json({
error: `Unsupported plugin version. Version must be ${REQUIRED_PLUGIN_VERSION}. Please update your KOReader koinsight.koplugin`,
});
return;
}

if (!file) {
res.status(400).json({ error: 'Missing file upload' });
return;
}

const md5 = req.params.md5;
try {
const book = await BooksRepository.getByMd5(md5);
if (!book) {
cleanupTemp();
res.status(404).json({ error: 'Book not found' });
return;
}

await CoversService.deleteExisting(book);
await CoversService.upload(book, file);
res.status(200).json({ message: 'Cover updated' });
} catch (e) {
cleanupTemp();
console.error('Error uploading plugin cover:', e);
res.status(500).json({ error: 'Unable to update cover' });
}
});

// TODO: implement check in koreader plugin
router.get('/health', rejectOldPluginVersion, async (_, res) => {
res.status(200).json({ message: 'Plugin is healthy' });
Expand Down
Loading