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
36 changes: 25 additions & 11 deletions apps/server/src/ai/open-ai-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import OpenAI from 'openai';
import { zodResponseFormat } from 'openai/helpers/zod';
import { z } from 'zod';

let openAIClient: OpenAI | undefined;
if (process.env.OPENAI_API_KEY && process.env.OPENAI_PROJECT_ID && process.env.OPENAI_ORG_ID) {
Expand All @@ -11,27 +9,43 @@ if (process.env.OPENAI_API_KEY && process.env.OPENAI_PROJECT_ID && process.env.O
});
}

const BookInsights = z.object({
genres: z.string().array(),
summary: z.string(),
});
type BookInsights = {
genres: string[];
summary: string;
};

export async function getBookInsights(bookTitle: string, bookAuthor: string) {
const completion = await openAIClient?.beta.chat.completions.parse({
export async function getBookInsights(
bookTitle: string,
bookAuthor: string
): Promise<BookInsights | null> {
if (!openAIClient) {
return null;
}

const completion = await openAIClient.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'system',
content:
'You are an expert librarian. You know everything about every book. Respond with details about the book given the title and author',
'You are an expert librarian. You know everything about every book. Respond with details about the book given the title and author. Return a JSON object with "genres" (array of strings) and "summary" (string).',
},
{
role: 'user',
content: `What can you tell me about the book ${bookTitle} by ${bookAuthor}`,
},
],
response_format: zodResponseFormat(BookInsights, 'book_insights'),
response_format: { type: 'json_object' },
});

return completion?.choices[0].message.parsed;
const content = completion.choices[0].message.content;
if (!content) {
return null;
}

try {
return JSON.parse(content) as BookInsights;
} catch {
return null;
}
}
6 changes: 5 additions & 1 deletion apps/server/src/books/books-repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BookGenre, BookWithData } from '@koinsight/common/types';
import { Book } from '@koinsight/common/types/book';
import { Book, BookStatus } from '@koinsight/common/types/book';
import { BookDevice } from '@koinsight/common/types/book-device';
import { Genre } from '@koinsight/common/types/genre';
import { sum } from 'ramda';
Expand Down Expand Up @@ -122,4 +122,8 @@ export class BooksRepository {
static async setReferencePages(id: number, referencePages: number | null) {
return db<Book>('book').where({ id }).update({ reference_pages: referencePages });
}

static async setStatus(id: number, status: BookStatus) {
return db<Book>('book').where({ id }).update({ status });
}
}
29 changes: 29 additions & 0 deletions apps/server/src/books/books-router.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { BookStatus } from '@koinsight/common/types/book';
import { NextFunction, Request, Response, Router } from 'express';
import { BooksRepository } from './books-repository';
import { BooksService } from './books-service';
import { coversRouter } from './covers/covers-router';
import { getBookById } from './get-book-by-id-middleware';

const VALID_STATUSES: BookStatus[] = ['complete', 'reading', 'abandoned', 'on_hold', null];

const router = Router();

router.use('/:bookId/cover', coversRouter);
Expand Down Expand Up @@ -101,4 +104,30 @@ router.put('/:bookId/reference_pages', getBookById, async (req: Request, res: Re
}
});

/**
* Updates a book's status (complete, reading, abandoned, on_hold, or null)
*/
router.put('/:bookId/status', getBookById, async (req: Request, res: Response) => {
const book = req.book!;
const { status } = req.body;

if (status === undefined) {
res.status(400).json({ error: 'Missing required fields' });
return;
}

if (!VALID_STATUSES.includes(status)) {
res.status(400).json({ error: 'Invalid status value' });
return;
}

try {
await BooksRepository.setStatus(book.id, status);
res.status(200).json({ message: 'Status updated' });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to update status' });
}
});

export { router as booksRouter };
1 change: 1 addition & 0 deletions apps/server/src/db/factories/book-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function fakeBook(overrides: Partial<FakeBook> = {}): FakeBook {
series: faker.book.series(),
language: faker.location.language().alpha2,
soft_deleted: false,
status: null,
...overrides,
};

Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/db/migrations/20251223100000_add_status_to_book.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
return knex.schema.alterTable('book', (table) => {
table.string('status').defaultTo(null);
});
}

export async function down(knex: Knex): Promise<void> {
return knex.schema.alterTable('book', (table) => {
table.dropColumn('status');
});
}
6 changes: 3 additions & 3 deletions apps/server/src/koplugin/koplugin-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import { UploadService } from '../upload/upload-service';
// Router for KoInsight koreader plugin
const router = Router();

const REQUIRED_PLUGIN_VERSION = '0.1.0';
const REQUIRED_PLUGIN_VERSION = '0.2.0';

const rejectOldPluginVersion = (req: Request, res: Response, next: NextFunction) => {
const { version } = req.body;

if (!version || version !== '0.1.0') {
if (!version || version !== '0.2.0') {
res.status(400).json({
error: `Unsupported plugin version. Version must be ${REQUIRED_PLUGIN_VERSION}. Please update your KOReader koinsight.koplugin`,
});
Expand Down Expand Up @@ -56,7 +56,7 @@ router.post('/import', rejectOldPluginVersion, async (req, res) => {
console.debug('Importing books:', koreaderBooks);
console.debug('Importing page stats:', newPageStats);
await UploadService.uploadStatisticData(koreaderBooks, newPageStats);
res.status(200).json({ message: 'Upload successfull' });
res.status(200).json({ message: 'Upload successful' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error importing data' });
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/upload/upload-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,14 @@ export class UploadService {
authors: book.authors,
series: book.series,
language: book.language,
status: book.status ?? null,
}));

await Promise.all(
newBooks.map(({ id, ...book }) => trx<Book>('book').insert(book).onConflict('md5').ignore())
newBooks.map(({ id, ...book }) => {
const query = trx<Book>('book').insert(book).onConflict('md5');
return book.status ? query.merge(['status']) : query.ignore();
})
);

const hasUnknownDevices =
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/api/books.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Book, BookWithData } from '@koinsight/common/types';
import { Book, BookStatus, BookWithData } from '@koinsight/common/types';
import useSWR from 'swr';
import { API_URL, fetchFromAPI } from './api';

Expand Down Expand Up @@ -37,3 +37,7 @@ export function uploadBookCover(bookId: Book['id'], formData: FormData) {
headers: { Accept: 'multipart/form-data' },
});
}

export async function updateBookStatus(id: Book['id'], status: BookStatus) {
return fetchFromAPI<{ message: string }>(`books/${id}/status`, 'PUT', { status });
}
1 change: 1 addition & 0 deletions apps/web/src/components/status-icons/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AbandonedIcon, CompletedIcon, OnHoldIcon, ReadingIcon } from './status-icons';
127 changes: 127 additions & 0 deletions apps/web/src/components/status-icons/status-icons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { Tooltip } from '@mantine/core';
import { JSX } from 'react';

type StatusIconProps = {
size?: number;
withTooltip?: boolean;
};

export function CompletedIcon({ size = 16, withTooltip = true }: StatusIconProps): JSX.Element {
const icon = (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
width={size}
height={size}
style={{ flexShrink: 0, color: 'var(--mantine-color-green-6)' }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z"
/>
</svg>
);

if (withTooltip) {
return (
<Tooltip label="Completed" withArrow>
{icon}
</Tooltip>
);
}
return icon;
}

export function ReadingIcon({ size = 16, withTooltip = true }: StatusIconProps): JSX.Element {
const icon = (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
width={size}
height={size}
style={{ flexShrink: 0, color: 'var(--mantine-color-blue-6)' }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m12.75 15 3-3m0 0-3-3m3 3h-7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
);

if (withTooltip) {
return (
<Tooltip label="Reading" withArrow>
{icon}
</Tooltip>
);
}
return icon;
}

export function OnHoldIcon({ size = 16, withTooltip = true }: StatusIconProps): JSX.Element {
const icon = (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
width={size}
height={size}
style={{ flexShrink: 0, color: 'var(--mantine-color-yellow-6)' }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 9v6m-4.5 0V9M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
);

if (withTooltip) {
return (
<Tooltip label="On Hold" withArrow>
{icon}
</Tooltip>
);
}
return icon;
}

export function AbandonedIcon({ size = 16, withTooltip = true }: StatusIconProps): JSX.Element {
const icon = (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
width={size}
height={size}
style={{ flexShrink: 0, color: 'var(--mantine-color-red-6)' }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m20.25 7.5-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5m8.25 3v6.75m0 0-3-3m3 3 3-3M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z"
/>
</svg>
);

if (withTooltip) {
return (
<Tooltip label="Abandoned" withArrow>
{icon}
</Tooltip>
);
}
return icon;
}
Loading