├── apps ├── server │ ├── .gitignore │ ├── src │ │ ├── utils │ │ │ ├── strings.ts │ │ │ ├── strings.test.ts │ │ │ ├── ranges.ts │ │ │ └── ranges.test.ts │ │ ├── knex.ts │ │ ├── devices │ │ │ ├── devices-router.ts │ │ │ ├── devices-router.test.ts │ │ │ ├── device-repository.ts │ │ │ └── device-repository.test.ts │ │ ├── db │ │ │ ├── migrations │ │ │ │ ├── 20250412161000_create_device_table.ts │ │ │ │ ├── 20250119073031_add_soft_deleted_to_book.ts │ │ │ │ ├── 20250403145503_create_genre_table.ts │ │ │ │ ├── 20250412065854_add_reference_pages_to_book.ts │ │ │ │ ├── 20250401091204_create_user_table.ts │ │ │ │ ├── 20250403145555_create_book_genre_table.ts │ │ │ │ ├── 20250118201503_create_book_table.ts │ │ │ │ ├── 20250118202607_create_page_stat_table.ts │ │ │ │ ├── 20250401113133_create_progress_table.ts │ │ │ │ ├── 20250413124229_create_book_device_table.ts │ │ │ │ └── 20250412161907_use_book_md5_as_foreign_key.ts │ │ │ ├── factories │ │ │ │ ├── genre-factory.ts │ │ │ │ ├── device-factory.ts │ │ │ │ ├── book-factory.ts │ │ │ │ ├── page-stat-factory.ts │ │ │ │ └── book-device-factory.ts │ │ │ └── seeds │ │ │ │ ├── 03_book_devices.ts │ │ │ │ ├── 01_devices.ts │ │ │ │ ├── 04_page_stats.ts │ │ │ │ └── 02_books.ts │ │ ├── ai │ │ │ ├── open-ai-router.ts │ │ │ └── open-ai-service.ts │ │ ├── knexfile.ts │ │ ├── config.ts │ │ ├── books │ │ │ ├── get-book-by-id-middleware.ts │ │ │ ├── covers │ │ │ │ ├── covers-service.ts │ │ │ │ └── covers-router.ts │ │ │ ├── books-router.ts │ │ │ └── books-service.ts │ │ ├── kosync │ │ │ ├── kosync-authenticate-middleware.ts │ │ │ ├── user-repository.ts │ │ │ └── kosync-repository.ts │ │ ├── genres │ │ │ ├── genre-repository.ts │ │ │ └── genre-repository.test.ts │ │ ├── stats │ │ │ ├── stats-repository.ts │ │ │ ├── stats-router.ts │ │ │ ├── stats-router.test.ts │ │ │ ├── stats-repository.test.ts │ │ │ └── stats-service.ts │ │ ├── open-library │ │ │ ├── open-library-service.ts │ │ │ ├── open-library-router.ts │ │ │ └── open-library-types.ts │ │ ├── upload │ │ │ ├── upload-router.ts │ │ │ └── upload-service.ts │ │ ├── app.ts │ │ └── koplugin │ │ │ └── koplugin-router.ts │ ├── tsconfig.migrations.json │ ├── test │ │ └── setup │ │ │ └── test-setup.ts │ ├── vitest.config.ts │ ├── tsconfig.json │ └── package.json └── web │ ├── public │ ├── favicon.png │ └── book-placeholder-small.png │ ├── src │ ├── components │ │ ├── statistics │ │ │ ├── statistics.module.css │ │ │ ├── statistics.tsx │ │ │ ├── statistic.module.css │ │ │ ├── statistic.tsx │ │ │ └── reading-calendar.tsx │ │ ├── calendar │ │ │ ├── calendar-week.module.css │ │ │ ├── calendar-week.tsx │ │ │ ├── calendar.module.css │ │ │ └── calendar.tsx │ │ ├── dot-trail │ │ │ ├── dot-trail.module.css │ │ │ └── dot-trail.tsx │ │ ├── charts │ │ │ └── custom-bar.tsx │ │ ├── logo │ │ │ ├── logo.module.css │ │ │ └── logo.tsx │ │ ├── empty-state │ │ │ └── empty-state.tsx │ │ └── navbar │ │ │ ├── download-plugin.tsx │ │ │ ├── navbar.module.css │ │ │ ├── upload-form.tsx │ │ │ └── navbar.tsx │ ├── api │ │ ├── devices.ts │ │ ├── upload-db-file.ts │ │ ├── use-book-with-data.ts │ │ ├── kosync.ts │ │ ├── open-library.ts │ │ ├── use-page-stats.ts │ │ ├── api.ts │ │ └── books.ts │ ├── pages │ │ ├── books-page │ │ │ ├── books-page.module.css │ │ │ ├── books-table.module.css │ │ │ ├── books-cards.module.css │ │ │ └── books-cards.tsx │ │ ├── book-page │ │ │ ├── book-page-cover-selector.module.css │ │ │ ├── book-card.module.css │ │ │ ├── book-page-manage │ │ │ │ ├── book-page-manage.tsx │ │ │ │ ├── book-hide.tsx │ │ │ │ ├── book-upload-cover.tsx │ │ │ │ ├── book-delete.tsx │ │ │ │ └── book-reference-pages.tsx │ │ │ ├── book-page-calendar.tsx │ │ │ ├── book-card.tsx │ │ │ └── book-page-raw.tsx │ │ └── calendar-page.tsx │ ├── routes.ts │ ├── index.css │ ├── index.tsx │ ├── app.module.css │ ├── utils │ │ └── dates.ts │ ├── assets │ │ ├── logo-mono.svg │ │ └── logo.svg │ └── app.tsx │ ├── vite-env.d.ts │ ├── postcss.config.js │ ├── tsconfig.json │ ├── index.html │ ├── vite.config.ts │ └── package.json ├── bruno ├── koinsight │ ├── books │ │ ├── folder.bru │ │ ├── get all books.bru │ │ ├── get book.bru │ │ ├── delete book.bru │ │ ├── get book cover.bru │ │ ├── hide book.bru │ │ ├── show book.bru │ │ ├── add genre.bru │ │ └── set reference pages.bru │ ├── stats │ │ ├── folder.bru │ │ ├── get all stats.bru │ │ └── get book stats.bru │ ├── devices │ │ ├── folder.bru │ │ └── get all devices.bru │ ├── kosync │ │ ├── folder.bru │ │ ├── auth.bru │ │ ├── get progress.bru │ │ ├── register.bru │ │ └── put progress.bru │ ├── openai │ │ ├── folder.bru │ │ └── book insights.bru │ ├── collection.bru │ └── bruno.json └── README.md ├── stylua.toml ├── images ├── heading.png ├── heading-dark.png ├── screenshots │ ├── book_d.png │ ├── book_l.png │ ├── home_d.png │ ├── home_l.png │ ├── book_ld.png │ ├── home_ld.png │ ├── stats_1_d.png │ ├── stats_1_l.png │ ├── stats_2_d.png │ ├── stats_2_l.png │ ├── stats_1_ld.png │ └── stats_2_ld.png ├── logo-mono.svg └── logo.svg ├── packages └── common │ ├── types │ ├── genre.ts │ ├── device.ts │ ├── user.ts │ ├── book-genre.ts │ ├── openai.ts │ ├── page-stat.ts │ ├── index.ts │ ├── book-device.ts │ ├── progress.ts │ ├── book.ts │ ├── books-api.ts │ └── stats-api.ts │ ├── package.json │ └── tsconfig.json ├── plugins └── koinsight.koplugin │ ├── const.lua │ ├── _meta.lua │ ├── db_reader.lua │ ├── call_api.lua │ ├── settings.lua │ └── upload.lua ├── .dockerignore ├── .gitignore ├── compose.yaml ├── .prettierrc ├── .vscode └── settings.json ├── package.json ├── turbo.json ├── .github └── workflows │ └── ci.yaml ├── Dockerfile ├── LICENSE.md └── release.sh /apps/server/.gitignore: -------------------------------------------------------------------------------- 1 | test/coverage 2 | -------------------------------------------------------------------------------- /bruno/koinsight/books/folder.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: books 3 | } 4 | -------------------------------------------------------------------------------- /bruno/koinsight/stats/folder.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: stats 3 | } 4 | -------------------------------------------------------------------------------- /bruno/koinsight/devices/folder.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: devices 3 | } 4 | -------------------------------------------------------------------------------- /bruno/koinsight/kosync/folder.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: kosync 3 | } 4 | -------------------------------------------------------------------------------- /bruno/koinsight/openai/folder.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: openai 3 | } 4 | -------------------------------------------------------------------------------- /stylua.toml: -------------------------------------------------------------------------------- 1 | indent_type = "Spaces" 2 | indent_width = 2 3 | column_width = 100 4 | -------------------------------------------------------------------------------- /bruno/README.md: -------------------------------------------------------------------------------- 1 | This is a collection of endpoints for [Bruno](https://www.usebruno.com) 2 | -------------------------------------------------------------------------------- /images/heading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/images/heading.png -------------------------------------------------------------------------------- /images/heading-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/images/heading-dark.png -------------------------------------------------------------------------------- /packages/common/types/genre.ts: -------------------------------------------------------------------------------- 1 | export type Genre = { 2 | id: number; 3 | name: string; 4 | } 5 | -------------------------------------------------------------------------------- /apps/web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/apps/web/public/favicon.png -------------------------------------------------------------------------------- /packages/common/types/device.ts: -------------------------------------------------------------------------------- 1 | export type Device = { 2 | id: string; 3 | model: string; 4 | }; 5 | -------------------------------------------------------------------------------- /images/screenshots/book_d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/images/screenshots/book_d.png -------------------------------------------------------------------------------- /images/screenshots/book_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/images/screenshots/book_l.png -------------------------------------------------------------------------------- /images/screenshots/home_d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/images/screenshots/home_d.png -------------------------------------------------------------------------------- /images/screenshots/home_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/images/screenshots/home_l.png -------------------------------------------------------------------------------- /plugins/koinsight.koplugin/const.lua: -------------------------------------------------------------------------------- 1 | local const = {} 2 | 3 | const.VERSION = "0.1.0" 4 | 5 | return const 6 | -------------------------------------------------------------------------------- /images/screenshots/book_ld.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/images/screenshots/book_ld.png -------------------------------------------------------------------------------- /images/screenshots/home_ld.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/images/screenshots/home_ld.png -------------------------------------------------------------------------------- /images/screenshots/stats_1_d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/images/screenshots/stats_1_d.png -------------------------------------------------------------------------------- /images/screenshots/stats_1_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/images/screenshots/stats_1_l.png -------------------------------------------------------------------------------- /images/screenshots/stats_2_d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/images/screenshots/stats_2_d.png -------------------------------------------------------------------------------- /images/screenshots/stats_2_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/images/screenshots/stats_2_l.png -------------------------------------------------------------------------------- /apps/web/src/components/statistics/statistics.module.css: -------------------------------------------------------------------------------- 1 | .statistics { 2 | > * { 3 | flex-grow: 1; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /images/screenshots/stats_1_ld.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/images/screenshots/stats_1_ld.png -------------------------------------------------------------------------------- /images/screenshots/stats_2_ld.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/images/screenshots/stats_2_ld.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | ./.docker-data/ 2 | ./.vscode/ 3 | ./data/ 4 | 5 | .turbo/ 6 | .env 7 | .env.* 8 | dist 9 | node_modules 10 | -------------------------------------------------------------------------------- /packages/common/types/user.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: number; 3 | username: string; 4 | password_hash: string; 5 | }; 6 | -------------------------------------------------------------------------------- /apps/web/public/book-placeholder-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeorgeSG/KoInsight/HEAD/apps/web/public/book-placeholder-small.png -------------------------------------------------------------------------------- /packages/common/types/book-genre.ts: -------------------------------------------------------------------------------- 1 | export type BookGenre = { 2 | id: number; 3 | book_md5: string; 4 | genre_id: number; 5 | }; 6 | -------------------------------------------------------------------------------- /packages/common/types/openai.ts: -------------------------------------------------------------------------------- 1 | export type BookDetails = { 2 | genres: string[]; 3 | summary: string; 4 | cover: string; 5 | }; 6 | -------------------------------------------------------------------------------- /apps/server/src/utils/strings.ts: -------------------------------------------------------------------------------- 1 | export function generateMd5Hash(title: string): string { 2 | return require('crypto').createHash('md5').update(title).digest('hex'); 3 | } 4 | -------------------------------------------------------------------------------- /plugins/koinsight.koplugin/_meta.lua: -------------------------------------------------------------------------------- 1 | local _ = require("gettext") 2 | return { 3 | name = "koinsight", 4 | fullname = _("KoInsight"), 5 | description = _([[KoInsight sync plugin.]]), 6 | } 7 | -------------------------------------------------------------------------------- /bruno/koinsight/collection.bru: -------------------------------------------------------------------------------- 1 | vars:pre-request { 2 | port: 3001 3 | username: GeorgeSG 4 | password: test 5 | host: localhost 6 | book_md5: 8910339ab792480534bd96120b4b3c14 7 | book_id: 28 8 | } 9 | -------------------------------------------------------------------------------- /bruno/koinsight/stats/get all stats.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get all stats 3 | type: http 4 | seq: 1 5 | } 6 | 7 | get { 8 | url: http://{{host}}:{{port}}/api/stats 9 | body: none 10 | auth: inherit 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .docker-data/ 2 | data/ 3 | dist/ 4 | 5 | node_modules 6 | .DS_Store 7 | .tsbuildinfo 8 | .turbo 9 | .env 10 | .env 11 | .env*.local 12 | .env.development 13 | .env.production 14 | 15 | 16 | *.tsbuildinfo 17 | -------------------------------------------------------------------------------- /bruno/koinsight/devices/get all devices.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get all devices 3 | type: http 4 | seq: 1 5 | } 6 | 7 | get { 8 | url: http://{{host}}:{{port}}/api/devices 9 | body: none 10 | auth: inherit 11 | } 12 | -------------------------------------------------------------------------------- /bruno/koinsight/stats/get book stats.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get book stats 3 | type: http 4 | seq: 2 5 | } 6 | 7 | get { 8 | url: http://{{host}}:{{port}}/api/stats/{{book_md5}} 9 | body: none 10 | auth: inherit 11 | } 12 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@koinsight/common", 3 | "version": "v0.1.4", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "tsc", 8 | "dev": "tsc --watch" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /apps/server/src/knex.ts: -------------------------------------------------------------------------------- 1 | import knex, { Knex } from 'knex'; 2 | import { appConfig } from './config'; 3 | import config from './knexfile'; 4 | 5 | const environment = appConfig.env || 'development'; 6 | export const db: Knex = knex(config[environment]); 7 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | koinsight: 3 | build: . 4 | container_name: koinsight 5 | ports: 6 | - "3005:3000" 7 | volumes: 8 | - ./.docker-data:/app/data 9 | - ./.env:/app/.env 10 | restart: unless-stopped 11 | -------------------------------------------------------------------------------- /bruno/koinsight/books/get all books.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get all books 3 | type: http 4 | seq: 1 5 | } 6 | 7 | get { 8 | url: http://{{host}}:{{port}}/api/books 9 | body: json 10 | auth: inherit 11 | } 12 | 13 | params:query { 14 | ~: 15 | } 16 | -------------------------------------------------------------------------------- /bruno/koinsight/books/get book.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get book 3 | type: http 4 | seq: 2 5 | } 6 | 7 | get { 8 | url: http://{{host}}:{{port}}/api/books/{{book_id}}/ 9 | body: json 10 | auth: inherit 11 | } 12 | 13 | params:query { 14 | ~: 15 | } 16 | -------------------------------------------------------------------------------- /bruno/koinsight/books/delete book.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: delete book 3 | type: http 4 | seq: 4 5 | } 6 | 7 | delete { 8 | url: http://{{host}}:{{port}}/api/books/{{book_id}}/ 9 | body: json 10 | auth: inherit 11 | } 12 | 13 | params:query { 14 | ~: 15 | } 16 | -------------------------------------------------------------------------------- /bruno/koinsight/books/get book cover.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get book cover 3 | type: http 4 | seq: 3 5 | } 6 | 7 | get { 8 | url: http://{{host}}:{{port}}/api/books/{{book_id}}/cover 9 | body: json 10 | auth: inherit 11 | } 12 | 13 | params:query { 14 | ~: 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/src/api/devices.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr'; 2 | import { fetchFromAPI } from './api'; 3 | import { Device } from '@koinsight/common/types/device'; 4 | 5 | export function useDevices() { 6 | return useSWR('devices', () => fetchFromAPI('devices'), { fallbackData: [] }); 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/src/api/upload-db-file.ts: -------------------------------------------------------------------------------- 1 | import { API_URL } from './api'; 2 | 3 | export function uploadDbFile(formData: FormData) { 4 | return fetch(`${API_URL}/upload`, { 5 | method: 'POST', 6 | body: formData, 7 | headers: { Accept: 'multipart/form-data' }, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /bruno/koinsight/kosync/auth.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: auth 3 | type: http 4 | seq: 2 5 | } 6 | 7 | get { 8 | url: http://{{host}}:{{port}}/users/auth 9 | body: json 10 | auth: inherit 11 | } 12 | 13 | headers { 14 | x-auth-user: {{username}} 15 | x-auth-key: {{password}} 16 | } 17 | -------------------------------------------------------------------------------- /bruno/koinsight/bruno.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1", 3 | "name": "KoInsight", 4 | "type": "collection", 5 | "ignore": [ 6 | "node_modules", 7 | ".git" 8 | ], 9 | "size": 0.0003681182861328125, 10 | "filesCount": 3, 11 | "presets": { 12 | "requestType": "http" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/src/api/use-book-with-data.ts: -------------------------------------------------------------------------------- 1 | import { BookWithData } from '@koinsight/common/types'; 2 | import useSWR from 'swr'; 3 | import { fetchFromAPI } from './api'; 4 | 5 | export function useBookWithData(id: number) { 6 | return useSWR(`books/${id}`, () => fetchFromAPI(`books/${id}`)); 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/src/components/calendar/calendar-week.module.css: -------------------------------------------------------------------------------- 1 | .CalendarWeek { 2 | display: grid; 3 | grid-template-columns: repeat(7, 1fr); 4 | background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-gray-8)); 5 | text-align: center; 6 | padding: 10px 0; 7 | font-weight: bold; 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/src/pages/books-page/books-page.module.css: -------------------------------------------------------------------------------- 1 | .Controls { 2 | display: flex; 3 | gap: 1rem; 4 | justify-content: space-between; 5 | margin-bottom: var(--mantine-spacing-xl); 6 | } 7 | 8 | @media (max-width: $mantine-breakpoint-md) { 9 | .Controls { 10 | flex-direction: column; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /bruno/koinsight/kosync/get progress.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get progress 3 | type: http 4 | seq: 4 5 | } 6 | 7 | get { 8 | url: http://{{host}}:{{port}}/syncs/progress 9 | body: json 10 | auth: inherit 11 | } 12 | 13 | headers { 14 | x-auth-user: {{username}} 15 | x-auth-key: {{password}} 16 | } 17 | -------------------------------------------------------------------------------- /bruno/koinsight/openai/book insights.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: book insights 3 | type: http 4 | seq: 1 5 | } 6 | 7 | get { 8 | url: http://{{host}}:{{port}}/api/book-insights 9 | body: json 10 | auth: inherit 11 | } 12 | 13 | headers { 14 | x-auth-user: {{username}} 15 | x-auth-key: {{password}} 16 | } 17 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "es5", 8 | "bracketSpacing": true, 9 | "jsxSingleQuote": false, 10 | "jsxBracketSameLine": false, 11 | "arrowParens": "always", 12 | "endOfLine": "lf" 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare const __APP_VERSION__: string; 5 | 6 | interface ImportMetaEnv { 7 | readonly VITE_WEB_API_URL: string; 8 | } 9 | 10 | interface ImportMeta { 11 | readonly env: ImportMetaEnv; 12 | } 13 | -------------------------------------------------------------------------------- /packages/common/types/page-stat.ts: -------------------------------------------------------------------------------- 1 | export type KoReaderPageStat = { 2 | page: number; 3 | start_time: number; 4 | duration: number; 5 | total_pages: number; 6 | id_book: number; 7 | }; 8 | 9 | export type PageStat = Omit & { 10 | device_id: string; 11 | book_md5: string; 12 | }; 13 | -------------------------------------------------------------------------------- /bruno/koinsight/kosync/register.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: register 3 | type: http 4 | seq: 1 5 | } 6 | 7 | post { 8 | url: http://{{host}}:{{port}}/users/create 9 | body: json 10 | auth: inherit 11 | } 12 | 13 | body:json { 14 | { 15 | "username": "{{username}}", 16 | "password": "{{password}}" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/web/src/pages/book-page/book-page-cover-selector.module.css: -------------------------------------------------------------------------------- 1 | .Placeholder { 2 | position: absolute; 3 | display: block; 4 | width: 180px; 5 | height: 250px; 6 | background-color: var(--mantine-color-gray-1); 7 | border-radius: 4px; 8 | z-index: 1; 9 | } 10 | 11 | .Cover { 12 | position: relative; 13 | z-index: 2; 14 | } 15 | -------------------------------------------------------------------------------- /bruno/koinsight/books/hide book.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: hide book 3 | type: http 4 | seq: 6 5 | } 6 | 7 | put { 8 | url: http://{{host}}:{{port}}/api/books/{{book_id}}/hide 9 | body: json 10 | auth: inherit 11 | } 12 | 13 | params:query { 14 | ~: 15 | } 16 | 17 | body:json { 18 | { 19 | "hidden": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /bruno/koinsight/books/show book.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: show book 3 | type: http 4 | seq: 7 5 | } 6 | 7 | put { 8 | url: http://{{host}}:{{port}}/api/books/{{book_id}}/hide 9 | body: json 10 | auth: inherit 11 | } 12 | 13 | params:query { 14 | ~: 15 | } 16 | 17 | body:json { 18 | { 19 | "hidden": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /bruno/koinsight/books/add genre.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: add genre 3 | type: http 4 | seq: 8 5 | } 6 | 7 | post { 8 | url: http://{{host}}:{{port}}/api/books/{{book_id}}/genres 9 | body: json 10 | auth: inherit 11 | } 12 | 13 | params:query { 14 | ~: 15 | } 16 | 17 | body:json { 18 | { 19 | "genreName": "fantasy" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.editor.customLabels.enabled": true, 3 | "workbench.editor.customLabels.patterns": { 4 | "**/server/**": "BE · ${filename}.${extname}", 5 | "**/common/**": "Common · ${filename}.${extname}", 6 | "**/web/**": "FE · ${filename}.${extname}" 7 | }, 8 | "typescript.preferences.importModuleSpecifier": "relative" 9 | } 10 | -------------------------------------------------------------------------------- /packages/common/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './book-device'; 2 | export * from './book'; 3 | export * from './books-api'; 4 | export * from './book-genre'; 5 | export * from './device'; 6 | export * from './genre'; 7 | export * from './openai'; 8 | export * from './page-stat'; 9 | export * from './progress'; 10 | export * from './user'; 11 | export * from './stats-api'; 12 | -------------------------------------------------------------------------------- /bruno/koinsight/books/set reference pages.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: set reference pages 3 | type: http 4 | seq: 5 5 | } 6 | 7 | put { 8 | url: http://{{host}}:{{port}}/api/books/{{book_id}}/reference_pages 9 | body: json 10 | auth: inherit 11 | } 12 | 13 | params:query { 14 | ~: 15 | } 16 | 17 | body:json { 18 | { 19 | "reference_pages": 3 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/common/types/book-device.ts: -------------------------------------------------------------------------------- 1 | import { Book } from './book'; 2 | import { Device } from './device'; 3 | 4 | export type BookDevice = { 5 | id: number; 6 | book_md5: Book['md5']; 7 | device_id: Device['id']; 8 | last_open: number; 9 | notes: number; 10 | highlights: number; 11 | pages: number; 12 | total_read_time: number; 13 | total_read_pages: number; 14 | }; 15 | -------------------------------------------------------------------------------- /apps/server/src/devices/devices-router.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | import { DeviceRepository } from '../devices/device-repository'; 3 | const router = Router(); 4 | 5 | router.get('/', async (_: Request, res: Response) => { 6 | const devices = await DeviceRepository.getAll(); 7 | res.json(devices); 8 | }); 9 | 10 | export { router as devicesRouter }; 11 | -------------------------------------------------------------------------------- /packages/common/types/progress.ts: -------------------------------------------------------------------------------- 1 | import { User } from './user'; 2 | 3 | export type Progress = { 4 | id: number; 5 | user_id: number; 6 | document: string; 7 | progress: string; 8 | percentage: number; 9 | device: string; 10 | device_id: string; 11 | created_at: Date; 12 | updated_at: Date; 13 | }; 14 | 15 | export type ProgressWithUsername = Progress & Pick; 16 | -------------------------------------------------------------------------------- /apps/web/src/components/dot-trail/dot-trail.module.css: -------------------------------------------------------------------------------- 1 | .DotGrid { 2 | display: grid; 3 | grid-template-rows: repeat(7, 1fr); 4 | grid-gap: 2px; 5 | justify-content: flex-start; 6 | grid-auto-flow: column; 7 | width: 100%; 8 | } 9 | 10 | .Dot { 11 | width: 16px; 12 | height: 16px; 13 | border-radius: 4px; 14 | outline: 1px solid rgba(0, 0, 0, 0.2); 15 | outline-offset: -1px; 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/routes.ts: -------------------------------------------------------------------------------- 1 | import { generatePath } from 'react-router'; 2 | 3 | export enum RoutePath { 4 | BOOKS = '/books', 5 | BOOK = '/books/:id', 6 | CALENDAR = '/calendar/', 7 | STATS = '/stats/', 8 | SYNCS = '/syncs', 9 | 10 | HOME = BOOKS, 11 | } 12 | 13 | export function getBookPath(bookId: number | string): string { 14 | return generatePath(RoutePath.BOOK, { id: bookId.toString() }); 15 | } 16 | -------------------------------------------------------------------------------- /apps/server/src/db/migrations/20250412161000_create_device_table.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('device', (table) => { 5 | table.string('id').primary(); 6 | table.string('model'); 7 | }); 8 | } 9 | 10 | export async function down(knex: Knex): Promise { 11 | return knex.schema.dropTableIfExists('device'); 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/src/pages/book-page/book-card.module.css: -------------------------------------------------------------------------------- 1 | .InfoText { 2 | color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); 3 | font-size: var(--mantine-font-size-xs); 4 | font-weight: 600; 5 | } 6 | 7 | .Author { 8 | color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); 9 | font-size: var(--mantine-font-size-xs); 10 | font-weight: 700; 11 | text-transform: uppercase; 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/src/components/charts/custom-bar.tsx: -------------------------------------------------------------------------------- 1 | import { BarProps } from 'recharts'; 2 | 3 | export const CustomBar = (props: BarProps & { accent: string }) => { 4 | const { x, y, width, height, fill, accent } = props; 5 | return ( 6 | <> 7 | 8 | {height && height > 0 && } 9 | 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /apps/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | autoprefixer: {}, 4 | 'postcss-preset-mantine': {}, 5 | 'postcss-simple-vars': { 6 | variables: { 7 | 'mantine-breakpoint-xs': '36em', 8 | 'mantine-breakpoint-sm': '48em', 9 | 'mantine-breakpoint-md': '62em', 10 | 'mantine-breakpoint-lg': '75em', 11 | 'mantine-breakpoint-xl': '88em', 12 | }, 13 | }, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /apps/web/src/api/kosync.ts: -------------------------------------------------------------------------------- 1 | import { ProgressWithUsername } from '@koinsight/common/types/progress'; 2 | import useSWR from 'swr'; 3 | import { SERVER_URL } from './api'; 4 | 5 | export function useProgresses() { 6 | return useSWR( 7 | 'progresses', 8 | () => 9 | fetch(`${SERVER_URL}/syncs/progress`).then( 10 | (res) => res.json() as Promise 11 | ), 12 | { fallbackData: [] } 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/server/tsconfig.migrations.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./test/dist/migrations", 5 | "rootDir": "./src/db/migrations", 6 | "module": "CommonJS", 7 | "target": "ES2020", 8 | "esModuleInterop": true, 9 | "declaration": false, 10 | "noEmit": false, 11 | "incremental": true, 12 | "isolatedModules": true, 13 | }, 14 | "include": ["src/db/migrations/**/*.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /apps/server/src/db/migrations/20250119073031_add_soft_deleted_to_book.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.alterTable('book', (table) => { 5 | table.boolean('soft_deleted').defaultTo(false); 6 | }); 7 | } 8 | 9 | export async function down(knex: Knex): Promise { 10 | return knex.schema.alterTable('book', (table) => { 11 | table.dropColumn('soft_deleted'); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /apps/server/src/db/migrations/20250403145503_create_genre_table.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('genre', (table) => { 5 | table.increments('id').primary(); 6 | table.string('name').notNullable(); 7 | 8 | table.unique(['name']); 9 | }); 10 | } 11 | 12 | export async function down(knex: Knex): Promise { 13 | await knex.schema.dropTableIfExists('genre'); 14 | } 15 | -------------------------------------------------------------------------------- /apps/server/src/db/migrations/20250412065854_add_reference_pages_to_book.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.alterTable('book', (table) => { 5 | table.integer('reference_pages').defaultTo(null); 6 | }); 7 | } 8 | 9 | export async function down(knex: Knex): Promise { 10 | return knex.schema.alterTable('book', (table) => { 11 | table.dropColumn('reference_pages'); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /bruno/koinsight/kosync/put progress.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: put progress 3 | type: http 4 | seq: 3 5 | } 6 | 7 | put { 8 | url: http://{{host}}:{{port}}/syncs/progress 9 | body: json 10 | auth: inherit 11 | } 12 | 13 | headers { 14 | x-auth-user: {{username}} 15 | x-auth-key: {{password}} 16 | } 17 | 18 | body:json { 19 | { 20 | "document": "d1", 21 | "progress": "40%", 22 | "percentage": 30, 23 | "device": "dev1", 24 | "device_id": "dev1-id" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koinsight", 3 | "private": true, 4 | "version": "0.1.4", 5 | "packageManager": "npm@10.2.4", 6 | "license": "MIT", 7 | "engines": { 8 | "node": ">=22" 9 | }, 10 | "workspaces": [ 11 | "apps/*", 12 | "packages/*" 13 | ], 14 | "scripts": { 15 | "build": "turbo run build", 16 | "test:coverage": "turbo run test:coverage" 17 | }, 18 | "devDependencies": { 19 | "prettier": "3.6.2", 20 | "turbo": "2.5.8", 21 | "typescript": "5.9.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "rootDir": "./", 6 | "outDir": "dist", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "composite": true, 14 | "declaration": true, 15 | }, 16 | "include": ["**/*.ts"], 17 | "exclude": ["node_modules", "dist"] 18 | } 19 | -------------------------------------------------------------------------------- /apps/server/test/setup/test-setup.ts: -------------------------------------------------------------------------------- 1 | import { db } from '../../src/knex'; 2 | 3 | beforeAll(async () => { 4 | await db.migrate.latest(); 5 | }); 6 | 7 | beforeEach(async () => { 8 | await db.raw('PRAGMA foreign_keys = OFF'); 9 | 10 | const tables = ['book', 'book_device', 'book_genre', 'device', 'genre', 'page_stat', 'user']; 11 | 12 | for (const table of tables) { 13 | await db(table).truncate(); 14 | } 15 | 16 | await db.raw('PRAGMA foreign_keys = ON'); 17 | }); 18 | 19 | afterAll(async () => { 20 | await db.destroy(); 21 | }); 22 | -------------------------------------------------------------------------------- /apps/server/vitest.config.ts: -------------------------------------------------------------------------------- 1 | // vitest.config.ts 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | environment: 'node', 8 | setupFiles: ['./test/setup/test-setup.ts'], 9 | include: ['**/*.test.ts'], 10 | coverage: { 11 | provider: 'v8', 12 | reporter: ['text', 'html', 'lcov'], 13 | reportsDirectory: './test/coverage', 14 | include: ['src/**/*.ts'], 15 | exclude: ['src/db/migrations', 'src/db/seeds'], 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /apps/web/src/pages/books-page/books-table.module.css: -------------------------------------------------------------------------------- 1 | .SubTitle { 2 | display: flex; 3 | align-items: center; 4 | color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); 5 | font-size: var(--mantine-font-size-sm); 6 | } 7 | 8 | .BookCoverLink { 9 | position: relative; 10 | } 11 | 12 | .BookHidden { 13 | filter: grayscale(100%); 14 | filter: brightness(0.3); 15 | } 16 | 17 | .BookHiddenIndicator { 18 | position: absolute; 19 | z-index: 10; 20 | top: 2px; 21 | left: 2px; 22 | color: var(--mantine-color-koinsight-light-color); 23 | } 24 | -------------------------------------------------------------------------------- /apps/server/src/db/migrations/20250401091204_create_user_table.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('user', (table) => { 5 | table.increments('id').primary(); 6 | table.string('username').notNullable(); 7 | table.string('password_hash').notNullable(); 8 | table.timestamps(true, true); 9 | 10 | table.unique(['username']); 11 | }); 12 | } 13 | 14 | export async function down(knex: Knex): Promise { 15 | await knex.schema.dropTableIfExists('user'); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/components/logo/logo.module.css: -------------------------------------------------------------------------------- 1 | .Logo { 2 | display: flex; 3 | align-items: center; 4 | gap: var(--mantine-spacing-md); 5 | color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); 6 | text-decoration: none; 7 | transition: color 200ms ease; 8 | font-size: rem(20px); 9 | font-family: var(--mantine-font-family-headings); 10 | 11 | &:hover { 12 | color: var(--mantine-color-violet-text); 13 | } 14 | } 15 | 16 | .LogoImage { 17 | color: var(--mantine-color-koinsight-light-color); 18 | width: 32px; 19 | height: 32px; 20 | } 21 | -------------------------------------------------------------------------------- /apps/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "baseUrl": "./", 8 | "rootDir": "./src", 9 | "outDir": "./dist", 10 | "types": ["node", "vitest/globals"], 11 | "forceConsistentCasingInFileNames": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "noImplicitAny": true, 15 | "incremental": false, 16 | "isolatedModules": true 17 | }, 18 | "include": ["src/**/*.ts", "src/*.ts"], 19 | "exclude": ["node_modules"], 20 | } 21 | -------------------------------------------------------------------------------- /apps/server/src/ai/open-ai-router.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response, Router } from 'express'; 2 | import { getBookInsights } from './open-ai-service'; 3 | 4 | const router = Router(); 5 | 6 | router.get('/book-insights', async (req: Request, res: Response, next: NextFunction) => { 7 | const { title, author } = req.query; 8 | 9 | try { 10 | const book_insights = await getBookInsights(title?.toString() ?? '', author?.toString() ?? ''); 11 | res.send(book_insights); 12 | } catch { 13 | res.status(500).send('Failed to fetch data'); 14 | } 15 | }); 16 | 17 | export { router as openAiRouter }; 18 | -------------------------------------------------------------------------------- /apps/web/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | -webkit-font-smoothing: antialiased; 4 | -moz-osx-font-smoothing: grayscale; 5 | background-color: light-dark(#f0f0f0, var(--mantine-color-dark-9)); 6 | } 7 | 8 | code { 9 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 10 | } 11 | 12 | #root [data-variant='danger'] { 13 | background-color: light-dark(var(--mantine-color-red-7), var(--mantine-color-red-9)); 14 | color: var(--mantine-color-red-0); 15 | 16 | &:hover { 17 | background-color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-8)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "rootDir": "./src", 8 | "outDir": "dist", 9 | "baseUrl": "./", 10 | "allowSyntheticDefaultImports": true, 11 | "esModuleInterop": true, 12 | "jsx": "react-jsx", 13 | "jsxImportSource": "react", 14 | "types": ["vite/client", "vite-plugin-svgr/client"], 15 | "noEmit": true, 16 | "isolatedModules": true, 17 | "skipLibCheck": true, 18 | }, 19 | "include": ["src"], 20 | "exclude": ["node_modules", "dist"], 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/src/components/statistics/statistics.tsx: -------------------------------------------------------------------------------- 1 | import { Group } from '@mantine/core'; 2 | import { JSX } from 'react'; 3 | import { Statistic, StatisticProps } from './statistic'; 4 | 5 | import style from './statistics.module.css'; 6 | 7 | type StatisticsProps = { 8 | data: StatisticProps[]; 9 | }; 10 | 11 | export function Statistics({ data }: StatisticsProps): JSX.Element { 12 | return ( 13 | 14 | {data.map((stat) => ( 15 | 16 | ))} 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/common/types/book.ts: -------------------------------------------------------------------------------- 1 | export type KoReaderBook = { 2 | id: number; 3 | md5: string; 4 | title: string; 5 | authors: string; 6 | notes: number; 7 | last_open: number; 8 | highlights: number; 9 | pages: number; 10 | series: string; 11 | language: string; 12 | total_read_time: number; 13 | total_read_pages: number; 14 | }; 15 | 16 | export type DbBook = { 17 | id: number; 18 | md5: string; 19 | title: string; 20 | authors: string; 21 | series: string; 22 | language: string; 23 | }; 24 | 25 | export type Book = DbBook & { 26 | soft_deleted: boolean; 27 | reference_pages: number | null; 28 | }; 29 | -------------------------------------------------------------------------------- /apps/server/src/utils/strings.test.ts: -------------------------------------------------------------------------------- 1 | import { generateMd5Hash } from './strings'; 2 | 3 | describe(generateMd5Hash, () => { 4 | it('generates the same hash for the same string', () => { 5 | const title = 'test'; 6 | const hash1 = generateMd5Hash(title); 7 | const hash2 = generateMd5Hash(title); 8 | expect(hash1).toEqual(hash2); 9 | }); 10 | 11 | it('generates different hashes for different strings', () => { 12 | const title1 = 'test1'; 13 | const title2 = 'test2'; 14 | const hash1 = generateMd5Hash(title1); 15 | const hash2 = generateMd5Hash(title2); 16 | expect(hash1).not.toEqual(hash2); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /apps/web/src/api/open-library.ts: -------------------------------------------------------------------------------- 1 | import { Book } from '@koinsight/common/types/book'; 2 | import { fetchFromAPI } from './api'; 3 | 4 | export function saveCover(bookId: Book['id'], coverId: string) { 5 | const params = new URLSearchParams({ 6 | bookId: bookId.toString(), 7 | coverId, 8 | }); 9 | 10 | return fetchFromAPI(`open-library/cover?${params}`); 11 | } 12 | 13 | export function listCovers(searchTerm: string, limit: number = 3) { 14 | const params = new URLSearchParams({ 15 | searchTerm, 16 | limit: String(limit), 17 | }); 18 | 19 | return fetchFromAPI(`open-library/list-covers?${params.toString()}`); 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/src/components/calendar/calendar-week.tsx: -------------------------------------------------------------------------------- 1 | import { addDays } from 'date-fns/addDays'; 2 | import { format } from 'date-fns/format'; 3 | import { startOfWeek } from 'date-fns/startOfWeek'; 4 | import { range } from 'ramda'; 5 | import { JSX } from 'react'; 6 | 7 | import style from './calendar-week.module.css'; 8 | 9 | export function CalendarWeek({ currentDate }: { currentDate: Date }): JSX.Element { 10 | const startDate = startOfWeek(currentDate, { locale: { options: { weekStartsOn: 1 } } }); 11 | const days = range(0, 7).map((i) =>
{format(addDays(startDate, i), 'EEEEEE')}
); 12 | 13 | return
{days}
; 14 | } 15 | -------------------------------------------------------------------------------- /apps/server/src/db/migrations/20250403145555_create_book_genre_table.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('book_genre', (table) => { 5 | table.increments('id').primary(); 6 | table.integer('book_id').notNullable(); 7 | table.integer('genre_id').notNullable(); 8 | 9 | table.foreign('book_id').references('book.id'); 10 | table.foreign('genre_id').references('genre.id'); 11 | 12 | table.unique(['book_id', 'genre_id']); 13 | }); 14 | } 15 | 16 | export async function down(knex: Knex): Promise { 17 | await knex.schema.dropTableIfExists('book_genre'); 18 | } 19 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": [".env"], 4 | "globalEnv": [ 5 | "NODE_ENV", 6 | "HOSTNAME", 7 | "PORT", 8 | "VITE_WEB_HOSTNAME", 9 | "VITE_WEB_PORT", 10 | "OPENAI_API_KEY", 11 | "OPENAI_API_URL", 12 | "OPENAI_API_VERSION" 13 | ], 14 | "tasks": { 15 | "build": { 16 | "dependsOn": ["^build"], 17 | "outputs": ["dist/**"] 18 | }, 19 | "start": { 20 | "dependsOn": ["build"] 21 | }, 22 | "dev": { 23 | "persistent": true, 24 | "cache": false 25 | }, 26 | "test:coverage": { 27 | "dependsOn": ["^test:coverage"] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/server/src/db/factories/genre-factory.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { Genre } from '@koinsight/common/types'; 3 | import { Knex } from 'knex'; 4 | 5 | type FakeGenre = Omit; 6 | 7 | export function fakeGenre(overrides: Partial = {}): FakeGenre { 8 | const genre: FakeGenre = { 9 | name: faker.book.genre(), 10 | ...overrides, 11 | }; 12 | 13 | return genre; 14 | } 15 | 16 | export async function createGenre(db: Knex, overrides: Partial = {}): Promise { 17 | const genreData = fakeGenre(overrides); 18 | const [genre] = await db('genre').insert(genreData).returning('*'); 19 | return genre; 20 | } 21 | -------------------------------------------------------------------------------- /packages/common/types/books-api.ts: -------------------------------------------------------------------------------- 1 | import { Book } from './book'; 2 | import { BookDevice } from './book-device'; 3 | import { Genre } from './genre'; 4 | import { PageStat } from './page-stat'; 5 | 6 | type Stats = { 7 | last_open: number; 8 | total_read_time: number; 9 | total_pages: number; 10 | total_read_pages: number; 11 | unique_read_pages: number; 12 | notes: number; 13 | highlights: number; 14 | read_per_day: Record; 15 | started_reading: number; 16 | }; 17 | 18 | type RelatedEntities = { 19 | stats: PageStat[]; 20 | device_data: BookDevice[]; 21 | genres: Genre[]; 22 | }; 23 | 24 | export type BookWithData = Book & Stats & RelatedEntities; 25 | -------------------------------------------------------------------------------- /packages/common/types/stats-api.ts: -------------------------------------------------------------------------------- 1 | import { PageStat } from './page-stat'; 2 | 3 | export type PerMonthReadingTime = { 4 | month: string; 5 | duration: number; 6 | // FIXME: Date is used for sorting. Can just pass startOfMonth timestamp and format date in UI. 7 | date: number; 8 | }; 9 | 10 | export type PerDayOfTheWeek = { 11 | name: string; 12 | value: number; 13 | day: number; 14 | }; 15 | 16 | export type GetAllStatsResponse = { 17 | stats: PageStat[]; 18 | perMonth: PerMonthReadingTime[]; 19 | perDayOfTheWeek: PerDayOfTheWeek[]; 20 | mostPagesInADay: number; 21 | totalReadingTime: number; 22 | longestDay: number; 23 | last7DaysReadTime: number; 24 | totalPagesRead: number; 25 | }; 26 | -------------------------------------------------------------------------------- /apps/web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { BrowserRouter } from 'react-router'; 4 | import { App } from './app'; 5 | 6 | import '@mantine/charts/styles.css'; 7 | import '@mantine/core/styles.css'; 8 | import '@mantine/dates/styles.css'; 9 | import '@mantine/notifications/styles.css'; 10 | import './index.css'; 11 | 12 | const root = document.getElementById('root'); 13 | 14 | if (import.meta.env.DEV && !(root instanceof HTMLElement)) { 15 | throw new Error('Root element not found.'); 16 | } 17 | 18 | ReactDOM.createRoot(root!).render( 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | -------------------------------------------------------------------------------- /apps/server/src/db/factories/device-factory.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { Device } from '@koinsight/common/types'; 3 | import { Knex } from 'knex'; 4 | 5 | export function fakeDevice(overrides: Partial = {}): Device { 6 | const device: Device = { 7 | id: faker.string.uuid(), 8 | model: faker.helpers.arrayElement(['Kindle', 'Kobo', 'Nook', 'Note Air 3C']), 9 | ...overrides, 10 | }; 11 | 12 | return device; 13 | } 14 | 15 | export async function createDevice(db: Knex, overrides: Partial = {}): Promise { 16 | const deviceData = fakeDevice(overrides); 17 | const [device] = await db('device').insert(deviceData).returning('*'); 18 | 19 | return device; 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/src/components/statistics/statistic.module.css: -------------------------------------------------------------------------------- 1 | .Statistic { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | justify-content: center; 6 | width: 100%; 7 | height: 100%; 8 | padding: 1rem; 9 | background-color: #f5f5f5; 10 | border-radius: var(--mantine-radius-sm); 11 | } 12 | 13 | .Value { 14 | font-size: var(--mantine-font-size-xl); 15 | font-weight: 600; 16 | line-height: 1; 17 | margin: 0; 18 | } 19 | 20 | .Diff { 21 | line-height: 1; 22 | display: flex; 23 | align-items: center; 24 | } 25 | 26 | .Icon { 27 | color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3)); 28 | } 29 | 30 | .Title { 31 | font-weight: 900; 32 | text-transform: uppercase; 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | 21 | - name: Install dependencies 22 | run: npm ci 23 | 24 | - name: Run tests with coverage 25 | run: npm run test:coverage 26 | 27 | - name: Upload coverage to Coveralls 28 | uses: coverallsapp/github-action@v2 29 | with: 30 | github-token: ${{ secrets.GITHUB_TOKEN }} 31 | path-to-lcov: ./apps/server/test/coverage/lcov.info 32 | -------------------------------------------------------------------------------- /apps/web/src/api/use-page-stats.ts: -------------------------------------------------------------------------------- 1 | import { PageStat } from '@koinsight/common/types/page-stat'; 2 | import useSWR from 'swr'; 3 | import { fetchFromAPI } from './api'; 4 | import { GetAllStatsResponse } from '@koinsight/common/types'; 5 | 6 | export function usePageStats() { 7 | return useSWR('stats', () => fetchFromAPI('stats'), { 8 | fallbackData: { 9 | stats: [], 10 | perMonth: [], 11 | perDayOfTheWeek: [], 12 | mostPagesInADay: 0, 13 | totalReadingTime: 0, 14 | longestDay: 0, 15 | last7DaysReadTime: 0, 16 | totalPagesRead: 0, 17 | }, 18 | }); 19 | } 20 | 21 | export function useBookStats(bookMd5: string) { 22 | return useSWR(`stats/${bookMd5}`, () => fetchFromAPI(`stats/${bookMd5}`)); 23 | } 24 | -------------------------------------------------------------------------------- /apps/server/src/db/seeds/03_book_devices.ts: -------------------------------------------------------------------------------- 1 | import { BookDevice } from '@koinsight/common/types'; 2 | import { Knex } from 'knex'; 3 | import { db } from '../../knex'; 4 | import { createBookDevice } from '../factories/book-device-factory'; 5 | import { SEEDED_DEVICES } from './01_devices'; 6 | import { SEEDED_BOOKS } from './02_books'; 7 | 8 | export let SEEDED_BOOK_DEVICES: BookDevice[] = []; 9 | 10 | export async function seed(knex: Knex): Promise { 11 | await knex('book_device').del(); 12 | 13 | let promises: Promise[] = []; 14 | 15 | SEEDED_BOOKS.forEach((book) => { 16 | SEEDED_DEVICES.forEach((device) => { 17 | promises.push(createBookDevice(db, book, device)); 18 | }); 19 | }); 20 | 21 | SEEDED_BOOK_DEVICES = await Promise.all(promises); 22 | } 23 | -------------------------------------------------------------------------------- /apps/server/src/utils/ranges.ts: -------------------------------------------------------------------------------- 1 | export type Range = [number, number]; 2 | 3 | export function normalizeRanges(ranges: Range[]): Range[] { 4 | const epsilon = 1e-9; 5 | const sorted = [...ranges].sort((a, b) => a[0] - b[0]); 6 | 7 | const result: Range[] = []; 8 | 9 | for (const [start, end] of sorted) { 10 | if (result.length === 0) { 11 | result.push([start, end]); 12 | } else { 13 | const last = result[result.length - 1]; 14 | if (start <= last[1] + epsilon) { 15 | last[1] = Math.max(last[1], end); 16 | } else { 17 | result.push([start, end]); 18 | } 19 | } 20 | } 21 | 22 | return result; 23 | } 24 | 25 | export function totalRangeLength(ranges: Range[]): number { 26 | return ranges.reduce((acc, [start, end]) => acc + (end - start), 0); 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/pages/book-page/book-page-manage/book-page-manage.tsx: -------------------------------------------------------------------------------- 1 | import { Book } from '@koinsight/common/types'; 2 | import { Flex } from '@mantine/core'; 3 | import { JSX } from 'react'; 4 | import { BookHide } from './book-hide'; 5 | import { BookReferencePages } from './book-reference-pages'; 6 | import { BookUploadCover } from './book-upload-cover'; 7 | import { BookDelete } from './book-delete'; 8 | 9 | type BookPageManageProps = { 10 | book: Book; 11 | }; 12 | 13 | export function BookPageManage({ book }: BookPageManageProps): JSX.Element { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/server/src/db/migrations/20250118201503_create_book_table.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('book', (table) => { 5 | table.increments('id').primary(); 6 | table.text('title'); 7 | table.text('authors'); 8 | table.integer('notes').defaultTo(0); 9 | table.integer('last_open').defaultTo(0); 10 | table.integer('highlights').defaultTo(0); 11 | table.integer('pages').defaultTo(0); 12 | table.text('series'); 13 | table.text('language'); 14 | table.text('md5'); 15 | table.integer('total_read_time').defaultTo(0); 16 | table.integer('total_read_pages').defaultTo(0); 17 | }); 18 | } 19 | 20 | export async function down(knex: Knex): Promise { 21 | await knex.schema.dropTableIfExists('book'); 22 | } 23 | -------------------------------------------------------------------------------- /apps/server/src/db/migrations/20250118202607_create_page_stat_table.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('page_stat', (table) => { 5 | table.increments('id').primary(); 6 | table.integer('book_id').notNullable(); 7 | table.integer('page').notNullable().defaultTo(0); 8 | table.integer('start_time').notNullable().defaultTo(0); 9 | table.integer('duration').notNullable().defaultTo(0); 10 | table.integer('total_pages').notNullable().defaultTo(0); 11 | 12 | table.unique(['book_id', 'page', 'start_time']); 13 | table.foreign('book_id').references('book.id').onDelete('CASCADE'); 14 | }); 15 | } 16 | 17 | export async function down(knex: Knex): Promise { 18 | await knex.schema.dropTableIfExists('page_stat'); 19 | } 20 | -------------------------------------------------------------------------------- /apps/server/src/knexfile.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | import { appConfig } from './config'; 3 | 4 | const defaultConfig: Knex.Config = { 5 | useNullAsDefault: true, 6 | client: 'better-sqlite3', 7 | }; 8 | 9 | const config: { [key: string]: Knex.Config } = { 10 | development: { 11 | ...defaultConfig, 12 | connection: { filename: appConfig.db.dev }, 13 | seeds: { directory: './db/seeds' }, 14 | migrations: { directory: './db/migrations' }, 15 | }, 16 | production: { 17 | ...defaultConfig, 18 | connection: { filename: appConfig.db.prod }, 19 | migrations: { directory: './db/migrations' }, 20 | }, 21 | test: { 22 | ...defaultConfig, 23 | connection: { filename: ':memory:' }, 24 | migrations: { 25 | directory: './test/dist/migrations', 26 | }, 27 | }, 28 | }; 29 | 30 | export default config; 31 | -------------------------------------------------------------------------------- /apps/web/src/app.module.css: -------------------------------------------------------------------------------- 1 | .App { 2 | display: flex; 3 | justify-content: stretch; 4 | align-items: stretch; 5 | height: calc(100vh - 90px); 6 | max-width: min(1600px, 90vw); 7 | margin: 40px auto 12px; 8 | 9 | border-radius: var(--mantine-radius-lg); 10 | background-color: light-dark(#fff, var(--mantine-color-dark-8)); 11 | padding-block: var(--mantine-spacing-lg); 12 | box-shadow: var(--mantine-shadow-md); 13 | } 14 | 15 | .Main { 16 | padding: 4px var(--mantine-spacing-xl); 17 | overflow-y: auto; 18 | flex-grow: 1; 19 | } 20 | 21 | @media (max-width: $mantine-breakpoint-md) { 22 | .App { 23 | flex-direction: column; 24 | height: 100vh; 25 | max-width: 100vw; 26 | padding-block: var(--mantine-spacing-md); 27 | margin: 0 auto; 28 | } 29 | 30 | .Main { 31 | padding: 0 var(--mantine-spacing-lg); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/server/src/db/seeds/01_devices.ts: -------------------------------------------------------------------------------- 1 | import { Device } from '@koinsight/common/types/device'; 2 | import { Knex } from 'knex'; 3 | import { db } from '../../knex'; 4 | import { createDevice, fakeDevice } from '../factories/device-factory'; 5 | 6 | function generateDevices(): Device[] { 7 | const deviceNames = ['Kindle', 'Kobo', 'Nook', 'iPad', 'Android Tablet']; 8 | return deviceNames.map((name) => fakeDevice({ model: name })); 9 | } 10 | 11 | const SEED_DEVICES = generateDevices(); 12 | 13 | export let SEEDED_DEVICES: Device[] = []; 14 | 15 | export async function seed(knex: Knex): Promise { 16 | await knex('book_device').del(); 17 | await knex('book').del(); 18 | await knex('device').del(); 19 | 20 | const devices = await Promise.all(SEED_DEVICES.map((device) => createDevice(db, device))); 21 | 22 | SEEDED_DEVICES = devices as Device[]; 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | KoInsight 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /apps/server/src/db/migrations/20250401113133_create_progress_table.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('progress', (table) => { 5 | table.increments('id').primary(); 6 | table.integer('user_id').notNullable(); 7 | table.string('document').notNullable(); 8 | table.string('progress').notNullable(); 9 | table.float('percentage').notNullable(); 10 | table.string('device').notNullable(); 11 | table.string('device_id').notNullable(); 12 | 13 | table.timestamps(true, true); 14 | 15 | table.unique(['user_id', 'document', 'device_id']); 16 | table.foreign('user_id').references('id').inTable('user').onDelete('CASCADE'); 17 | }); 18 | } 19 | 20 | export async function down(knex: Knex): Promise { 21 | await knex.schema.dropTableIfExists('progress'); 22 | } 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Builder 2 | FROM node:22-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | COPY package.json . 7 | COPY apps/server/package.json ./apps/server/package.json 8 | COPY apps/web/package.json ./apps/web/package.json 9 | COPY packages/common/package.json ./packages/common/package.json 10 | 11 | RUN npm install 12 | 13 | COPY turbo.json . 14 | COPY apps ./apps 15 | COPY packages ./packages 16 | 17 | RUN npm run build 18 | 19 | # Runner 20 | FROM node:22-alpine AS runner 21 | 22 | WORKDIR /app 23 | RUN mkdir -p /app/data 24 | 25 | COPY --from=builder /app/node_modules /app/node_modules 26 | COPY --from=builder /app/apps/server/dist /app/apps/server/dist 27 | COPY --from=builder /app/apps/web/dist /app/apps/web/dist 28 | COPY plugins ./plugins 29 | 30 | ENV NODE_ENV="production" 31 | ENV DATA_PATH="/app/data" 32 | ENV MAX_FILE_SIZE_MB="100" 33 | 34 | CMD ["node", "./apps/server/dist/app.js"] 35 | -------------------------------------------------------------------------------- /apps/server/src/db/factories/book-factory.ts: -------------------------------------------------------------------------------- 1 | import { Book } from '@koinsight/common/types'; 2 | import { faker } from '@faker-js/faker'; 3 | import { Knex } from 'knex'; 4 | 5 | type FakeBook = Omit; 6 | 7 | export function fakeBook(overrides: Partial = {}): FakeBook { 8 | const book: FakeBook = { 9 | title: faker.book.title(), 10 | md5: faker.string.alphanumeric(32), 11 | reference_pages: faker.number.int({ min: 50, max: 1000 }), 12 | authors: faker.book.author(), 13 | series: faker.book.series(), 14 | language: faker.location.language().alpha2, 15 | soft_deleted: false, 16 | ...overrides, 17 | }; 18 | 19 | return book; 20 | } 21 | 22 | export async function createBook(db: Knex, overrides: Partial = {}): Promise { 23 | const bookData = fakeBook(overrides); 24 | const [book] = await db('book').insert(bookData).returning('*'); 25 | 26 | return book; 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/components/logo/logo.tsx: -------------------------------------------------------------------------------- 1 | import { useComputedColorScheme } from '@mantine/core'; 2 | import C from 'clsx'; 3 | import { JSX } from 'react'; 4 | import { NavLink } from 'react-router'; 5 | import LogoSVG from '../../assets/logo.svg?react'; 6 | import LogoMono from '../../assets/logo-mono.svg?react'; 7 | import { RoutePath } from '../../routes'; 8 | 9 | import style from './logo.module.css'; 10 | 11 | export type LogoProps = { 12 | className?: string; 13 | onClick?: () => void; 14 | }; 15 | 16 | export function Logo({ onClick, className }: LogoProps): JSX.Element { 17 | const colorScheme = useComputedColorScheme(); 18 | 19 | return ( 20 | 21 | 22 | {colorScheme === 'light' ? : } 23 | 24 | KoInsight 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/components/empty-state/empty-state.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | Flex, 4 | Stack, 5 | Text, 6 | Title, 7 | useComputedColorScheme, 8 | useMantineTheme, 9 | } from '@mantine/core'; 10 | import { IconProgressX } from '@tabler/icons-react'; 11 | 12 | export type EmptyStateProps = { 13 | title: string; 14 | description?: string; 15 | }; 16 | 17 | export function EmptyState({ title, description }: EmptyStateProps) { 18 | const theme = useComputedColorScheme(); 19 | const { colors } = useMantineTheme(); 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | {title} 27 | {description && {description}} 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/server/src/config.ts: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | import path from 'path'; 4 | 5 | const BASE_PATH = __dirname; 6 | const DATA_PATH = process.env.DATA_PATH || path.resolve(BASE_PATH, '../../../', 'data'); 7 | const MAX_FILE_SIZE_MB = Number(process.env.MAX_FILE_SIZE_MB) || 100; 8 | 9 | const UPLOAD_DB_FILENAME = 'statistics.sqlite3'; 10 | 11 | export const appConfig = { 12 | hostname: process.env.HOSTNAME || '127.0.0.1', 13 | port: Number(process.env.PORT ?? 3000), 14 | env: process.env.NODE_ENV, 15 | 16 | coversPath: path.resolve(DATA_PATH, 'covers'), 17 | 18 | dataPath: DATA_PATH, 19 | 20 | webBuildPath: path.join(BASE_PATH, '../../web/dist'), 21 | 22 | upload: { 23 | filename: UPLOAD_DB_FILENAME, 24 | path: path.resolve(DATA_PATH, UPLOAD_DB_FILENAME), 25 | maxFileSizeMegaBytes: MAX_FILE_SIZE_MB, 26 | }, 27 | 28 | db: { 29 | dev: path.resolve(DATA_PATH, 'dev.sqlite3'), 30 | prod: path.resolve(DATA_PATH, 'prod.sqlite3'), 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /apps/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { defineConfig, loadEnv } from 'vite'; 3 | import svgr from 'vite-plugin-svgr'; 4 | 5 | export default ({ mode }) => { 6 | const env = loadEnv(mode, process.cwd(), 'VITE_'); 7 | 8 | const PORT = Number(env.VITE_WEB_PORT ?? 3000); 9 | const HOST = env.VITE_WEB_HOSTNAME || 'localhost'; 10 | 11 | return defineConfig({ 12 | plugins: [react(), svgr()], 13 | css: { postcss: './postcss.config.cjs' }, 14 | server: { host: HOST, port: PORT }, 15 | define: { '__APP_VERSION__': JSON.stringify(process.env.npm_package_version) }, 16 | build: { 17 | target: 'esnext', 18 | outDir: './dist', 19 | emptyOutDir: true, 20 | }, 21 | resolve: { 22 | alias: { 23 | // /esm/icons/index.mjs only exports the icons statically, so no separate chunks are created 24 | '@tabler/icons-react': '@tabler/icons-react/dist/esm/icons/index.mjs', 25 | }, 26 | }, 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /apps/server/src/books/get-book-by-id-middleware.ts: -------------------------------------------------------------------------------- 1 | import { Book } from '@koinsight/common/types/book'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | import { BooksRepository } from './books-repository'; 4 | 5 | declare global { 6 | namespace Express { 7 | interface Request { 8 | book?: Book; 9 | } 10 | } 11 | } 12 | 13 | export async function getBookById(req: Request, res: Response, next: NextFunction) { 14 | const bookId = req.params.bookId; 15 | 16 | if (!bookId) { 17 | res.status(400).json({ error: 'Book ID is required' }); 18 | return; 19 | } 20 | 21 | try { 22 | const book = await BooksRepository.getById(Number(bookId)); 23 | 24 | if (!book) { 25 | res.status(404).json({ error: 'Book not found' }); 26 | return; 27 | } 28 | 29 | req.book = book; 30 | next(); 31 | } catch (error) { 32 | console.error('Error fetching book:', error); 33 | res.status(500).json({ error: 'Internal server error' }); 34 | return; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /apps/web/src/api/api.ts: -------------------------------------------------------------------------------- 1 | export const API_URL = `${import.meta.env.VITE_WEB_API_URL ?? ''}/api`; 2 | export const SERVER_URL = `${import.meta.env.VITE_WEB_API_URL ?? ''}`; 3 | 4 | export async function fetchFromAPI( 5 | endpoint: string, 6 | method: string = 'GET', 7 | body: Record | null = null 8 | ) { 9 | let searchParams: string = ''; 10 | 11 | if (method === 'GET' && body) { 12 | let tempSearchParams = new URLSearchParams(); 13 | for (const [key, value] of Object.entries(body)) { 14 | tempSearchParams.set(key, String(value)); 15 | } 16 | 17 | searchParams = `?${tempSearchParams.toString()}`; 18 | } 19 | 20 | const response = await fetch(`${API_URL}/${endpoint}${searchParams}`, { 21 | method, 22 | body: method !== 'GET' && body ? JSON.stringify(body) : null, 23 | headers: { 'Content-Type': 'application/json' }, 24 | }); 25 | 26 | if (!response.ok) { 27 | throw new Error(`Failed to fetch data, ${method} ${endpoint}`); 28 | } 29 | return response.json() as Promise; 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Georgi Gardev (gar.dev) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /apps/server/src/kosync/kosync-authenticate-middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { UserRepository } from './user-repository'; 3 | import { User } from '@koinsight/common/types/user'; 4 | 5 | declare global { 6 | namespace Express { 7 | interface Request { 8 | user?: User; 9 | } 10 | } 11 | } 12 | 13 | export const authenticate = async (req: Request, res: Response, next: NextFunction) => { 14 | const username = req.header('x-auth-user'); 15 | const key = req.header('x-auth-key'); 16 | 17 | if (!username || !key) { 18 | res.status(401).json({ error: 'Missing authentication headers' }); 19 | return; 20 | } 21 | 22 | try { 23 | const user = await UserRepository.login(username, key); 24 | 25 | if (!user) { 26 | res.status(401).json({ error: 'Unauthorized' }); 27 | } else { 28 | req.user = user; 29 | next(); 30 | } 31 | } catch (err) { 32 | console.error('Auth middleware error:', err); 33 | res.status(500).json({ error: 'Internal server error' }); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /apps/server/src/devices/devices-router.test.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import request from 'supertest'; 3 | import { createDevice } from '../db/factories/device-factory'; 4 | import { db } from '../knex'; 5 | import { devicesRouter } from './devices-router'; 6 | 7 | describe('GET /devices', () => { 8 | const app = express(); 9 | app.use(express.json()); 10 | app.use('/devices', devicesRouter); 11 | 12 | it('returns all devices as JSON', async () => { 13 | await createDevice(db, { model: 'Device 1' }); 14 | 15 | let response = await request(app).get('/devices'); 16 | expect(response.status).toBe(200); 17 | expect(response.body).toHaveLength(1); 18 | expect(response.body[0]).toEqual(expect.objectContaining({ model: 'Device 1' })); 19 | 20 | await createDevice(db, { model: 'Device 2' }); 21 | 22 | response = await request(app).get('/devices'); 23 | expect(response.status).toBe(200); 24 | expect(response.body).toHaveLength(2); 25 | expect(response.body[1]).toEqual(expect.objectContaining({ model: 'Device 2' })); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /apps/web/src/components/calendar/calendar.module.css: -------------------------------------------------------------------------------- 1 | .Calendar { 2 | --border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-gray-7)); 3 | width: 100%; 4 | margin: auto; 5 | overflow: hidden; 6 | } 7 | 8 | .CalendarHeader { 9 | display: flex; 10 | justify-content: space-between; 11 | align-items: center; 12 | padding: var(--mantine-spacing-xs); 13 | border-bottom: 1px solid var(--border-color); 14 | } 15 | 16 | .CalendarGrid { 17 | display: grid; 18 | grid-template-columns: repeat(7, 1fr); 19 | } 20 | 21 | .CalendarDate { 22 | padding: var(--mantine-spacing-xs); 23 | border: 1px solid var(--border-color); 24 | min-height: 150px; 25 | } 26 | 27 | .CalendarDay { 28 | text-align: left; 29 | font-size: 1.1rem; 30 | margin-bottom: 2px; 31 | } 32 | 33 | .CalendarEvent { 34 | text-align: center; 35 | } 36 | 37 | .CalendarDateToday { 38 | background-color: var(--mantine-color-koinsight-light); 39 | font-weight: bold; 40 | border: 1px solid var(--mantine-color-koinsight-light-color); 41 | } 42 | 43 | .CalendarDateDisabled { 44 | color: var(--mantine-color-gray-5); 45 | } 46 | -------------------------------------------------------------------------------- /apps/server/src/db/factories/page-stat-factory.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { Book, BookDevice, Device, PageStat } from '@koinsight/common/types'; 3 | import { Knex } from 'knex'; 4 | 5 | export function fakePageStat( 6 | book: Book, 7 | bookDevice: BookDevice, 8 | device: Device, 9 | overrides: Partial = {} 10 | ): PageStat { 11 | const pageStat: PageStat = { 12 | device_id: device.id, 13 | book_md5: book.md5, 14 | page: faker.number.int({ min: 1, max: bookDevice.pages }), 15 | start_time: faker.date.past().getTime(), 16 | duration: faker.number.int({ min: 5, max: 120 }), 17 | total_pages: bookDevice.pages, 18 | ...overrides, 19 | }; 20 | 21 | return pageStat; 22 | } 23 | 24 | export async function createPageStat( 25 | db: Knex, 26 | book: Book, 27 | bookDevice: BookDevice, 28 | device: Device, 29 | overrides: Partial = {} 30 | ): Promise { 31 | const pageStatData = fakePageStat(book, bookDevice, device, overrides); 32 | const [pageStat] = await db('page_stat').insert(pageStatData).returning('*'); 33 | 34 | return pageStat; 35 | } 36 | -------------------------------------------------------------------------------- /apps/server/src/devices/device-repository.ts: -------------------------------------------------------------------------------- 1 | import { Device } from '@koinsight/common/types/device'; 2 | import { db } from '../knex'; 3 | 4 | export class DeviceRepository { 5 | static async getAll(): Promise { 6 | return db('device').select('*'); 7 | } 8 | 9 | static async getById(id: string): Promise { 10 | return db('device').where({ id }).first(); 11 | } 12 | 13 | static async getByModel(model: string): Promise { 14 | return db('device').where({ model }).first(); 15 | } 16 | 17 | static async insertIfNotExists(device: Device): Promise { 18 | const existingDevice = await this.getById(device.id); 19 | 20 | if (!existingDevice) { 21 | await db('device').insert(device); 22 | } 23 | } 24 | 25 | static async findOrCreateByModel(model: Device['model']): Promise { 26 | const existingDevice = await db('device').where({ model }).first(); 27 | 28 | if (existingDevice) { 29 | return existingDevice; 30 | } 31 | 32 | const [result] = await db('device').insert({ model }).returning('*'); 33 | return result; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /apps/server/src/genres/genre-repository.ts: -------------------------------------------------------------------------------- 1 | import { Genre } from '@koinsight/common/types/genre'; 2 | import { db } from '../knex'; 3 | 4 | type GenreCreate = Omit; 5 | 6 | export class GenreRepository { 7 | static async getAll(): Promise { 8 | return db('genre').select('*'); 9 | } 10 | 11 | static async getByName(name: string): Promise { 12 | return db('genre').where({ name }).first(); 13 | } 14 | 15 | static async getByBookMd5(md5: string): Promise { 16 | return db('genre') 17 | .select('genre.name') 18 | .join('book_genre', 'book_genre.id', 'genre.id') 19 | .where({ 'book_genre.book_md5': md5 }); 20 | } 21 | 22 | static async create(genre: GenreCreate): Promise { 23 | const [createdGenre] = await db('genre').insert(genre).returning('*'); 24 | 25 | return createdGenre; 26 | } 27 | 28 | static async findOrCreate(genre: GenreCreate): Promise { 29 | const existingGenre = await this.getByName(genre.name); 30 | 31 | if (existingGenre) { 32 | return existingGenre; 33 | } else { 34 | return this.create(genre); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/server/src/db/migrations/20250413124229_create_book_device_table.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | await knex.schema.createTable('book_device', (table) => { 5 | table.increments('id').primary(); 6 | table.string('book_md5').references('book.md5'); 7 | table.string('device_id').references('device.id'); 8 | 9 | // Migrate from book table to per-device 10 | table.integer('last_open').defaultTo(0); 11 | table.integer('notes').defaultTo(0); 12 | table.integer('highlights').defaultTo(0); 13 | table.integer('pages').defaultTo(0); 14 | table.integer('total_read_time').defaultTo(0); 15 | table.integer('total_read_pages').defaultTo(0); 16 | 17 | table.unique(['book_md5', 'device_id']); 18 | }); 19 | 20 | await knex.schema.alterTable('book', (table) => { 21 | table.dropColumn('last_open'); 22 | table.dropColumn('notes'); 23 | table.dropColumn('highlights'); 24 | table.dropColumn('pages'); 25 | table.dropColumn('total_read_time'); 26 | table.dropColumn('total_read_pages'); 27 | }); 28 | } 29 | 30 | export async function down(knex: Knex): Promise { 31 | throw new Error('Down migration impossible'); 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/src/components/statistics/statistic.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, JSX } from 'react'; 2 | 3 | import { Group, Paper, Text } from '@mantine/core'; 4 | 5 | import style from './statistic.module.css'; 6 | 7 | export type StatisticProps = { 8 | label: string; 9 | value: string | number; 10 | icon: ComponentType<{ size: number; stroke: number; className: string }>; 11 | }; 12 | 13 | export function Statistic({ label, value, icon: Icon }: StatisticProps): JSX.Element { 14 | return ( 15 | 16 | 17 | 18 | {label} 19 | 20 | 21 | 22 | 23 | 24 |

{value}

25 | {/* 0 ? 'teal' : 'red'} fz="sm" fw={500} className={style.diff}> 26 | {stat.diff}% 27 | 28 | */} 29 |
30 | 31 | {/* 32 | Compared to previous month 33 | */} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /apps/server/src/stats/stats-repository.ts: -------------------------------------------------------------------------------- 1 | import { PageStat } from '@koinsight/common/types/page-stat'; 2 | import { db } from '../knex'; 3 | 4 | export class StatsRepository { 5 | private static updateStartTime(stat: PageStat): PageStat { 6 | return { ...stat, start_time: stat.start_time * 1000 }; 7 | } 8 | 9 | static async getAll(): Promise { 10 | const stats = await db('page_stat') 11 | .join('book', 'page_stat.book_md5', 'book.md5') 12 | .where({ 'book.soft_deleted': false }) 13 | .select('page_stat.*'); 14 | 15 | return stats.map(this.updateStartTime); 16 | } 17 | 18 | static async getByBookMD5(book_md5: string): Promise { 19 | const bookStats = await db('page_stat').where({ book_md5 }); 20 | return bookStats.map(this.updateStartTime); 21 | } 22 | 23 | static async insert(data: PageStat): Promise { 24 | return db('page_stat').insert(data); 25 | } 26 | 27 | static async update( 28 | book_md5: string, 29 | device_id: string, 30 | page: number, 31 | start_time: number, 32 | data: Partial 33 | ): Promise { 34 | return db('page_stat').where({ book_md5, device_id, page, start_time }).update(data); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /apps/server/src/db/factories/book-device-factory.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { Book, BookDevice, Device } from '@koinsight/common/types'; 3 | import { Knex } from 'knex'; 4 | 5 | type FakeBookDevice = Omit; 6 | 7 | export function fakeBookDevice( 8 | book: Book, 9 | device: Device, 10 | overrides: Partial = {} 11 | ): FakeBookDevice { 12 | const bookDevice: FakeBookDevice = { 13 | book_md5: book.md5, 14 | device_id: device.id, 15 | last_open: faker.date.past().getTime() / 1000, 16 | notes: faker.number.int({ min: 0, max: 100 }), 17 | highlights: faker.number.int({ min: 0, max: 100 }), 18 | pages: faker.number.int({ min: 0, max: 1000 }), 19 | total_read_time: faker.number.int({ min: 0, max: 100 }), 20 | total_read_pages: faker.number.int({ min: 0, max: 100 }), 21 | ...overrides, 22 | }; 23 | 24 | return bookDevice; 25 | } 26 | 27 | export async function createBookDevice( 28 | db: Knex, 29 | book: Book, 30 | device: Device, 31 | overrides: Partial = {} 32 | ): Promise { 33 | const bookDeviceData = fakeBookDevice(book, device, overrides); 34 | const [bookDevice] = await db('book_device').insert(bookDeviceData).returning('*'); 35 | 36 | return bookDevice; 37 | } 38 | -------------------------------------------------------------------------------- /apps/server/src/kosync/user-repository.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs'; 2 | import { db } from '../knex'; 3 | import { User } from '@koinsight/common/types/user'; 4 | 5 | const SALT_ROUNDS = 12; 6 | 7 | async function hashPassword(plain: string): Promise { 8 | return await bcrypt.hash(plain, SALT_ROUNDS); 9 | } 10 | 11 | export class UserExistsError extends Error { 12 | constructor(message?: string) { 13 | super(message); 14 | this.name = 'UserExistsError'; 15 | } 16 | } 17 | 18 | export class UserRepository { 19 | static async login(username: string, password: string): Promise { 20 | const user = await db('User').where({ username }).first(); 21 | if (!user) { 22 | return null; 23 | } 24 | const isPasswordValid = await bcrypt.compare(password, user.password_hash); 25 | if (!isPasswordValid) { 26 | return null; 27 | } 28 | 29 | return user; 30 | } 31 | 32 | static async createUser(username: string, password: string): Promise { 33 | const existingUser = await db('User').where({ username }).first(); 34 | 35 | if (existingUser) { 36 | throw new UserExistsError(); 37 | } 38 | 39 | const passwordHash = await hashPassword(password); 40 | await db('User').insert({ username, password_hash: passwordHash }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "v0.1.4", 4 | "description": "KoInsight front-end web app", 5 | "type": "module", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "vite", 9 | "dev": "vite", 10 | "build": "vite build" 11 | }, 12 | "devDependencies": { 13 | "@types/node": "22.12.0", 14 | "@types/ramda": "0.31.1", 15 | "@types/react": "19.0.2", 16 | "@types/react-dom": "19.0.2", 17 | "postcss": "8.5.6", 18 | "postcss-preset-mantine": "1.18.0", 19 | "postcss-simple-vars": "7.0.1", 20 | "typescript": "5.9.3", 21 | "vite": "7.1.10", 22 | "vite-plugin-svgr": "4.5.0" 23 | }, 24 | "dependencies": { 25 | "@koinsight/common": "*", 26 | "@mantine/charts": "8.3.5", 27 | "@mantine/core": "8.3.5", 28 | "@mantine/dates": "8.3.5", 29 | "@mantine/hooks": "8.3.5", 30 | "@mantine/modals": "8.3.5", 31 | "@mantine/notifications": "8.3.5", 32 | "@tabler/icons-react": "3.35.0", 33 | "@vitejs/plugin-react": "5.0.4", 34 | "autoprefixer": "10.4.21", 35 | "clsx": "2.1.1", 36 | "date-fns": "4.1.0", 37 | "dotenv": "17.2.3", 38 | "ramda": "0.31.1", 39 | "react": "18.3.1", 40 | "react-dom": "18.3.1", 41 | "react-router": "7.9.4", 42 | "recharts": "^2.15.0", 43 | "swr": "2.3.6" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /apps/server/src/ai/open-ai-service.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | import { zodResponseFormat } from 'openai/helpers/zod'; 3 | import { z } from 'zod'; 4 | 5 | let openAIClient: OpenAI | undefined; 6 | if (process.env.OPENAI_API_KEY && process.env.OPENAI_PROJECT_ID && process.env.OPENAI_ORG_ID) { 7 | openAIClient = new OpenAI({ 8 | apiKey: process.env.OPENAI_API_KEY, 9 | project: process.env.OPENAI_PROJECT_ID, 10 | organization: process.env.OPENAI_ORG_ID, 11 | }); 12 | } 13 | 14 | const BookInsights = z.object({ 15 | genres: z.string().array(), 16 | summary: z.string(), 17 | }); 18 | 19 | export async function getBookInsights(bookTitle: string, bookAuthor: string) { 20 | const completion = await openAIClient?.beta.chat.completions.parse({ 21 | model: 'gpt-4o', 22 | messages: [ 23 | { 24 | role: 'system', 25 | content: 26 | 'You are an expert librarian. You know everything about every book. Respond with details about the book given the title and author', 27 | }, 28 | { 29 | role: 'user', 30 | content: `What can you tell me about the book ${bookTitle} by ${bookAuthor}`, 31 | }, 32 | ], 33 | response_format: zodResponseFormat(BookInsights, 'book_insights'), 34 | }); 35 | 36 | return completion?.choices[0].message.parsed; 37 | } 38 | -------------------------------------------------------------------------------- /apps/web/src/api/books.ts: -------------------------------------------------------------------------------- 1 | import { Book, BookWithData } from '@koinsight/common/types'; 2 | import useSWR from 'swr'; 3 | import { API_URL, fetchFromAPI } from './api'; 4 | 5 | export function useBooks({ showHidden } = { showHidden: false }) { 6 | return useSWR( 7 | ['books', showHidden], 8 | () => fetchFromAPI('books', 'GET', { showHidden }), 9 | { 10 | fallbackData: [], 11 | } 12 | ); 13 | } 14 | 15 | export async function deleteBook(id: Book['id']) { 16 | return fetchFromAPI<{ message: string }>(`books/${id}`, 'DELETE'); 17 | } 18 | 19 | export async function hideBook(id: Book['id']) { 20 | return fetchFromAPI<{ message: string }>(`books/${id}/hide`, 'PUT', { hidden: true }); 21 | } 22 | 23 | export async function showBook(id: Book['id']) { 24 | return fetchFromAPI<{ message: string }>(`books/${id}/hide`, 'PUT', { hidden: false }); 25 | } 26 | 27 | export async function updateBookReferencePages(id: Book['id'], referencePages: number | null) { 28 | return fetchFromAPI(`books/${id}/reference_pages`, 'PUT', { 29 | reference_pages: referencePages, 30 | }); 31 | } 32 | 33 | export function uploadBookCover(bookId: Book['id'], formData: FormData) { 34 | return fetch(`${API_URL}/books/${bookId}/cover`, { 35 | method: 'POST', 36 | body: formData, 37 | headers: { Accept: 'multipart/form-data' }, 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /apps/server/src/books/covers/covers-service.ts: -------------------------------------------------------------------------------- 1 | import { Book } from '@koinsight/common/types'; 2 | import { existsSync, mkdirSync, promises, rename, rmSync } from 'fs'; 3 | import path from 'path'; 4 | import { appConfig } from '../../config'; 5 | 6 | export class CoversService { 7 | static async get(book: Book): Promise { 8 | const files = await promises.readdir(appConfig.coversPath); 9 | const file = files.find((f) => f.startsWith(book.md5)); 10 | 11 | if (file) { 12 | return `${appConfig.coversPath}/${file}`; 13 | } else { 14 | return null; 15 | } 16 | } 17 | 18 | static async deleteExisting(book: Book) { 19 | const files = await promises.readdir(appConfig.coversPath); 20 | const file = files.find((f) => f.startsWith(book.md5)); 21 | 22 | if (file) { 23 | const filePath = `${appConfig.coversPath}/${file}`; 24 | rmSync(filePath, { force: true }); 25 | } 26 | } 27 | 28 | static async upload(book: Book, file: Express.Multer.File) { 29 | if (!existsSync(appConfig.coversPath)) { 30 | mkdirSync(appConfig.coversPath, { recursive: true }); 31 | } 32 | 33 | const extension = path.extname(file.originalname) || ''; 34 | const newFilename = `${book.md5}${extension}`; 35 | const newPath = path.join(path.dirname(file.path), newFilename); 36 | await rename(file.path, newPath, () => {}); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/web/src/pages/books-page/books-cards.module.css: -------------------------------------------------------------------------------- 1 | .CardGrid { 2 | display: grid; 3 | grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 4 | justify-items: center; 5 | gap: var(--mantine-spacing-lg); 6 | } 7 | 8 | .Card { 9 | position: relative; 10 | cursor: pointer; 11 | border-radius: 10px; 12 | overflow: hidden; 13 | 14 | .CardDetails { 15 | display: flex; 16 | flex-direction: column; 17 | gap: 4px; 18 | width: 100%; 19 | background: light-dark(rgba(255, 255, 255, 0.85), rgba(0, 0, 0, 0.8)); 20 | backdrop-filter: blur(10px); 21 | position: absolute; 22 | bottom: -100px; 23 | opacity: 0; 24 | padding-block: var(--mantine-spacing-lg); 25 | 26 | transition: 27 | opacity 300ms ease, 28 | bottom 300ms ease; 29 | 30 | &.Small { 31 | padding-block: var(--mantine-spacing-xs); 32 | } 33 | } 34 | 35 | &:hover .CardDetails { 36 | opacity: 1; 37 | bottom: 0; 38 | } 39 | } 40 | 41 | .Attribute { 42 | font-size: var(--mantine-font-size-xs); 43 | color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6)); 44 | } 45 | 46 | .BookHiddenIndicator { 47 | position: absolute; 48 | z-index: 10; 49 | top: 8px; 50 | left: 8px; 51 | color: var(--mantine-color-koinsight-light-color); 52 | } 53 | 54 | .BookHidden { 55 | filter: grayscale(100%); 56 | filter: brightness(0.3); 57 | } 58 | -------------------------------------------------------------------------------- /apps/web/src/utils/dates.ts: -------------------------------------------------------------------------------- 1 | import { Duration } from 'date-fns'; 2 | import { formatDistanceToNow } from 'date-fns/formatDistanceToNow'; 3 | import { formatDuration } from 'date-fns/formatDuration'; 4 | import { intervalToDuration } from 'date-fns/intervalToDuration'; 5 | 6 | export function getDuration(seconds: number): Duration { 7 | return intervalToDuration({ start: 0, end: seconds * 1000 }); 8 | } 9 | 10 | export function shortDuration(duration: Duration): string { 11 | const hours = String(duration.hours ?? 0).padStart(2, '0'); 12 | const minutes = String(duration.minutes ?? 0).padStart(2, '0'); 13 | 14 | return `${hours}:${minutes}`; 15 | } 16 | 17 | export function formatSecondsToHumanReadable(seconds: number, hideSeconds = true): string { 18 | const duration = intervalToDuration({ start: 0, end: seconds * 1000 }); 19 | 20 | if (!hideSeconds) { 21 | return formatDuration(duration); 22 | } 23 | 24 | if (!duration.minutes && !duration.hours && !duration.seconds) { 25 | return 'N/A'; 26 | } 27 | 28 | if (!duration.minutes && !duration.hours && duration.seconds && duration.seconds > 0) { 29 | return 'Less than a minute'; 30 | } 31 | 32 | return formatDuration(duration, { format: ['months', 'days', 'hours', 'minutes'] }); 33 | } 34 | 35 | export function formatRelativeDate(date: number): string { 36 | return formatDistanceToNow(new Date(date), { addSuffix: true }); 37 | } 38 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | BUMP_TYPE=${1:-patch} 6 | if [[ ! "$BUMP_TYPE" =~ ^(patch|minor|major|prerelease)$ ]]; then 7 | echo "Invalid version bump type: $BUMP_TYPE" 8 | echo "Valid types: patch, minor, major, prerelease" 9 | exit 1 10 | fi 11 | 12 | # === Config === 13 | GHCR_USER="georgesg" 14 | REPO_NAME="koinsight" 15 | PACKAGE_DIRS=("apps/server" "apps/web" "packages/common") 16 | IMAGE_NAME="ghcr.io/$GHCR_USER/$REPO_NAME" 17 | 18 | # === Bump version using changesets or npm version === 19 | VERSION=$(npm version "$BUMP_TYPE" --no-git-tag-version) 20 | 21 | # === Apply version to each package.json === 22 | for DIR in "${PACKAGE_DIRS[@]}"; do 23 | jq --arg v "$VERSION" '.version = $v' "$DIR/package.json" > "$DIR/package.tmp.json" && mv "$DIR/package.tmp.json" "$DIR/package.json" 24 | done 25 | 26 | # === Commit version bump === 27 | git add . 28 | git commit -m "chore: release $VERSION" 29 | 30 | # === Tag and push === 31 | git tag "$VERSION" 32 | git push origin master 33 | git push origin "$VERSION" 34 | 35 | # === Build Docker image === 36 | docker buildx use koinsight-builder 37 | docker buildx inspect --bootstrap 38 | 39 | # === Build multi-arch image and push it 40 | docker buildx build \ 41 | --platform linux/amd64,linux/arm64 \ 42 | -t "$IMAGE_NAME:$VERSION" \ 43 | -t "$IMAGE_NAME:latest" \ 44 | --push \ 45 | . 46 | 47 | 48 | echo "✅ Released version $VERSION" 49 | -------------------------------------------------------------------------------- /apps/web/src/pages/book-page/book-page-calendar.tsx: -------------------------------------------------------------------------------- 1 | import { BookWithData, PageStat } from '@koinsight/common/types'; 2 | import { IconClock } from '@tabler/icons-react'; 3 | import { startOfDay } from 'date-fns/startOfDay'; 4 | import { sum } from 'ramda'; 5 | import { JSX } from 'react'; 6 | import { Calendar, CalendarEvent } from '../../components/calendar/calendar'; 7 | import { getDuration, shortDuration } from '../../utils/dates'; 8 | 9 | type BookPageCalendarProps = { 10 | book: BookWithData; 11 | }; 12 | 13 | type DayData = { 14 | events: PageStat[]; 15 | }; 16 | 17 | export function BookPageCalendar({ book }: BookPageCalendarProps): JSX.Element { 18 | const calendarEvents = book.stats.reduce>>((acc, event) => { 19 | const date = startOfDay(event.start_time); 20 | const key = date.toISOString(); 21 | acc[key] = acc[key] || { date, data: { events: [] } }; 22 | acc[key].data = acc[key]?.data?.events 23 | ? { events: [...acc[key].data.events, event] } 24 | : { events: [event] }; 25 | 26 | return acc; 27 | }, {}); 28 | 29 | return ( 30 | 31 | events={calendarEvents} 32 | dayRenderer={(data) => ( 33 | <> 34 | {' '} 35 | {shortDuration(getDuration(sum(data.events.map((event) => event.duration))))} 36 | 37 | )} 38 | /> 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /apps/web/src/components/statistics/reading-calendar.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Title } from '@mantine/core'; 2 | import { formatDate, startOfDay } from 'date-fns'; 3 | import { JSX, useMemo } from 'react'; 4 | import { usePageStats } from '../../api/use-page-stats'; 5 | import { formatSecondsToHumanReadable } from '../../utils/dates'; 6 | import { DayData, DotTrail } from '../dot-trail/dot-trail'; 7 | 8 | export function ReadingCalendar(): JSX.Element { 9 | const { 10 | data: { stats }, 11 | } = usePageStats(); 12 | 13 | const percentPerDay: Record = useMemo(() => { 14 | const timePerDay = stats.reduce>((acc, stat) => { 15 | const day = startOfDay(stat.start_time).getTime(); 16 | acc[day] = (acc[day] || 0) + stat.duration; 17 | return acc; 18 | }, {}); 19 | 20 | const maxTime = Math.max(...Object.values(timePerDay ?? {})); 21 | 22 | return Object.entries(timePerDay ?? {}).reduce>((acc, [day, time]) => { 23 | acc[Number(day)] = { 24 | percent: Math.floor((time / maxTime) * 100), 25 | tooltip: ( 26 | <> 27 | {formatSecondsToHumanReadable(time)} read on{' '} 28 | {formatDate(new Date(Number(day)), 'dd MMM yyyy')} 29 | 30 | ), 31 | }; 32 | return acc; 33 | }, {}); 34 | }, [stats]); 35 | 36 | return ( 37 | 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /apps/server/src/stats/stats-router.ts: -------------------------------------------------------------------------------- 1 | import { GetAllStatsResponse } from '@koinsight/common/types'; 2 | import { Request, Response, Router } from 'express'; 3 | import { BooksRepository } from '../books/books-repository'; 4 | import { StatsRepository } from './stats-repository'; 5 | import { StatsService } from './stats-service'; 6 | 7 | const router = Router(); 8 | 9 | /** 10 | * Get all stats 11 | */ 12 | router.get('/', async (_: Request, res: Response) => { 13 | const books = await BooksRepository.getAllWithData(); 14 | const totalPagesRead = StatsService.totalPagesRead(books); 15 | 16 | const stats = await StatsRepository.getAll(); 17 | const perMonth = StatsService.getPerMonthReadingTime(stats); 18 | const perDayOfTheWeek = StatsService.perDayOfTheWeek(stats); 19 | const mostPagesInADay = StatsService.mostPagesInADay(books, stats); 20 | const totalReadingTime = StatsService.totalReadingTime(stats); 21 | const longestDay = StatsService.longestDay(stats); 22 | const last7DaysReadTime = StatsService.last7DaysReadTime(stats); 23 | 24 | const response: GetAllStatsResponse = { 25 | stats, 26 | perMonth, 27 | perDayOfTheWeek, 28 | mostPagesInADay, 29 | totalReadingTime, 30 | longestDay, 31 | last7DaysReadTime, 32 | totalPagesRead, 33 | }; 34 | 35 | res.json(response); 36 | }); 37 | 38 | /** 39 | * Get stats by book md5 40 | */ 41 | router.get('/:book_md5', async (req: Request, res: Response) => { 42 | const book = await StatsRepository.getByBookMD5(req.params.book_md5); 43 | res.json(book); 44 | }); 45 | 46 | export { router as statsRouter }; 47 | -------------------------------------------------------------------------------- /apps/web/src/components/navbar/download-plugin.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Modal, ModalProps, Stack, Text, Title } from '@mantine/core'; 2 | import { IconDownload } from '@tabler/icons-react'; 3 | import { JSX } from 'react'; 4 | 5 | export type DownloadPluginModalProps = Pick; 6 | 7 | export function DownloadPluginModal({ opened, onClose }: DownloadPluginModalProps): JSX.Element { 8 | return ( 9 | 25 | 26 | 27 | 28 | 29 | 30 | Download a zip bundle of the KoInsight plugin for KOReader that allows you to sync 31 | your reading statistics with a KoInsight instance. 32 | 33 | 34 | To install the plugin, extract the contents of the zip file into the KOReader plugins 35 | directory on your device. 36 | 37 | 38 | 39 | 40 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /apps/server/src/open-library/open-library-service.ts: -------------------------------------------------------------------------------- 1 | import { uniq } from 'ramda'; 2 | import { OpenLibrarySearchResult } from './open-library-types'; 3 | 4 | const OPEN_LIBRARY_API = 'https://openlibrary.org'; 5 | const OPEN_LIBRARY_COVERS_API = 'https://covers.openlibrary.org'; 6 | 7 | export class OpenLibraryService { 8 | static async fetchCover(coverId: string, size: 'S' | 'M' | 'L' = 'M') { 9 | const url = `${OPEN_LIBRARY_COVERS_API}/b/id/${coverId}-${size}.jpg`; 10 | return fetch(url).then((response) => response.arrayBuffer()); 11 | } 12 | 13 | static async queryCovers(searchTerm: string, limit: number = 3) { 14 | const response = await this.searchBooks(searchTerm, limit); 15 | const docs = response.docs; 16 | 17 | const coverIds = docs.flatMap((doc) => doc.cover_i); 18 | const keys = docs.flatMap((doc) => doc.key); 19 | 20 | const newCoverIds = (await Promise.all(keys.map((k) => this.queryCoverForKey(k)))).flat(); 21 | 22 | return uniq([...coverIds, ...newCoverIds].filter(Boolean)); 23 | } 24 | 25 | private static async searchBooks( 26 | searchTerm: string, 27 | limit = 3, 28 | fields = 'key,cover_i', 29 | lang = 'eng' 30 | ): Promise { 31 | const params = new URLSearchParams({ 32 | q: searchTerm, 33 | limit: limit.toString(), 34 | lang, 35 | fields, 36 | }); 37 | 38 | return fetch(`${OPEN_LIBRARY_API}/search.json?${params}`).then((response) => response.json()); 39 | } 40 | 41 | private static queryCoverForKey(key: string) { 42 | return fetch(`${OPEN_LIBRARY_API}${key}/editions.json`) 43 | .then((r) => r.json()) 44 | .then((r) => r.entries.flatMap((entry: { covers: string[] }) => entry.covers)); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /apps/server/src/stats/stats-router.test.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import request from 'supertest'; 3 | import { createDevice } from '../db/factories/device-factory'; 4 | import { db } from '../knex'; 5 | import { statsRouter } from './stats-router'; 6 | import { createBook } from '../db/factories/book-factory'; 7 | import { createBookDevice } from '../db/factories/book-device-factory'; 8 | import { createPageStat } from '../db/factories/page-stat-factory'; 9 | import { Book, BookDevice, Device } from '@koinsight/common/types'; 10 | 11 | describe('GET /stats', () => { 12 | const app = express(); 13 | app.use(express.json()); 14 | app.use('/stats', statsRouter); 15 | 16 | let device: Device; 17 | let book: Book; 18 | let bookDevice: BookDevice; 19 | 20 | beforeEach(async () => { 21 | device = await createDevice(db, { model: 'Device 1' }); 22 | book = await createBook(db, { reference_pages: 100 }); 23 | bookDevice = await createBookDevice(db, book, device, { pages: 100 }); 24 | }); 25 | 26 | it('returns all stats', async () => { 27 | await createPageStat(db, book, bookDevice, device, { duration: 10, page: 1 }); 28 | await createPageStat(db, book, bookDevice, device, { duration: 20, page: 2 }); 29 | await createPageStat(db, book, bookDevice, device, { duration: 10, page: 3 }); 30 | await createPageStat(db, book, bookDevice, device, { duration: 20, page: 4 }); 31 | 32 | const response = await request(app).get('/stats'); 33 | const body = response.body; 34 | 35 | expect(response.status).toBe(200); 36 | 37 | // TODO: Do we need a more detailed test here provided everything is from the StatsService? 38 | expect(body).toHaveProperty('perMonth'); 39 | expect(body.longestDay).toBe(20); 40 | expect(body.totalPagesRead).toBe(4); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /apps/server/src/db/migrations/20250412161907_use_book_md5_as_foreign_key.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | // Change md5 to VARCHAR(32) and ensure it is not nullable and unique 5 | // Covert other text fields in book to VARCHAR 6 | await knex.schema.alterTable('book', (table) => { 7 | table.string('md5', 32).notNullable().unique().alter(); 8 | table.string('title').alter(); 9 | table.string('authors').alter(); 10 | table.string('series').alter(); 11 | table.string('language').alter(); 12 | }); 13 | 14 | // Add device_id to page_stat 15 | await knex.schema.alterTable('page_stat', (table) => { 16 | table.string('device_id').references('device.id').onDelete('CASCADE'); 17 | }); 18 | 19 | // Add new book_md5 columns to referencing tables 20 | await knex.schema.alterTable('page_stat', (table) => { 21 | table.string('book_md5', 32); 22 | table.dropForeign('book_id'); 23 | 24 | table.dropUnique(['book_id', 'page', 'start_time'], 'page_stat_book_id_page_start_time_unique'); 25 | table.unique(['book_md5', 'device_id', 'page', 'start_time']); 26 | 27 | table.dropColumn('book_id'); 28 | table.foreign('book_md5').references('book.md5').onDelete('CASCADE'); 29 | }); 30 | 31 | await knex.schema.alterTable('book_genre', (table) => { 32 | table.string('book_md5', 32); 33 | table.dropForeign('book_id'); 34 | 35 | table.dropUnique(['book_id', 'genre_id'], 'book_genre_book_id_genre_id_unique'); 36 | table.unique(['book_md5', 'genre_id']); 37 | 38 | table.dropColumn('book_id'); 39 | table.foreign('book_md5').references('book.md5').onDelete('CASCADE'); 40 | }); 41 | } 42 | 43 | export async function down(knex: Knex): Promise { 44 | throw new Error('Down migration impossible'); 45 | } 46 | -------------------------------------------------------------------------------- /apps/server/src/stats/stats-repository.test.ts: -------------------------------------------------------------------------------- 1 | import { Book, Device } from '@koinsight/common/types'; 2 | import { createBookDevice } from '../db/factories/book-device-factory'; 3 | import { createBook } from '../db/factories/book-factory'; 4 | import { createDevice } from '../db/factories/device-factory'; 5 | import { createPageStat } from '../db/factories/page-stat-factory'; 6 | import { db } from '../knex'; 7 | import { StatsRepository } from './stats-repository'; 8 | 9 | describe(StatsRepository, () => { 10 | describe(StatsRepository.getAll, () => { 11 | let device1: Device; 12 | let book1: Book; 13 | 14 | beforeEach(async () => { 15 | device1 = await createDevice(db); 16 | book1 = await createBook(db, { title: 'Book 1', soft_deleted: false }); 17 | }); 18 | 19 | it('does not include soft deleted books', async () => { 20 | const bookDevice1 = await createBookDevice(db, book1, device1); 21 | 22 | await createPageStat(db, book1, bookDevice1, device1, { page: 1, duration: 20 }); 23 | await createPageStat(db, book1, bookDevice1, device1, { page: 2, duration: 20 }); 24 | await createPageStat(db, book1, bookDevice1, device1, { page: 3, duration: 20 }); 25 | 26 | const book2 = await createBook(db, { title: 'Book 2', soft_deleted: true }); 27 | const bookDevice2 = await createBookDevice(db, book2, device1); 28 | await createPageStat(db, book2, bookDevice2, device1, { page: 1, duration: 10 }); 29 | await createPageStat(db, book2, bookDevice2, device1, { page: 2, duration: 10 }); 30 | await createPageStat(db, book2, bookDevice2, device1, { page: 3, duration: 10 }); 31 | 32 | const stats = await StatsRepository.getAll(); 33 | expect(stats).toHaveLength(3); 34 | expect(stats.map((s) => s.book_md5)).not.includes(book2.md5); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /apps/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "v0.1.4", 4 | "description": "KoInsight back-end server", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "node dist/app.js", 8 | "build": "tsc -b", 9 | "build:migrations": "tsc -p tsconfig.migrations.json", 10 | "dev": "NODE_ENV=development nodemon src/app.ts", 11 | "knex": "DATA_PATH=../../../data knex --knexfile ./src/knexfile.ts", 12 | "seed": "npm run knex seed:run --env=development", 13 | "test": "npm run build:migrations && vitest run", 14 | "test:watch": "npm run build:migrations && vitest", 15 | "test:coverage": "npm run build:migrations && vitest run --coverage" 16 | }, 17 | "dependencies": { 18 | "@koinsight/common": "*", 19 | "@types/bcrypt": "^5.0.2", 20 | "archiver": "^7.0.1", 21 | "bcryptjs": "^3.0.2", 22 | "better-sqlite3": "11.7.0", 23 | "cors": "2.8.5", 24 | "date-fns": "^4.1.0", 25 | "dotenv": "17.2.3", 26 | "express": "4.21.2", 27 | "knex": "^3.1.0", 28 | "morgan": "^1.10.0", 29 | "multer": "1.4.5-lts.1", 30 | "openai": "6.5.0", 31 | "ramda": "0.31.1", 32 | "sqlite3": "^5.1.7", 33 | "zod": "4.1.12" 34 | }, 35 | "devDependencies": { 36 | "@faker-js/faker": "10.1.0", 37 | "@types/archiver": "^6.0.3", 38 | "@types/better-sqlite3": "7.6.13", 39 | "@types/cors": "2.8.19", 40 | "@types/express": "5.0.0", 41 | "@types/knex": "^0.16.1", 42 | "@types/morgan": "1.9.10", 43 | "@types/multer": "1.4.12", 44 | "@types/node": "22.12.0", 45 | "@types/ramda": "0.31.1", 46 | "@types/supertest": "^6.0.3", 47 | "@vitest/coverage-v8": "3.2.4", 48 | "@vitest/ui": "3.2.4", 49 | "nodemon": "3.1.10", 50 | "supertest": "7.1.4", 51 | "ts-node": "^10.9.2", 52 | "tsx": "4.20.6", 53 | "typescript": "5.9.3", 54 | "vitest": "3.2.4" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /apps/server/src/open-library/open-library-router.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response, Router } from 'express'; 2 | import { existsSync, mkdirSync, writeFileSync } from 'fs'; 3 | import { BooksRepository } from '../books/books-repository'; 4 | import { CoversService } from '../books/covers/covers-service'; 5 | import { appConfig } from '../config'; 6 | import { OpenLibraryService } from './open-library-service'; 7 | 8 | const router = Router(); 9 | 10 | router.get('/list-covers', async (req: Request, res: Response, next: NextFunction) => { 11 | const { searchTerm, limit } = req.query; 12 | 13 | OpenLibraryService.queryCovers(searchTerm as string, Number(limit)) 14 | .then((covers) => { 15 | res.send(covers); 16 | }) 17 | .catch((error) => { 18 | res.status(500).send('Error fetching covers'); 19 | next(error); 20 | }); 21 | }); 22 | 23 | // TODO: change method? 24 | /** 25 | * Fetches a book cover from Open Library API and saves it to the server 26 | */ 27 | router.get('/cover', async (req: Request, res: Response, next: NextFunction) => { 28 | const { coverId, bookId, size = 'M' } = req.query; 29 | 30 | if (!bookId || !coverId) { 31 | res.status(400).send('Invalid request'); 32 | return next(); 33 | } 34 | 35 | const book = await BooksRepository.getById(Number(bookId)); 36 | if (!book) { 37 | res.status(404).send('Book not found'); 38 | return next(); 39 | } 40 | 41 | if (!existsSync(appConfig.coversPath)) { 42 | mkdirSync(appConfig.coversPath); 43 | } 44 | 45 | try { 46 | CoversService.deleteExisting(book); 47 | const cover = await OpenLibraryService.fetchCover(coverId as string, size as 'S' | 'M' | 'L'); 48 | writeFileSync(`${appConfig.coversPath}/${book.md5}.jpg`, Buffer.from(cover)); 49 | res.send({ status: 'Cover updated' }); 50 | } catch { 51 | res.status(404).send('Cover not found'); 52 | } 53 | }); 54 | 55 | export { router as openLibraryRouter }; 56 | -------------------------------------------------------------------------------- /apps/web/src/components/navbar/navbar.module.css: -------------------------------------------------------------------------------- 1 | .Navbar { 2 | display: flex; 3 | flex-direction: column; 4 | flex-shrink: 0; 5 | width: 280px; 6 | height: 100%; 7 | padding-inline: var(--mantine-spacing-md); 8 | } 9 | 10 | .Logo { 11 | padding-top: var(--mantine-spacing-sm); 12 | margin-bottom: var(--mantine-spacing-xl); 13 | padding-left: var(--mantine-spacing-sm); 14 | } 15 | 16 | .Link { 17 | display: flex; 18 | align-items: center; 19 | text-decoration: none; 20 | font-size: var(--mantine-font-size-sm); 21 | color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); 22 | padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); 23 | border-radius: var(--mantine-radius-sm); 24 | font-weight: 500; 25 | transition: all 0.2s ease; 26 | cursor: pointer; 27 | 28 | &:hover { 29 | background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); 30 | color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); 31 | 32 | & .LinkIcon { 33 | color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); 34 | } 35 | } 36 | 37 | &[data-active] { 38 | &, 39 | &:hover { 40 | background-color: var(--mantine-color-violet-light); 41 | color: var(--mantine-color-violet-text); 42 | 43 | & .LinkIcon { 44 | color: var(--mantine-color-violet-text); 45 | } 46 | } 47 | } 48 | } 49 | 50 | .LinkIcon { 51 | color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); 52 | margin-right: var(--mantine-spacing-sm); 53 | width: 25px; 54 | height: 25px; 55 | transition: all 0.2s ease; 56 | } 57 | 58 | .Footer { 59 | display: flex; 60 | flex-direction: column; 61 | gap: var(--mantine-spacing-sm); 62 | align-items: flex-start; 63 | justify-content: space-between; 64 | margin-top: auto; 65 | padding-top: var(--mantine-spacing-md); 66 | border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); 67 | } 68 | -------------------------------------------------------------------------------- /apps/server/src/utils/ranges.test.ts: -------------------------------------------------------------------------------- 1 | import { normalizeRanges, Range, totalRangeLength } from './ranges'; 2 | 3 | describe(normalizeRanges, () => { 4 | it('normalizes overlapping ranges', () => { 5 | const ranges: Range[] = [ 6 | [1, 5], 7 | [2, 6], 8 | [7, 10], 9 | [8, 12], 10 | [15, 20], 11 | ]; 12 | 13 | expect(normalizeRanges(ranges)).toEqual([ 14 | [1, 6], 15 | [7, 12], 16 | [15, 20], 17 | ]); 18 | }); 19 | 20 | it('handles single range', () => { 21 | const ranges: Range[] = [[1, 5]]; 22 | expect(normalizeRanges(ranges)).toEqual([[1, 5]]); 23 | }); 24 | 25 | it('handles non-overlapping ranges', () => { 26 | const ranges: Range[] = [ 27 | [1, 2], 28 | [3, 4], 29 | [5, 6], 30 | ]; 31 | 32 | expect(normalizeRanges(ranges)).toEqual([ 33 | [1, 2], 34 | [3, 4], 35 | [5, 6], 36 | ]); 37 | }); 38 | 39 | it('handles touching ranges', () => { 40 | const ranges: Range[] = [ 41 | [1, 3], 42 | [3, 5], 43 | [6, 8], 44 | [8, 10], 45 | ]; 46 | 47 | expect(normalizeRanges(ranges)).toEqual([ 48 | [1, 5], 49 | [6, 10], 50 | ]); 51 | }); 52 | 53 | it('handles point ranges', () => { 54 | const ranges: Range[] = [ 55 | [1, 1], 56 | [2, 2], 57 | [3, 3], 58 | ]; 59 | 60 | expect(normalizeRanges(ranges)).toEqual([ 61 | [1, 1], 62 | [2, 2], 63 | [3, 3], 64 | ]); 65 | }); 66 | 67 | it('handles empty input', () => { 68 | const ranges: Range[] = []; 69 | expect(normalizeRanges(ranges)).toEqual([]); 70 | }); 71 | }); 72 | 73 | describe(totalRangeLength, () => { 74 | it('totals the length of ranges', () => { 75 | const ranges: Range[] = [ 76 | [1, 5], 77 | [6, 10], 78 | [16, 20], 79 | ]; 80 | 81 | expect(totalRangeLength(ranges)).toBe(12); 82 | }); 83 | 84 | it('works with no ranges', () => { 85 | const ranges: Range[] = []; 86 | expect(totalRangeLength(ranges)).toBe(0); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /apps/web/src/pages/book-page/book-page-manage/book-hide.tsx: -------------------------------------------------------------------------------- 1 | import { Book } from '@koinsight/common/types'; 2 | import { Switch, Text, Title } from '@mantine/core'; 3 | import { notifications } from '@mantine/notifications'; 4 | import { useState } from 'react'; 5 | import { useNavigate } from 'react-router'; 6 | import { mutate } from 'swr'; 7 | import { hideBook, showBook } from '../../../api/books'; 8 | import { RoutePath } from '../../../routes'; 9 | 10 | export type BookHideProps = { 11 | book: Book; 12 | }; 13 | 14 | export function BookHide({ book }: BookHideProps) { 15 | const navigate = useNavigate(); 16 | 17 | const [hideLoading, setHideLoading] = useState(false); 18 | 19 | const onUpdate = async (hidden: boolean) => { 20 | try { 21 | setHideLoading(true); 22 | 23 | if (hidden) { 24 | await hideBook(book.id); 25 | } else { 26 | await showBook(book.id); 27 | } 28 | 29 | await mutate('books'); 30 | await mutate(`books/${book.id}`); 31 | 32 | if (hidden) { 33 | navigate(RoutePath.HOME); 34 | } 35 | 36 | notifications.show({ 37 | title: `Book ${hidden ? 'hidden' : 'shown'}`, 38 | message: `${book ? `"${book?.title}"` : 'Book'} ${hidden ? 'hidden' : 'shown'} successfully.`, 39 | color: 'green', 40 | position: 'top-center', 41 | }); 42 | } catch (error) { 43 | notifications.show({ 44 | title: `Failed to ${hidden ? 'hide' : 'show'} the book`, 45 | message: `Failed to ${hidden ? 'hide' : 'show'} the book.`, 46 | color: 'red', 47 | position: 'top-center', 48 | }); 49 | } finally { 50 | setHideLoading(false); 51 | } 52 | }; 53 | 54 | return ( 55 |
56 | 57 | Hide book 58 | 59 | 60 | Hidden books are not shown in the book list and are excluded from statistics. 61 | 62 | onUpdate(e.target.checked)} 67 | > 68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /apps/web/src/pages/book-page/book-page-manage/book-upload-cover.tsx: -------------------------------------------------------------------------------- 1 | import { Book } from '@koinsight/common/types'; 2 | import { Button, FileInput, Flex, Title } from '@mantine/core'; 3 | import { notifications } from '@mantine/notifications'; 4 | import { FormEvent, useState } from 'react'; 5 | import { mutate } from 'swr'; 6 | import { uploadBookCover } from '../../../api/books'; 7 | 8 | export type BookUploadCoverProps = { 9 | book: Book; 10 | }; 11 | 12 | export function BookUploadCover({ book }: BookUploadCoverProps) { 13 | const [file, setFile] = useState(null); 14 | const [message, setMessage] = useState(''); 15 | 16 | const onSuccess = async () => { 17 | // FIXME: this doesn't seem to work. 18 | await mutate('books'); 19 | await mutate(`books/${book.id}`); 20 | notifications.show({ 21 | title: 'Success', 22 | message: 'File uploaded and validated successfully.', 23 | position: 'top-center', 24 | color: 'green', 25 | }); 26 | setMessage('Cover updated'); 27 | close(); 28 | }; 29 | 30 | const handleUpload = async (event: FormEvent) => { 31 | event.preventDefault(); 32 | 33 | if (!file) { 34 | return; 35 | } 36 | 37 | try { 38 | const formData = new FormData(); 39 | formData.append('file', file); 40 | const response = await uploadBookCover(book.id, formData); 41 | 42 | if (response.ok) { 43 | await onSuccess(); 44 | } else { 45 | setMessage('Failed to upload file.'); 46 | } 47 | } catch (error) { 48 | setMessage(`Error: ${error}`); 49 | } 50 | }; 51 | 52 | return ( 53 |
54 | 55 | Upload cover 56 | 57 |
58 | 59 | setFile(e)} 64 | accept=".png,.jpg,.jpeg,.gif" 65 | /> 66 | 69 | 70 | {message &&

{message}

} 71 |
72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /apps/server/src/db/seeds/04_page_stats.ts: -------------------------------------------------------------------------------- 1 | import { Book } from '@koinsight/common/types/book'; 2 | import { PageStat } from '@koinsight/common/types/page-stat'; 3 | import { subDays, subMinutes } from 'date-fns'; 4 | import { Knex } from 'knex'; 5 | import { SEEDED_DEVICES } from './01_devices'; 6 | import { SEEDED_BOOKS } from './02_books'; 7 | import { createPageStat, fakePageStat } from '../factories/page-stat-factory'; 8 | import { SEEDED_BOOK_DEVICES } from './03_book_devices'; 9 | import { db } from '../../knex'; 10 | 11 | function generateBookStats(book: Book): PageStat[] { 12 | const pageStats: PageStat[] = []; 13 | const today = new Date(); 14 | 15 | const startDate = subDays(today, Math.floor(Math.random() * 100)); 16 | 17 | const maxPages = Math.max(book.reference_pages ?? 0, 300); 18 | for (let i = 0; i < maxPages; i++) { 19 | pageStats.push({ 20 | page: i, 21 | start_time: subMinutes(startDate, (maxPages - i) * 30).valueOf() / 1000, 22 | duration: Math.floor(Math.random() * 100), 23 | total_pages: Math.floor(Math.random() * maxPages), 24 | device_id: SEEDED_DEVICES[Math.floor(Math.random() * SEEDED_DEVICES.length)].id, 25 | book_md5: book.md5, 26 | }); 27 | } 28 | 29 | return pageStats; 30 | } 31 | 32 | export async function seed(knex: Knex): Promise { 33 | await knex('page_stat').del(); 34 | 35 | let promises: Promise[] = []; 36 | 37 | SEEDED_BOOKS.forEach((book) => { 38 | const bookDevice = SEEDED_BOOK_DEVICES.find((bd) => bd.book_md5 === book.md5); 39 | const device = SEEDED_DEVICES.find((d) => d.id === bookDevice?.device_id); 40 | 41 | if (!bookDevice || !device) { 42 | return; 43 | } 44 | 45 | const today = new Date(); 46 | const startDate = subDays(today, Math.floor(Math.random() * 100)); 47 | const readPages = Math.floor(Math.random() * bookDevice.pages); 48 | 49 | for (let i = 0; i < readPages; i++) { 50 | promises.push( 51 | createPageStat(db, book, bookDevice, device, { 52 | page: i, 53 | start_time: subMinutes(startDate, (readPages - i) * 30).valueOf() / 1000, 54 | duration: Math.floor(Math.random() * 100), 55 | total_pages: bookDevice.pages, 56 | }) 57 | ); 58 | } 59 | }); 60 | 61 | await Promise.all(promises); 62 | } 63 | -------------------------------------------------------------------------------- /plugins/koinsight.koplugin/db_reader.lua: -------------------------------------------------------------------------------- 1 | local SQ3 = require("lua-ljsqlite3/init") 2 | local DataStorage = require("datastorage") 3 | local logger = require("logger") 4 | 5 | local db_location = DataStorage:getSettingsDir() .. "/statistics.sqlite3" 6 | 7 | local KoInsightDbReader = {} 8 | 9 | function KoInsightDbReader.bookData() 10 | local conn = SQ3.open(db_location) 11 | local result, rows = conn:exec("SELECT * FROM book") 12 | local books = {} 13 | 14 | for i = 1, rows do 15 | local book = { 16 | id = tonumber(result[1][i]), 17 | title = result[2][i], 18 | authors = result[3][i], 19 | notes = tonumber(result[4][i]), 20 | last_open = tonumber(result[5][i]), 21 | highlights = tonumber(result[6][i]), 22 | pages = tonumber(result[7][i]), 23 | series = result[8][i], 24 | language = result[9][i], 25 | md5 = result[10][i], 26 | total_read_time = tonumber(result[11][i]), 27 | total_read_pages = tonumber(result[12][i]), 28 | } 29 | table.insert(books, book) 30 | end 31 | 32 | conn:close() 33 | return books 34 | end 35 | 36 | function get_md5_by_id(books, target_id) 37 | for _, book in ipairs(books) do 38 | if book.id == target_id then 39 | return book.md5 40 | end 41 | end 42 | return nil 43 | end 44 | 45 | function KoInsightDbReader.progressData() 46 | local conn = SQ3.open(db_location) 47 | local result, rows = conn:exec("SELECT * FROM page_stat_data") 48 | local results = {} 49 | 50 | local book_data = KoInsightDbReader.bookData() 51 | 52 | local device_id = G_reader_settings:readSetting("device_id") 53 | 54 | for i = 1, rows do 55 | local book_id = tonumber(result[1][i]) 56 | local book_md5 = get_md5_by_id(book_data, book_id) 57 | 58 | if book_md5 == nil then 59 | logger.warn("[KoInsight] Book MD5 not found in book data:" .. book_id) 60 | goto continue 61 | end 62 | 63 | table.insert(results, { 64 | page = tonumber(result[2][i]), 65 | start_time = tonumber(result[3][i]), 66 | duration = tonumber(result[4][i]), 67 | total_pages = tonumber(result[5][i]), 68 | book_md5 = book_md5, 69 | device_id = device_id, 70 | }) 71 | 72 | ::continue:: 73 | end 74 | 75 | conn:close() 76 | return results 77 | end 78 | 79 | return KoInsightDbReader 80 | -------------------------------------------------------------------------------- /apps/server/src/upload/upload-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { unlinkSync } from 'fs'; 3 | import multer from 'multer'; 4 | import { appConfig } from '../config'; 5 | import { UploadService } from './upload-service'; 6 | 7 | const storage = multer.diskStorage({ 8 | destination: (_req, _res, cb) => { 9 | cb(null, appConfig.dataPath); 10 | }, 11 | filename: (_req, _res, cb) => { 12 | cb(null, appConfig.upload.filename); 13 | }, 14 | }); 15 | 16 | const upload = multer({ 17 | storage, 18 | fileFilter: (_req, file, cb) => { 19 | if (file.mimetype === 'application/octet-stream' || file.originalname.endsWith('.sqlite3')) { 20 | cb(null, true); // Accept the file 21 | } else { 22 | cb(new Error('Only .sqlite3 files are allowed')); 23 | } 24 | }, 25 | limits: { fileSize: appConfig.upload.maxFileSizeMegaBytes * 1024 * 1024 }, 26 | }); 27 | 28 | const router = Router(); 29 | 30 | router.post('/', upload.single('file'), async (req, res, next) => { 31 | const uploadedFilePath = req.file?.path; 32 | 33 | if (!uploadedFilePath) { 34 | res.status(400).json({ error: 'No file uploaded' }); 35 | next(); 36 | return; 37 | } 38 | 39 | let db; 40 | try { 41 | db = UploadService.openStatisticsDbFile(uploadedFilePath); 42 | } catch (err) { 43 | console.error(err); 44 | res.status(400).json({ error: 'Invalid SQLite file or no books found' }); 45 | return; 46 | } 47 | 48 | try { 49 | const { newBooks, newPageStats } = UploadService.extractDataFromStatisticsDb(db); 50 | await UploadService.uploadStatisticData(newBooks, newPageStats); 51 | 52 | res.json({ message: 'Database imported successfully' }); 53 | } catch (err) { 54 | console.error(err); 55 | res.status(500).json({ error: 'Failed to import database' }); 56 | } finally { 57 | db.close(); 58 | unlinkSync(uploadedFilePath); 59 | } 60 | }); 61 | 62 | router.use((err: any, req: any, res: any, next: any) => { 63 | if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') { 64 | const maxMb = Math.round(appConfig.upload.maxFileSizeMegaBytes); 65 | return res 66 | .status(413) 67 | .json({ error: `File too large. Maximum file size allowed is ${maxMb} MB.` }); 68 | } 69 | return next(err); 70 | }); 71 | 72 | export { router as uploadRouter }; 73 | -------------------------------------------------------------------------------- /apps/web/src/pages/book-page/book-page-manage/book-delete.tsx: -------------------------------------------------------------------------------- 1 | import { Book } from '@koinsight/common/types'; 2 | import { Button, Text, Title } from '@mantine/core'; 3 | import { modals } from '@mantine/modals'; 4 | import { notifications } from '@mantine/notifications'; 5 | import { IconTrash } from '@tabler/icons-react'; 6 | import { useState } from 'react'; 7 | import { useNavigate } from 'react-router'; 8 | import { mutate } from 'swr'; 9 | import { deleteBook } from '../../../api/books'; 10 | import { RoutePath } from '../../../routes'; 11 | 12 | export type BookDeleteProps = { 13 | book: Book; 14 | }; 15 | 16 | export function BookDelete({ book }: BookDeleteProps) { 17 | const navigate = useNavigate(); 18 | 19 | const [deleteLoading, setDeleteLoading] = useState(false); 20 | 21 | const openDeleteConfirm = () => 22 | modals.openConfirmModal({ 23 | title: 'Delete Book?', 24 | centered: true, 25 | children: ( 26 | 27 | Are you sure you want to delete {book ? `"${book?.title}"` : 'this book'} 28 | ? This action is destructive and cannot be reverted. 29 | 30 | ), 31 | labels: { confirm: 'Delete', cancel: "No, don't delete it" }, 32 | confirmProps: { color: 'red' }, 33 | onConfirm: onDelete, 34 | }); 35 | 36 | const onDelete = async () => { 37 | try { 38 | setDeleteLoading(true); 39 | await deleteBook(book.id); 40 | await mutate('books'); 41 | navigate(RoutePath.HOME); 42 | notifications.show({ 43 | title: 'Book deleted', 44 | message: `${book ? `"${book?.title}"` : 'Book'} deleted successfully.`, 45 | color: 'green', 46 | position: 'top-center', 47 | }); 48 | } catch (error) { 49 | notifications.show({ 50 | title: 'Failed to delete the book', 51 | message: 'Failed to delete the book.', 52 | color: 'red', 53 | position: 'top-center', 54 | }); 55 | } 56 | }; 57 | 58 | return ( 59 |
60 | 61 | Delete book 62 | 63 | 71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /plugins/koinsight.koplugin/call_api.lua: -------------------------------------------------------------------------------- 1 | local socketutil = require("socketutil") 2 | local ltn12 = require("ltn12") 3 | local logger = require("logger") 4 | local socket = require("socket") 5 | local http = require("socket.http") 6 | local UIManager = require("ui/uimanager") 7 | local JSON = require("json") 8 | local InfoMessage = require("ui/widget/infomessage") 9 | local _ = require("gettext") 10 | 11 | function response_not_valid(content) 12 | logger.err("[KoInsight] callApi: response was not valid JSON", content) 13 | UIManager:show(InfoMessage:new({ 14 | text = _("Server response is not valid."), 15 | })) 16 | end 17 | 18 | return function(method, url, headers, body, filepath, quiet) 19 | quiet = quiet or false 20 | 21 | local sink = {} 22 | local request = { 23 | method = method, 24 | } 25 | 26 | request.url = url 27 | request.headers = headers or {} 28 | 29 | request.sink = ltn12.sink.table(sink) 30 | socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT) 31 | 32 | if body ~= nil then 33 | request.source = ltn12.source.string(body) 34 | end 35 | 36 | logger.dbg("[KoInsight] callApi:", request.method, request.url) 37 | 38 | local code, resp_headers, status = socket.skip(1, http.request(request)) 39 | socketutil:reset_timeout() 40 | 41 | -- Raise error if network is unavailable 42 | if resp_headers == nil then 43 | logger.err("[KoInsight] callApi: network error", status or code) 44 | return false, "network_error" 45 | end 46 | 47 | -- If the request returned successfully 48 | if code == 200 then 49 | local content = table.concat(sink) 50 | 51 | if content == nil or content == "" or string.sub(content, 1, 1) ~= "{" then 52 | response_not_valid(content) 53 | return false, "empty_response" 54 | end 55 | 56 | local ok, result = pcall(JSON.decode, content) 57 | 58 | if ok and result then 59 | return true, result 60 | else 61 | response_not_valid(content) 62 | return false, "invalid_response" 63 | end 64 | else 65 | if not quiet then 66 | logger.err("[KoInsight] callApi: HTTP error", status or code, resp_headers, result) 67 | UIManager:show(InfoMessage:new({ 68 | text = _("Server error" .. (result and ": " .. result["error"] or "")), 69 | })) 70 | end 71 | 72 | logger.err("[KoInsight] callApi: HTTP error", status or code, resp_headers) 73 | return false, "http_error", code 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /apps/server/src/kosync/kosync-repository.ts: -------------------------------------------------------------------------------- 1 | import { Progress } from '@koinsight/common/types/progress'; 2 | import { User } from '@koinsight/common/types/user'; 3 | import { db } from '../knex'; 4 | 5 | export type ProgressCreate = Omit; 6 | export type ProgressUpdate = Omit; 7 | 8 | export class KosyncRepository { 9 | static async hasDocument(user_id: User['id'], document: Progress['document']): Promise { 10 | const result = await db('progress').where({ user_id, document }).select('id').first(); 11 | return !!result; 12 | } 13 | 14 | static async create(progress: ProgressCreate): Promise { 15 | const date = new Date(); 16 | const result = await db('progress') 17 | .insert({ ...progress, created_at: date, updated_at: date }) 18 | .returning('*'); 19 | return result.at(0); 20 | } 21 | 22 | static async update( 23 | user_id: User['id'], 24 | progress: ProgressUpdate 25 | ): Promise { 26 | const result = await db('progress') 27 | .where({ user_id, document: progress.document }) 28 | .update({ ...progress, updated_at: new Date() }) 29 | .returning('*'); 30 | 31 | return result.at(0); 32 | } 33 | 34 | static async upsert( 35 | user_id: User['id'], 36 | progress: ProgressUpdate 37 | ): Promise { 38 | const exists = await this.hasDocument(user_id, progress.document); 39 | 40 | if (exists) { 41 | return this.update(user_id, progress); 42 | } else { 43 | return this.create({ ...progress, user_id }); 44 | } 45 | } 46 | 47 | static async getByUserIdAndDocument( 48 | user_id: User['id'], 49 | document: Progress['document'] 50 | ): Promise { 51 | const result = await db('progress').where({ user_id, document }).select('*').first(); 52 | return result; 53 | } 54 | 55 | static async getAll(): Promise { 56 | const result = await db('progress') 57 | .select( 58 | 'progress.document', 59 | 'progress.progress', 60 | 'progress.percentage', 61 | 'progress.device', 62 | 'progress.device_id', 63 | 'progress.created_at', 64 | 'progress.updated_at', 65 | 'user.username' 66 | ) 67 | .innerJoin('user', 'user.id', 'progress.user_id'); 68 | return result; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /apps/web/src/pages/book-page/book-page-manage/book-reference-pages.tsx: -------------------------------------------------------------------------------- 1 | import { Book } from '@koinsight/common/types'; 2 | import { Button, Flex, NumberInput, Text, Title } from '@mantine/core'; 3 | import { notifications } from '@mantine/notifications'; 4 | import { useState } from 'react'; 5 | import { updateBookReferencePages } from '../../../api/books'; 6 | 7 | export type BookReferencePagesProps = { 8 | book: Book; 9 | }; 10 | 11 | export function BookReferencePages({ book }: BookReferencePagesProps) { 12 | const [referencePages, setReferencePages] = useState(book.reference_pages ?? 0); 13 | 14 | const [updateLoading, setUpdateLoading] = useState(false); 15 | 16 | const onUpdateReferencePages = async () => { 17 | try { 18 | setUpdateLoading(true); 19 | await updateBookReferencePages(book.id, referencePages); 20 | notifications.show({ 21 | title: 'Reference page count updated', 22 | message: `${book ? `"${book?.title}"` : 'Book'} reference page count updated successfully.`, 23 | color: 'green', 24 | position: 'top-center', 25 | }); 26 | } catch (error) { 27 | notifications.show({ 28 | title: 'Failed to update reference page count', 29 | message: '', 30 | color: 'red', 31 | position: 'top-center', 32 | }); 33 | } finally { 34 | setUpdateLoading(false); 35 | } 36 | }; 37 | 38 | return ( 39 |
40 | 41 | Reference page count 42 | 43 | 44 | KOReader tracks your reading progress based on pages in the app, which can vary 45 | depending on settings like font size, margins, and layout. For example, a 100-page book 46 | might show up as 150 pages in KOReader if you increase the font size. 47 |
48 |
49 | To get accurate reading stats, you can set the reference page count — the 50 | actual number of pages in the physical or original version of the book. KoInsight will then 51 | adjust your stats to match that real-world page count. 52 |
53 | 54 | setReferencePages(Number(e))} 58 | /> 59 | 62 | 63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /plugins/koinsight.koplugin/settings.lua: -------------------------------------------------------------------------------- 1 | local _ = require("gettext") 2 | local BD = require("ui/bidi") 3 | local DataStorage = require("datastorage") 4 | local InfoMessage = require("ui/widget/infomessage") 5 | local logger = require("logger") 6 | local LuaSettings = require("luasettings") 7 | local MultiInputDialog = require("ui/widget/multiinputdialog") 8 | local UIManager = require("ui/uimanager") 9 | 10 | local KoInsightSettings = { 11 | server_url = nil, 12 | } 13 | KoInsightSettings.__index = KoInsightSettings 14 | 15 | local SETTING_KEY = "koinsight" 16 | 17 | function KoInsightSettings:new() 18 | local obj = setmetatable({}, self) 19 | obj.settings = obj:readSettings() 20 | obj.server_url = obj.settings.data.koinsight.server_url 21 | return obj 22 | end 23 | 24 | function KoInsightSettings:readSettings() 25 | local settings = LuaSettings:open(DataStorage:getSettingsDir() .. "/" .. SETTING_KEY .. ".lua") 26 | settings:readSetting(SETTING_KEY, {}) 27 | return settings 28 | end 29 | 30 | function KoInsightSettings:persistSettings() 31 | local new_settings = { 32 | server_url = self.server_url, 33 | } 34 | 35 | self.settings:saveSetting(SETTING_KEY, new_settings) 36 | self.settings:flush() 37 | end 38 | 39 | function KoInsightSettings:editServerSettings() 40 | self.settings_dialog = MultiInputDialog:new({ 41 | title = _("KoInsight settings"), 42 | fields = { 43 | { 44 | text = self.server_url, 45 | description = _("Server URL:"), 46 | hint = _("http://example.com:port"), 47 | }, 48 | }, 49 | buttons = { 50 | { 51 | { 52 | text = _("Cancel"), 53 | id = "close", 54 | callback = function() 55 | UIManager:close(self.settings_dialog) 56 | end, 57 | }, 58 | { 59 | text = _("Info"), 60 | callback = function() 61 | UIManager:show(InfoMessage:new({ 62 | text = _("Enter the location of your KoInsight server"), 63 | })) 64 | end, 65 | }, 66 | { 67 | text = _("Apply"), 68 | callback = function() 69 | local myfields = self.settings_dialog:getFields() 70 | self.server_url = myfields[1]:gsub("/*$", "") -- remove all trailing slashes 71 | self:persistSettings() 72 | UIManager:close(self.settings_dialog) 73 | end, 74 | }, 75 | }, 76 | }, 77 | }) 78 | 79 | UIManager:show(self.settings_dialog) 80 | self.settings_dialog:onShowKeyboard() 81 | end 82 | 83 | return KoInsightSettings 84 | -------------------------------------------------------------------------------- /apps/web/src/components/dot-trail/dot-trail.tsx: -------------------------------------------------------------------------------- 1 | import { darken, Tooltip, useComputedColorScheme } from '@mantine/core'; 2 | import { useResizeObserver } from '@mantine/hooks'; 3 | import { startOfDay, startOfWeek, subDays } from 'date-fns'; 4 | import { JSX, ReactNode, useMemo } from 'react'; 5 | 6 | import style from './dot-trail.module.css'; 7 | 8 | export type DayData = { 9 | percent: number; 10 | tooltip: ReactNode; 11 | }; 12 | 13 | type DotTrailProps = { 14 | percentPerDay: Record; 15 | }; 16 | 17 | export function DotTrail({ percentPerDay }: DotTrailProps): JSX.Element { 18 | const [ref, rect] = useResizeObserver(); 19 | const colorScheme = useComputedColorScheme(); 20 | 21 | const today = startOfDay(new Date()); 22 | 23 | const dotsToFit = Math.floor((rect.width - 18) / 18); 24 | const daysToFit = dotsToFit * 7; 25 | 26 | const start = startOfDay( 27 | startOfWeek(subDays(today, daysToFit), { locale: { options: { weekStartsOn: 1 } } }) 28 | ); 29 | 30 | const getOutlineColor = (percent?: number): string => { 31 | const backgound = colorScheme === 'light' ? 'rgba(0, 0, 0, 0.05)' : 'rgba(255, 255, 255, 0.1)'; 32 | 33 | return percent ? darken(`rgba(35, 186, 175, ${percent / 100})`, 0.4) : backgound; 34 | }; 35 | const getBackgroundColor = (percent?: number): string => { 36 | const backgound = colorScheme === 'dark' ? 'rgba(0, 0, 0, 0.05)' : 'rgba(255, 255, 255)'; 37 | return percent ? `rgba(35, 186, 175, ${percent / 100})` : backgound; 38 | }; 39 | 40 | const allDays = useMemo(() => { 41 | const days = []; 42 | let current = start; 43 | while (current <= today) { 44 | days.push(startOfDay(current.getTime()).valueOf()); 45 | current = new Date(current.getTime() + 24 * 60 * 60 * 1000); 46 | } 47 | 48 | return days; 49 | }, [start, today]); 50 | 51 | return ( 52 |
53 | {allDays.map((day, id) => ( 54 |
55 | 63 |
71 | 72 |
73 | ))} 74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /plugins/koinsight.koplugin/upload.lua: -------------------------------------------------------------------------------- 1 | local _ = require("gettext") 2 | local callApi = require("call_api") 3 | local InfoMessage = require("ui/widget/infomessage") 4 | local JSON = require("json") 5 | local KoInsightDbReader = require("db_reader") 6 | local logger = require("logger") 7 | local UIManager = require("ui/uimanager") 8 | local const = require("const") 9 | local Device = require("device") 10 | 11 | local API_UPLOAD_LOCATION = "/api/plugin/import" 12 | local API_DEVICE_LOCATION = "/api/plugin/device" 13 | 14 | function get_headers(body) 15 | local headers = { 16 | ["Content-Type"] = "application/json", 17 | ["Content-Length"] = tostring(#body), 18 | } 19 | return headers 20 | end 21 | 22 | function render_response_message(response, prefix, default_text) 23 | local text = prefix .. " " .. default_text 24 | if response ~= nil and response["message"] ~= nil then 25 | logger.dbg("[KoInsight] API message received: ", JSON.encode(response)) 26 | text = prefix .. " " .. response["message"] 27 | end 28 | 29 | UIManager:show(InfoMessage:new({ 30 | text = _(text), 31 | })) 32 | end 33 | 34 | function send_device_data(server_url, silent) 35 | local url = server_url .. API_DEVICE_LOCATION 36 | local body = { 37 | id = G_reader_settings:readSetting("device_id"), 38 | model = Device.model, 39 | version = const.VERSION, 40 | } 41 | body = JSON.encode(body) 42 | 43 | local ok, response = callApi("POST", url, get_headers(body), body) 44 | 45 | if ok ~= true and not silent then 46 | render_response_message(response, "Error:", "Unable to register device.") 47 | end 48 | end 49 | 50 | function send_statistics_data(server_url, silent) 51 | local url = server_url .. API_UPLOAD_LOCATION 52 | 53 | local body = { 54 | stats = KoInsightDbReader.progressData(), 55 | books = KoInsightDbReader.bookData(), 56 | version = const.VERSION, 57 | } 58 | 59 | body = JSON.encode(body) 60 | 61 | local ok, response = callApi("POST", url, get_headers(body), body) 62 | 63 | if not silent then 64 | if ok then 65 | render_response_message(response, "Success:", "Data uploaded.") 66 | else 67 | render_response_message(response, "Error:", "Data upload failed.") 68 | end 69 | end 70 | end 71 | 72 | return function(server_url, silent) 73 | if silent == nil then 74 | silent = false 75 | end 76 | if server_url == nil or server_url == "" then 77 | UIManager:show(InfoMessage:new({ 78 | text = _("Please configure the server URL first."), 79 | })) 80 | return 81 | end 82 | 83 | send_device_data(server_url, silent) 84 | send_statistics_data(server_url, silent) 85 | end 86 | -------------------------------------------------------------------------------- /apps/server/src/open-library/open-library-types.ts: -------------------------------------------------------------------------------- 1 | export interface OpenLibrarySearchResult { 2 | numFound: number; 3 | start: number; 4 | numFoundExact: boolean; 5 | docs: Doc[]; 6 | num_found: number; 7 | q: string; 8 | offset: null; 9 | } 10 | 11 | export interface Doc { 12 | author_alternative_name?: string[]; 13 | author_key: string[]; 14 | author_name: string[]; 15 | contributor?: string[]; 16 | cover_edition_key?: string; 17 | cover_i?: number; 18 | ddc?: string[]; 19 | ebook_access: string; 20 | ebook_count_i: number; 21 | edition_count: number; 22 | edition_key: string[]; 23 | first_publish_year: number; 24 | format?: string[]; 25 | has_fulltext: boolean; 26 | ia?: string[]; 27 | ia_collection?: string[]; 28 | ia_collection_s?: string; 29 | isbn: string[]; 30 | key: string; 31 | language: string[]; 32 | last_modified_i: number; 33 | lcc?: string[]; 34 | lccn?: string[]; 35 | lending_edition_s?: string; 36 | lending_identifier_s?: string; 37 | number_of_pages_median: number; 38 | oclc?: string[]; 39 | printdisabled_s?: string; 40 | public_scan_b: boolean; 41 | publish_date: string[]; 42 | publish_place?: string[]; 43 | publish_year: number[]; 44 | publisher: string[]; 45 | seed: string[]; 46 | title: string; 47 | title_sort: string; 48 | title_suggest: string; 49 | type: string; 50 | id_amazon?: string[]; 51 | id_depósito_legal?: string[]; 52 | id_goodreads?: string[]; 53 | id_librarything?: string[]; 54 | subject?: string[]; 55 | ia_loaded_id?: string[]; 56 | ia_box_id?: string[]; 57 | ratings_average?: number; 58 | ratings_sortable?: number; 59 | ratings_count?: number; 60 | ratings_count_1?: number; 61 | ratings_count_2?: number; 62 | ratings_count_3?: number; 63 | ratings_count_4?: number; 64 | ratings_count_5?: number; 65 | readinglog_count: number; 66 | want_to_read_count: number; 67 | currently_reading_count: number; 68 | already_read_count: number; 69 | publisher_facet: string[]; 70 | subject_facet?: string[]; 71 | _version_: number; 72 | lcc_sort?: string; 73 | author_facet: string[]; 74 | subject_key?: string[]; 75 | ddc_sort?: string; 76 | first_sentence?: string[]; 77 | osp_count?: number; 78 | id_librivox?: string[]; 79 | id_project_gutenberg?: string[]; 80 | id_overdrive?: string[]; 81 | place?: string[]; 82 | time?: string[]; 83 | person?: string[]; 84 | person_key?: string[]; 85 | time_facet?: string[]; 86 | place_key?: string[]; 87 | person_facet?: string[]; 88 | place_facet?: string[]; 89 | time_key?: string[]; 90 | id_better_world_books?: string[]; 91 | id_google?: string[]; 92 | id_wikidata?: string[]; 93 | } 94 | -------------------------------------------------------------------------------- /apps/server/src/books/covers/covers-router.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response, Router } from 'express'; 2 | import { unlink } from 'fs'; 3 | import multer from 'multer'; 4 | import { appConfig } from '../../config'; 5 | import { getBookById } from '../get-book-by-id-middleware'; 6 | import { CoversService } from './covers-service'; 7 | 8 | const router = Router({ mergeParams: true }); 9 | 10 | /** 11 | * Fetches a book cover by book id 12 | */ 13 | router.get('/', getBookById, async (req: Request, res: Response) => { 14 | const book = req.book!; 15 | 16 | try { 17 | const coverPath = await CoversService.get(book); 18 | if (coverPath) { 19 | res.sendFile(coverPath); 20 | } else { 21 | res.status(404).send({ error: 'Cover not found' }); 22 | } 23 | } catch (error) { 24 | console.error('Error fetching cover:', error); 25 | res.status(500).send({ error: 'Error fetching cover' }); 26 | } 27 | }); 28 | 29 | const upload = multer({ 30 | dest: appConfig.coversPath, 31 | fileFilter: (_req, file, cb) => { 32 | const allowedExtensions = ['.png', '.jpg', '.jpeg', '.gif']; 33 | if ( 34 | file.mimetype === 'application/octet-stream' || 35 | allowedExtensions.some((ext) => file.originalname.endsWith(ext)) 36 | ) { 37 | cb(null, true); // Accept the file 38 | } else { 39 | cb(new Error(`Only ${allowedExtensions.join(', ')} files are allowed`)); 40 | } 41 | }, 42 | limits: { fileSize: 10 * 1024 * 1024 }, 43 | }); 44 | 45 | /** 46 | * Uploads a book cover by book id 47 | */ 48 | router.post( 49 | '/', 50 | getBookById, 51 | async (req: Request, _res: Response, next: NextFunction) => { 52 | console.debug('Deleting existing cover for book', req.book!.md5); 53 | await CoversService.deleteExisting(req.book!); 54 | next(); 55 | }, 56 | upload.single('file'), 57 | async (req: Request, res: Response, next: NextFunction) => { 58 | const book = req.book!; 59 | const file = req.file; 60 | 61 | if (!file) { 62 | res.status(400).json({ error: 'Missing file upload' }); 63 | return next(); 64 | } 65 | 66 | try { 67 | await CoversService.upload(book, file); 68 | 69 | res.send({ message: 'Cover updated' }); 70 | } catch (error) { 71 | // Cleanup uploaded file if there's an error 72 | if (file?.path) { 73 | try { 74 | unlink(file.path, () => {}); 75 | } catch (_) { 76 | // ignore cleanup errors 77 | } 78 | } 79 | console.log('Error uploading cover:', error); 80 | res.status(500).send({ message: 'Unable to update cover' }); 81 | } 82 | } 83 | ); 84 | 85 | export { router as coversRouter }; 86 | -------------------------------------------------------------------------------- /apps/server/src/app.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors'; 2 | import express, { Request, Response } from 'express'; 3 | import { Server } from 'http'; 4 | import morgan from 'morgan'; 5 | import path from 'path'; 6 | import { openAiRouter } from './ai/open-ai-router'; 7 | import { booksRouter } from './books/books-router'; 8 | import { appConfig } from './config'; 9 | import { devicesRouter } from './devices/devices-router'; 10 | import { db } from './knex'; 11 | import { kopluginRouter } from './koplugin/koplugin-router'; 12 | import { kosyncRouter } from './kosync/kosync-router'; 13 | import { openLibraryRouter } from './open-library/open-library-router'; 14 | import { statsRouter } from './stats/stats-router'; 15 | import { uploadRouter } from './upload/upload-router'; 16 | 17 | async function setupServer() { 18 | const app = express(); 19 | // Increase the limit to be able to upload the whole database 20 | app.use(express.json({ limit: '50mb' })); 21 | app.use(express.urlencoded({ limit: '50mb', extended: true })); 22 | app.use(morgan('tiny')); 23 | 24 | // if (appConfig.env === 'development') { 25 | // Allow requests from dev build 26 | app.use(cors({ origin: '*' })); 27 | // } 28 | 29 | app.use('/', kosyncRouter); // Needs to be mounted at root to follow KoSync API 30 | app.use('/api/plugin', kopluginRouter); 31 | app.use('/api/devices', devicesRouter); 32 | app.use('/api/books', booksRouter); 33 | app.use('/api/stats', statsRouter); 34 | app.use('/api/upload', uploadRouter); 35 | app.use('/api/open-library', openLibraryRouter); 36 | app.use('/api/ai', openAiRouter); 37 | 38 | // Serve react app 39 | app.use(express.static(appConfig.webBuildPath)); 40 | app.get('*', (_req: Request, res: Response) => { 41 | res.sendFile(path.join(appConfig.webBuildPath, 'index.html')); 42 | }); 43 | 44 | // Start :) 45 | const server = app.listen(appConfig.port, appConfig.hostname, () => { 46 | console.info(`KoInsight back-end is running on http://${appConfig.hostname}:${appConfig.port}`); 47 | }); 48 | 49 | return server; 50 | } 51 | 52 | function stopServer(signal: NodeJS.Signals, server: Server) { 53 | console.log(`Received ${signal.toString()}. Gracefully shutting down...`); 54 | server.close(() => { 55 | console.log('Server closed.'); 56 | process.exit(0); 57 | }); 58 | } 59 | 60 | async function main() { 61 | console.log('Running database migrations'); 62 | await db.migrate.latest({ directory: path.join(__dirname, 'db', 'migrations') }); 63 | console.log('Database migrated successfully'); 64 | 65 | setupServer().then((server) => { 66 | process.on('SIGINT', (signal) => stopServer(signal, server)); 67 | process.on('SIGTERM', (signal) => stopServer(signal, server)); 68 | }); 69 | } 70 | 71 | main(); 72 | -------------------------------------------------------------------------------- /apps/server/src/db/seeds/02_books.ts: -------------------------------------------------------------------------------- 1 | import { Book } from '@koinsight/common/types/book'; 2 | import { Knex } from 'knex'; 3 | import { db } from '../../knex'; 4 | import { generateMd5Hash } from '../../utils/strings'; 5 | import { createBook } from '../factories/book-factory'; 6 | 7 | const SEED_BOOKS: Partial[] = [ 8 | { 9 | id: 1, 10 | title: 'Mistborn: The Final Empire', 11 | authors: 'Brandon Sanderson', 12 | series: 'Mistborn', 13 | language: 'en', 14 | md5: generateMd5Hash('Mistborn: The Final Empire'), 15 | }, 16 | { 17 | id: 2, 18 | title: 'The Name of the Wind', 19 | authors: 'Patrick Rothfuss', 20 | series: 'The Kingkiller Chronicle', 21 | language: 'en', 22 | md5: generateMd5Hash('The Name of the Wind'), 23 | }, 24 | { 25 | id: 3, 26 | title: 'A Game of Thrones', 27 | authors: 'George R. R. Martin', 28 | series: 'A Song of Ice and Fire', 29 | language: 'en', 30 | md5: generateMd5Hash('A Game of Thrones'), 31 | }, 32 | { 33 | id: 4, 34 | title: 'The Way of Kings', 35 | authors: 'Brandon Sanderson', 36 | series: 'The Stormlight Archive', 37 | language: 'en', 38 | md5: generateMd5Hash('The Way of Kings'), 39 | }, 40 | { 41 | id: 5, 42 | title: 'The Fellowship of the Ring', 43 | authors: 'J.R.R. Tolkien', 44 | series: 'The Lord of the Rings', 45 | language: 'en', 46 | md5: generateMd5Hash('The Fellowship of the Ring'), 47 | }, 48 | { 49 | id: 6, 50 | title: 'The Two Towers', 51 | authors: 'J.R.R. Tolkien', 52 | series: 'The Lord of the Rings', 53 | language: 'en', 54 | md5: generateMd5Hash('The Two Towers'), 55 | }, 56 | { 57 | id: 7, 58 | title: 'The Last Wish', 59 | authors: 'Andrzej Sapkowski', 60 | series: 'The Witcher', 61 | language: 'en', 62 | md5: generateMd5Hash('The Last Wish'), 63 | }, 64 | { 65 | id: 8, 66 | title: 'Hyperion', 67 | authors: 'Dan Simmons', 68 | series: 'Hyperion Cantos', 69 | language: 'en', 70 | md5: generateMd5Hash('Hyperion'), 71 | }, 72 | { 73 | id: 9, 74 | title: 'The Martian', 75 | authors: 'Andy Weir', 76 | series: 'N/A', 77 | language: 'en', 78 | md5: generateMd5Hash('The Martian'), 79 | }, 80 | { 81 | id: 10, 82 | title: 'Foundation', 83 | authors: 'Isaac Asimov', 84 | series: 'Foundation Series', 85 | language: 'en', 86 | md5: generateMd5Hash('Foundation'), 87 | }, 88 | ]; 89 | 90 | export let SEEDED_BOOKS: Book[] = []; 91 | 92 | export async function seed(knex: Knex): Promise { 93 | await knex('book').del(); 94 | 95 | const books = await Promise.all(SEED_BOOKS.map((book) => createBook(db, book))); 96 | SEEDED_BOOKS = books as Book[]; 97 | } 98 | -------------------------------------------------------------------------------- /apps/web/src/pages/book-page/book-card.tsx: -------------------------------------------------------------------------------- 1 | import { BookWithData } from '@koinsight/common/types'; 2 | import { Flex, Group, Image, Title, Tooltip } from '@mantine/core'; 3 | import { useMediaQuery } from '@mantine/hooks'; 4 | import { IconBooks, IconCalendar, IconHighlight, IconNote, IconUser } from '@tabler/icons-react'; 5 | import { JSX } from 'react'; 6 | import { API_URL } from '../../api/api'; 7 | import { formatRelativeDate } from '../../utils/dates'; 8 | 9 | import style from './book-card.module.css'; 10 | 11 | type BookCardProps = { 12 | book: BookWithData; 13 | }; 14 | 15 | export function BookCard({ book }: BookCardProps): JSX.Element { 16 | const media = useMediaQuery(`(max-width: 62em)`); 17 | 18 | return ( 19 | 20 | {book.title} 27 |
28 | 29 | 30 | 31 | 32 | {book.authors ?? 'N/A'} 33 | 34 | 35 | {book.title} 36 | 37 | 38 | 39 | 40 | 41 | {book.series} 42 | 43 | 44 | 45 | 46 | 47 | 48 | {formatRelativeDate(book.last_open * 1000)} 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {book.device_data.reduce((acc, device) => acc + device.highlights, 0)} 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | {book.device_data.reduce((acc, device) => acc + device.notes, 0)} 67 | 68 | 69 | 70 |
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /apps/web/src/components/navbar/upload-form.tsx: -------------------------------------------------------------------------------- 1 | import { Button, FileInput, Flex, Modal, Text, Title } from '@mantine/core'; 2 | import { useDisclosure } from '@mantine/hooks'; 3 | import { notifications } from '@mantine/notifications'; 4 | import { IconUpload } from '@tabler/icons-react'; 5 | import { FormEvent, JSX, useState } from 'react'; 6 | import { uploadDbFile } from '../../api/upload-db-file'; 7 | import { mutate } from 'swr'; 8 | 9 | export function UploadForm(): JSX.Element { 10 | const [file, setFile] = useState(null); 11 | const [modalOpened, { open, close }] = useDisclosure(false); 12 | const [message, setMessage] = useState(''); 13 | 14 | const handleSubmit = async (event: FormEvent) => { 15 | event.preventDefault(); 16 | 17 | if (!file) { 18 | setMessage('Please select a file before submitting.'); 19 | return; 20 | } 21 | 22 | const formData = new FormData(); 23 | formData.append('file', file); 24 | 25 | try { 26 | const response = await uploadDbFile(formData); 27 | 28 | if (response.ok) { 29 | // FIXME: this doesn't seem to work. 30 | await mutate('books'); 31 | notifications.show({ 32 | title: 'Success', 33 | message: 'File uploaded and validated successfully.', 34 | position: 'top-center', 35 | color: 'green', 36 | }); 37 | setMessage(''); 38 | close(); 39 | } 40 | else if (response.status === 413) { 41 | const body = await response.json(); 42 | setMessage(body?.error); 43 | } else { 44 | setMessage('Failed to upload file.'); 45 | } 46 | } catch (error) { 47 | setMessage(`Error: ${error}`); 48 | } 49 | }; 50 | 51 | return ( 52 | <> 53 | 56 | 72 | 73 | Upload your KOReader statistics.sqlite3 file. 74 |
75 | setFile(e)} 79 | accept=".sqlite,.sqlite3" 80 | mb="sm" 81 | /> 82 | 83 | 84 | {message &&

{message}

} 85 |
86 |
87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /apps/web/src/pages/calendar-page.tsx: -------------------------------------------------------------------------------- 1 | import { PageStat } from '@koinsight/common/types'; 2 | import { Book } from '@koinsight/common/types/book'; 3 | import { Anchor, Flex, Loader, Title } from '@mantine/core'; 4 | import { IconClock } from '@tabler/icons-react'; 5 | import { startOfDay } from 'date-fns/startOfDay'; 6 | import { sum, uniq } from 'ramda'; 7 | import { JSX, useCallback, useMemo } from 'react'; 8 | import { Link } from 'react-router'; 9 | import { useBooks } from '../api/books'; 10 | import { usePageStats } from '../api/use-page-stats'; 11 | import { Calendar, CalendarEvent } from '../components/calendar/calendar'; 12 | import { getBookPath } from '../routes'; 13 | import { getDuration, shortDuration } from '../utils/dates'; 14 | 15 | type DayData = { 16 | events: PageStat[]; 17 | }; 18 | 19 | export function CalendarPage(): JSX.Element { 20 | const { data: books, isLoading } = useBooks(); 21 | const { 22 | data: { stats: events }, 23 | isLoading: eventsLoading, 24 | } = usePageStats(); 25 | 26 | const calendarEvents = useMemo>>(() => { 27 | if (eventsLoading || !events) { 28 | return {}; 29 | } 30 | 31 | const eventsList = events.reduce>>((acc, event) => { 32 | const date = startOfDay(event.start_time); 33 | const key = date.toISOString(); 34 | 35 | acc[key] = { 36 | date, 37 | data: acc[key]?.data?.events 38 | ? { events: [...acc[key].data.events, event] } 39 | : { events: [event] }, 40 | }; 41 | 42 | return acc; 43 | }, {}); 44 | 45 | return eventsList; 46 | }, [events, eventsLoading]); 47 | 48 | const getBookByMd5 = useCallback( 49 | (md5: Book['md5']) => books?.find((book) => book.md5 === md5), 50 | [books] 51 | ); 52 | 53 | const getBookNames = useCallback( 54 | (data: DayData) => { 55 | const uniqueBookMd5s = uniq(data.events.map(({ book_md5 }) => book_md5)); 56 | const eventBooks = uniqueBookMd5s.map((id) => getBookByMd5(id)).filter(Boolean) as Book[]; 57 | 58 | return eventBooks.map((book) => ( 59 | <> 60 | 61 | {book.title} 62 | 63 |
64 | {' '} 65 | {shortDuration( 66 | getDuration( 67 | sum( 68 | data.events 69 | .filter((event) => event.book_md5 === book.md5) 70 | .map((event) => event.duration) 71 | ) 72 | ) 73 | )} 74 |
75 | 76 | )); 77 | }, 78 | [getBookByMd5] 79 | ); 80 | 81 | if (isLoading || !books || !events || eventsLoading) { 82 | return ( 83 | 84 | 85 | 86 | ); 87 | } 88 | 89 | return ( 90 | <> 91 | Calendar 92 | 93 | events={calendarEvents} 94 | dayRenderer={(data) => getBookNames(data).map((el) =>
{el}
)} 95 | /> 96 | 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /apps/server/src/genres/genre-repository.test.ts: -------------------------------------------------------------------------------- 1 | import { createBook } from '../db/factories/book-factory'; 2 | import { createGenre } from '../db/factories/genre-factory'; 3 | import { db } from '../knex'; 4 | import { GenreRepository } from './genre-repository'; 5 | 6 | describe(GenreRepository, () => { 7 | describe(GenreRepository.getAll, () => { 8 | it('returns all genres', async () => { 9 | await createGenre(db, { name: 'Fantasy' }); 10 | await createGenre(db, { name: 'Comedy' }); 11 | const genres = await GenreRepository.getAll(); 12 | expect(genres.length).toBe(2); 13 | }); 14 | 15 | it('returns 0 with no genres', async () => { 16 | const genres = await GenreRepository.getAll(); 17 | expect(genres.length).toBe(0); 18 | }); 19 | }); 20 | 21 | describe(GenreRepository.getByName, () => { 22 | it('returns a genre by name', async () => { 23 | const genre = await createGenre(db, { name: 'Fantasy' }); 24 | const foundGenre = await GenreRepository.getByName(genre.name); 25 | expect(foundGenre?.name).toBe(genre.name); 26 | }); 27 | 28 | it('returns undefined if genre not found', async () => { 29 | const foundGenre = await GenreRepository.getByName('Nonexistent'); 30 | expect(foundGenre).toBeUndefined(); 31 | }); 32 | }); 33 | 34 | describe(GenreRepository.getByBookMd5, () => { 35 | it('returns genres by book md5', async () => { 36 | const genre = await createGenre(db, { name: 'Fantasy' }); 37 | await createBook(db, { md5: '12345' }); 38 | await db('book_genre').insert({ genre_id: genre.id, book_md5: '12345' }); 39 | const foundGenres = await GenreRepository.getByBookMd5('12345'); 40 | expect(foundGenres.length).toBe(1); 41 | expect(foundGenres[0].name).toBe(genre.name); 42 | }); 43 | 44 | it('returns empty array if no genres found', async () => { 45 | const foundGenres = await GenreRepository.getByBookMd5('Nonexistent'); 46 | expect(foundGenres.length).toBe(0); 47 | }); 48 | }); 49 | 50 | describe(GenreRepository.create, () => { 51 | it('creates a genre', async () => { 52 | expect(await GenreRepository.getAll()).toEqual([]); 53 | 54 | const createdGenre = await GenreRepository.create({ name: 'Fantasy' }); 55 | expect(createdGenre.name).toBe('Fantasy'); 56 | expect(await GenreRepository.getAll()).toEqual([createdGenre]); 57 | }); 58 | }); 59 | 60 | describe(GenreRepository.findOrCreate, () => { 61 | it('returns existing genre if it exists', async () => { 62 | const genre = await createGenre(db, { name: 'Fantasy' }); 63 | const foundGenre = await GenreRepository.findOrCreate({ name: genre.name }); 64 | expect(foundGenre.name).toBe(genre.name); 65 | expect(await GenreRepository.getAll()).toHaveLength(1); 66 | }); 67 | 68 | it('creates a new genre if it does not exist', async () => { 69 | expect(await GenreRepository.getAll()).toHaveLength(0); 70 | const foundGenre = await GenreRepository.findOrCreate({ name: 'Fantasy' }); 71 | expect(foundGenre).toBeDefined(); 72 | expect(foundGenre.name).toBe('Fantasy'); 73 | expect(await GenreRepository.getAll()).toHaveLength(1); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /apps/server/src/koplugin/koplugin-router.ts: -------------------------------------------------------------------------------- 1 | import { KoReaderBook } from '@koinsight/common/types/book'; 2 | import { Device } from '@koinsight/common/types/device'; 3 | import { PageStat } from '@koinsight/common/types/page-stat'; 4 | import archiver from 'archiver'; 5 | import { NextFunction, Request, Response, Router } from 'express'; 6 | import path from 'path'; 7 | import { DeviceRepository } from '../devices/device-repository'; 8 | import { UploadService } from '../upload/upload-service'; 9 | 10 | // Router for KoInsight koreader plugin 11 | const router = Router(); 12 | 13 | const REQUIRED_PLUGIN_VERSION = '0.1.0'; 14 | 15 | const rejectOldPluginVersion = (req: Request, res: Response, next: NextFunction) => { 16 | const { version } = req.body; 17 | 18 | if (!version || version !== '0.1.0') { 19 | res.status(400).json({ 20 | error: `Unsupported plugin version. Version must be ${REQUIRED_PLUGIN_VERSION}. Please update your KOReader koinsight.koplugin`, 21 | }); 22 | return; 23 | } 24 | 25 | next(); 26 | }; 27 | 28 | router.post('/device', rejectOldPluginVersion, async (req, res) => { 29 | const { id, model } = req.body; 30 | 31 | if (!id || !model) { 32 | res.status(400).json({ error: 'Missing device ID or model' }); 33 | return; 34 | } 35 | 36 | const device: Device = { id, model }; 37 | 38 | try { 39 | console.debug('Registering device:', device); 40 | await DeviceRepository.insertIfNotExists(device); 41 | res.status(200).json({ message: 'Device registered successfully' }); 42 | } catch (err) { 43 | console.error(err); 44 | res.status(500).json({ error: 'Error registering device' }); 45 | } 46 | }); 47 | 48 | router.post('/import', rejectOldPluginVersion, async (req, res) => { 49 | const contentLength = req.headers['content-length']; 50 | console.warn(`[${req.method}] ${req.url} — Content-Length: ${contentLength || 'unknown'} bytes`); 51 | 52 | const koreaderBooks: KoReaderBook[] = req.body.books; 53 | const newPageStats: PageStat[] = req.body.stats; 54 | 55 | try { 56 | console.debug('Importing books:', koreaderBooks); 57 | console.debug('Importing page stats:', newPageStats); 58 | await UploadService.uploadStatisticData(koreaderBooks, newPageStats); 59 | res.status(200).json({ message: 'Upload successfull' }); 60 | } catch (err) { 61 | console.error(err); 62 | res.status(500).json({ error: 'Error importing data' }); 63 | } 64 | }); 65 | 66 | // TODO: implement check in koreader plugin 67 | router.get('/health', rejectOldPluginVersion, async (_, res) => { 68 | res.status(200).json({ message: 'Plugin is healthy' }); 69 | }); 70 | 71 | router.get('/download', (_, res) => { 72 | const folderPath = path.join(__dirname, '../../../../', 'plugins'); 73 | const archive = archiver('zip', { zlib: { level: 9 } }); 74 | 75 | res.setHeader('Content-Type', 'application/zip'); 76 | res.setHeader('Content-Disposition', 'attachment; filename=koinsight.plugin.zip'); 77 | 78 | archive.on('error', (err) => { 79 | console.error('Archive error:', err); 80 | res.status(500).send('Error creating zip'); 81 | }); 82 | 83 | // Pipe the archive directly to the response 84 | archive.pipe(res); 85 | 86 | // Add folder contents to the archive 87 | archive.directory(folderPath, false); 88 | 89 | archive.finalize(); 90 | }); 91 | 92 | export { router as kopluginRouter }; 93 | -------------------------------------------------------------------------------- /apps/web/src/pages/book-page/book-page-raw.tsx: -------------------------------------------------------------------------------- 1 | import { BookWithData, Device } from '@koinsight/common/types'; 2 | import { Flex, NumberInput, Table } from '@mantine/core'; 3 | import { DateInput } from '@mantine/dates'; 4 | import { endOfDay, formatDate, startOfDay } from 'date-fns'; 5 | import { apply } from 'ramda'; 6 | import { JSX, useMemo, useState } from 'react'; 7 | import { useDevices } from '../../api/devices'; 8 | import { formatSecondsToHumanReadable } from '../../utils/dates'; 9 | 10 | type BookPageRawProps = { 11 | book: BookWithData; 12 | }; 13 | 14 | export function BookPageRaw({ book }: BookPageRawProps): JSX.Element { 15 | const { data: devices } = useDevices(); 16 | 17 | const devicesById = useMemo( 18 | () => 19 | devices.reduce( 20 | (acc, device) => { 21 | acc[device.id] = device; 22 | return acc; 23 | }, 24 | {} as Record 25 | ), 26 | [devices] 27 | ); 28 | 29 | const dates = book.stats.map((stat) => stat.start_time); 30 | const pages = book.stats.map((stat) => stat.page); 31 | const min = dates.length > 0 ? new Date(apply(Math.min, dates)) : new Date(); 32 | const max = dates.length > 0 ? new Date(apply(Math.max, dates)) : new Date(); 33 | const maxPage = apply(Math.max, pages); 34 | 35 | const [page, setPage] = useState(null); 36 | const [startDate, setStartDate] = useState(min); 37 | const [endDate, setEndDate] = useState(max); 38 | 39 | const visibleEvents = book.stats.filter( 40 | (stat) => 41 | (!page || stat.page === page) && 42 | stat.start_time >= startDate.getTime() && 43 | stat.start_time <= endDate.getTime() 44 | ); 45 | 46 | return ( 47 | 48 | 49 | setPage(Number(e))} 53 | max={maxPage} 54 | step={1} 55 | /> 56 | setStartDate(startOfDay(e!))} 60 | minDate={min} 61 | maxDate={endDate} 62 | /> 63 | setEndDate(endOfDay(e!))} 67 | minDate={startDate} 68 | maxDate={max} 69 | /> 70 | 71 | 72 | 73 | 74 | Page 75 | Start time 76 | Duration 77 | Total pages 78 | Device 79 | 80 | 81 | 82 | {visibleEvents.map((stat) => ( 83 | 84 | {stat.page} 85 | {formatDate(stat.start_time, 'dd LLL yyyy, HH:mm:ss')} 86 | {formatSecondsToHumanReadable(stat.duration, false)} 87 | {stat.total_pages} 88 | {devicesById[stat.device_id]?.model ?? stat.device_id} 89 | 90 | ))} 91 | 92 |
93 |
94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /images/logo-mono.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /apps/web/src/assets/logo-mono.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /apps/web/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /apps/web/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Anchor, 3 | Box, 4 | Burger, 5 | createTheme, 6 | Drawer, 7 | Flex, 8 | Group, 9 | MantineProvider, 10 | Stack, 11 | Text, 12 | } from '@mantine/core'; 13 | import { useDisclosure } from '@mantine/hooks'; 14 | import { ModalsProvider } from '@mantine/modals'; 15 | import { Notifications } from '@mantine/notifications'; 16 | import { IconError404, IconHeart } from '@tabler/icons-react'; 17 | import { JSX } from 'react'; 18 | import { Navigate, Route, Routes } from 'react-router'; 19 | import style from './app.module.css'; 20 | import { Logo } from './components/logo/logo'; 21 | import { Navbar } from './components/navbar/navbar'; 22 | import { BookPage } from './pages/book-page/book-page'; 23 | import { BooksPage } from './pages/books-page/books-page'; 24 | import { CalendarPage } from './pages/calendar-page'; 25 | import { StatsPage } from './pages/stats-page/stats-page'; 26 | import { SyncsPage } from './pages/syncs-page'; 27 | import { RoutePath } from './routes'; 28 | 29 | const theme = createTheme({ 30 | headings: { fontFamily: 'Noto Serif, serif' }, 31 | primaryColor: 'koinsight', 32 | primaryShade: 8, 33 | colors: { 34 | koinsight: [ 35 | '#e2fefc', 36 | '#d3f8f5', 37 | '#acede8', 38 | '#81e3dc', 39 | '#5edad1', 40 | '#46d5ca', 41 | '#36d2c7', 42 | '#23baaf', 43 | '#0aa69c', 44 | '#009087', 45 | ], 46 | }, 47 | }); 48 | 49 | export function App(): JSX.Element { 50 | const [drawerOpened, { open: openDrawer, close: closeDrawer }] = useDisclosure(false); 51 | const version = __APP_VERSION__; 52 | return ( 53 | 54 | 55 | 56 |
57 | 58 | openDrawer()} /> 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 |
68 | 69 | } /> 70 | } /> 71 | } /> 72 | } /> 73 | } /> 74 | } /> 75 | {/* Catch-all route goes last */} 76 | 80 | Page not found 😢 81 | 82 | } 83 | /> 84 | 85 |
86 |
87 | 88 | Made with by{' '} 89 | 90 | gar.dev 91 | 92 | . {version} 93 | 94 |
95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /apps/server/src/books/books-router.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response, Router } from 'express'; 2 | import { BooksRepository } from './books-repository'; 3 | import { BooksService } from './books-service'; 4 | import { coversRouter } from './covers/covers-router'; 5 | import { getBookById } from './get-book-by-id-middleware'; 6 | 7 | const router = Router(); 8 | 9 | router.use('/:bookId/cover', coversRouter); 10 | 11 | /** 12 | * Get all books with attached entity data 13 | */ 14 | router.get('/', async (req: Request, res: Response) => { 15 | const returnDeleted = Boolean(req.query.showHidden && req.query.showHidden === 'true'); 16 | const books = await BooksRepository.getAllWithData(returnDeleted); 17 | res.json(books); 18 | }); 19 | 20 | /** 21 | * Get a book with attached entity data by ID 22 | */ 23 | router.get('/:bookId', getBookById, async (req: Request, res: Response, next: NextFunction) => { 24 | const book = req.book!; 25 | const bookWithData = await BooksService.withData(book); 26 | res.json(bookWithData); 27 | }); 28 | 29 | /** 30 | * Delete a book by ID 31 | */ 32 | router.delete('/:bookId', getBookById, async (req: Request, res: Response) => { 33 | const book = req.book!; 34 | 35 | try { 36 | await BooksRepository.delete(book); 37 | res.status(200).json({ message: 'Book deleted' }); 38 | } catch (error) { 39 | console.error(error); 40 | res.status(500).json({ error: 'Failed to delete book' }); 41 | } 42 | }); 43 | 44 | router.put('/:bookId/hide', getBookById, async (req: Request, res: Response) => { 45 | const book = req.book!; 46 | const hidden = req.body.hidden; 47 | 48 | if (hidden === undefined || hidden === null) { 49 | res.status(400).json({ error: 'Missing required fields' }); 50 | return; 51 | } 52 | 53 | try { 54 | await BooksRepository.softDelete(book.id, hidden); 55 | res.status(200).json({ message: `Book ${hidden ? 'hidden' : 'shown'}` }); 56 | } catch (error) { 57 | console.error(error); 58 | res.status(500).json({ error: 'Failed to update book visibility' }); 59 | } 60 | }); 61 | 62 | /** 63 | * Adds a new genre to a book 64 | */ 65 | router.post('/:bookId/genres', getBookById, async (req: Request, res: Response) => { 66 | const book = req.book!; 67 | const { genreName } = req.body; 68 | 69 | if (!genreName) { 70 | res.status(400).json({ error: 'Missing required fields' }); 71 | return; 72 | } 73 | 74 | try { 75 | await BooksRepository.addGenre(book.md5, genreName); 76 | res.status(200).json({ message: 'Genre added' }); 77 | } catch (error) { 78 | console.error(error); 79 | res.status(500).json({ error: 'Failed to add genre' }); 80 | } 81 | }); 82 | 83 | /** 84 | * Updates a book's reference pages 85 | */ 86 | router.put('/:bookId/reference_pages', getBookById, async (req: Request, res: Response) => { 87 | const book = req.book!; 88 | const { reference_pages } = req.body; 89 | 90 | if (reference_pages === undefined || reference_pages === null) { 91 | res.status(400).json({ error: 'Missing required fields' }); 92 | return; 93 | } 94 | 95 | try { 96 | await BooksRepository.setReferencePages(book.id, reference_pages); 97 | res.status(200).json({ message: 'Reference pages updated' }); 98 | } catch (error) { 99 | console.error(error); 100 | res.status(500).json({ error: 'Failed to update reference pages' }); 101 | } 102 | }); 103 | 104 | export { router as booksRouter }; 105 | -------------------------------------------------------------------------------- /apps/web/src/pages/books-page/books-cards.tsx: -------------------------------------------------------------------------------- 1 | import { BookWithData } from '@koinsight/common/types'; 2 | import { Box, Group, Image, Progress, Text, Tooltip } from '@mantine/core'; 3 | import { useMediaQuery } from '@mantine/hooks'; 4 | import { IconBooks, IconEyeClosed, IconProgress, IconUser } from '@tabler/icons-react'; 5 | import C from 'clsx'; 6 | import { JSX } from 'react'; 7 | import { useNavigate } from 'react-router'; 8 | import { API_URL } from '../../api/api'; 9 | import { getBookPath } from '../../routes'; 10 | 11 | import style from './books-cards.module.css'; 12 | 13 | type BooksCardsProps = { 14 | books: BookWithData[]; 15 | }; 16 | 17 | export function BooksCards({ books }: BooksCardsProps): JSX.Element { 18 | const navigate = useNavigate(); 19 | const isSmallScreen = useMediaQuery(`(max-width: 62em)`); 20 | 21 | const cardWidth = isSmallScreen ? 120 : 200; 22 | 23 | return ( 24 |
28 | {books.map((book) => ( 29 | navigate(getBookPath(book.id))} 34 | > 35 | {book.soft_deleted ? ( 36 | 37 | 38 | 39 | ) : null} 40 | {book.title} 48 | 54 | 55 | 56 | {book.title} 57 | 58 | 59 | 60 | 61 | 62 | {book.authors ?? 'N/A'} 63 | 64 | {!isSmallScreen && ( 65 | <> 66 | 67 | 68 | 69 | 70 | {book.series} 71 | 72 | 73 | 74 | 75 | 76 | 77 | {book.total_read_pages} 78 |  /  79 | {book.total_pages} pages read 80 | 81 | 82 | 83 | )} 84 | 85 | 86 | ))} 87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /apps/web/src/components/navbar/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ActionIcon, 3 | Box, 4 | Flex, 5 | useComputedColorScheme, 6 | useMantineColorScheme, 7 | } from '@mantine/core'; 8 | import { useDisclosure } from '@mantine/hooks'; 9 | import { 10 | IconBooks, 11 | IconCalendar, 12 | IconChartBar, 13 | IconDownload, 14 | IconMoon, 15 | IconReload, 16 | IconSun, 17 | } from '@tabler/icons-react'; 18 | import { JSX, useState } from 'react'; 19 | import { NavLink, useLocation } from 'react-router'; 20 | import { RoutePath } from '../../routes'; 21 | import { Logo } from '../logo/logo'; 22 | import { DownloadPluginModal } from './download-plugin'; 23 | import { UploadForm } from './upload-form'; 24 | 25 | import style from './navbar.module.css'; 26 | 27 | export function Navbar({ onNavigate }: { onNavigate?: () => void }): JSX.Element { 28 | const { pathname } = useLocation(); 29 | const { setColorScheme } = useMantineColorScheme(); 30 | const computedColorScheme = useComputedColorScheme(); 31 | const toggleColorScheme = () => { 32 | setColorScheme(computedColorScheme === 'dark' ? 'light' : 'dark'); 33 | }; 34 | 35 | const [downloadOpened, { close: closeDownload, open: openDownload }] = useDisclosure(false); 36 | 37 | const tabs = [ 38 | { link: RoutePath.BOOKS, label: 'Books', icon: IconBooks }, 39 | { link: RoutePath.CALENDAR, label: 'Calendar', icon: IconCalendar }, 40 | { link: RoutePath.STATS, label: 'Reading stats', icon: IconChartBar }, 41 | { link: RoutePath.SYNCS, label: 'Progress syncs', icon: IconReload }, 42 | { onClick: openDownload, label: 'KOReader Plugin', icon: IconDownload }, 43 | ]; 44 | 45 | const [active, setActive] = useState( 46 | () => tabs.find((item) => item.link === pathname)?.link ?? RoutePath.HOME 47 | ); 48 | 49 | const onClick = (link: RoutePath) => { 50 | setActive(link); 51 | onNavigate?.(); 52 | }; 53 | 54 | const links = tabs.map((item) => 55 | item.link ? ( 56 | onClick(item.link)} 62 | > 63 | 64 | {item.label} 65 | 66 | ) : ( 67 | item.onClick()}> 68 | 69 | {item.label} 70 | 71 | ) 72 | ); 73 | 74 | return ( 75 | 76 | { 78 | setActive(RoutePath.HOME); 79 | onNavigate?.(); 80 | }} 81 | className={style.Logo} 82 | /> 83 |
{links}
84 |
85 | 86 | 87 | 93 | {computedColorScheme === 'dark' ? ( 94 | 95 | ) : ( 96 | 97 | )} 98 | 99 | 100 |
101 | 102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /apps/server/src/stats/stats-service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Book, 3 | BookWithData, 4 | PageStat, 5 | PerDayOfTheWeek, 6 | PerMonthReadingTime, 7 | } from '@koinsight/common/types'; 8 | import { format, startOfDay, subDays } from 'date-fns'; 9 | import { groupBy, sum } from 'ramda'; 10 | 11 | export class StatsService { 12 | static getPerMonthReadingTime(stats: PageStat[]): PerMonthReadingTime[] { 13 | const perMonth = (stats ?? []) 14 | .reduce((acc, stat) => { 15 | const month = format(stat.start_time, 'MMMM yyyy'); 16 | const monthData = acc.find((item) => item.month === month); 17 | if (monthData) { 18 | monthData.duration += stat.duration; 19 | } else { 20 | acc.push({ month, duration: stat.duration, date: stat.start_time }); 21 | } 22 | 23 | return acc; 24 | }, []) 25 | .sort((a, b) => a.date - b.date); 26 | 27 | return perMonth; 28 | } 29 | 30 | static perDayOfTheWeek(stats: PageStat[]): PerDayOfTheWeek[] { 31 | return stats 32 | .reduce((acc, stat) => { 33 | const day = format(stat.start_time, 'EEEE'); 34 | const existingDay = acc.find((d) => d.name === day); 35 | if (existingDay) { 36 | existingDay.value += stat.duration; 37 | } else { 38 | acc.push({ 39 | name: day, 40 | value: stat.duration, 41 | day: new Date(stat.start_time).getUTCDay(), 42 | }); 43 | } 44 | return acc; 45 | }, [] as PerDayOfTheWeek[]) 46 | .sort((a, b) => a.day - b.day); 47 | } 48 | 49 | static mostPagesInADay(books: Book[], stats: PageStat[]) { 50 | const max = Math.round(Math.max(...this.getPagesPerDay(stats, books))); 51 | return Math.max(0, max); 52 | } 53 | 54 | static totalReadingTime(stats: PageStat[]) { 55 | return sum((stats ?? []).map((s) => s.duration)); 56 | } 57 | 58 | static longestDay(stats: PageStat[]) { 59 | const timePerDay = stats.reduce>((acc, stat) => { 60 | const day = startOfDay(stat.start_time).getTime(); 61 | acc[day] = (acc[day] || 0) + stat.duration; 62 | return acc; 63 | }, {}); 64 | 65 | const maxTime = Math.max(...Object.values(timePerDay ?? [])); 66 | return Math.max(0, maxTime); 67 | } 68 | 69 | static last7DaysReadTime(stats: PageStat[]) { 70 | const sevenDaysAgo = subDays(new Date(), 7); 71 | const lastSevenDays = stats.filter((stat) => stat.start_time > sevenDaysAgo.getTime()); 72 | return sum(lastSevenDays.map((s) => s.duration)); 73 | } 74 | 75 | static totalPagesRead(books: BookWithData[]) { 76 | return books.reduce((acc, book) => acc + book.total_read_pages, 0); 77 | } 78 | 79 | private static getPagesPerDay(stats: PageStat[], books: Book[]) { 80 | const booksByMd5 = books?.reduce( 81 | (acc, book) => { 82 | acc[book.md5] = book; 83 | return acc; 84 | }, 85 | {} as Record 86 | ); 87 | 88 | const statsPerDay = groupBy((stat: PageStat) => 89 | startOfDay(stat.start_time).getTime().toString() 90 | )(stats); 91 | 92 | const pagesPerDay = Object.values(statsPerDay).map( 93 | (dayStats) => 94 | dayStats?.reduce((acc, stat) => { 95 | if (stat.total_pages && booksByMd5[stat.book_md5]?.reference_pages) { 96 | return acc + (1 / stat.total_pages) * booksByMd5[stat.book_md5].reference_pages!; 97 | } else { 98 | return acc + 1; 99 | } 100 | }, 0) ?? 0 101 | ); 102 | 103 | return pagesPerDay; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /apps/server/src/upload/upload-service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Book, 3 | BookDevice, 4 | Device, 5 | KoReaderBook, 6 | KoReaderPageStat, 7 | PageStat, 8 | } from '@koinsight/common/types'; 9 | import Database, { Database as DatabaseType } from 'better-sqlite3'; 10 | import { db } from '../knex'; 11 | 12 | export class UploadService { 13 | private static UNKNOWN_DEVICE_ID = 'manual-upload'; 14 | 15 | static openStatisticsDbFile(uploadedFilePath: string) { 16 | const db = new Database(uploadedFilePath, { readonly: true }); 17 | const bookIds = db.prepare('SELECT id FROM book').all(); 18 | 19 | if (!bookIds.length) { 20 | throw new Error('No books found in the uploaded file'); 21 | } 22 | 23 | return db; 24 | } 25 | 26 | static extractDataFromStatisticsDb(db: DatabaseType) { 27 | const newBooks = db.prepare('SELECT * FROM book').all() as KoReaderBook[]; 28 | const dbPageStats = db.prepare('SELECT * FROM page_stat_data').all() as KoReaderPageStat[]; 29 | 30 | const newPageStats: PageStat[] = dbPageStats.map(({ id_book, ...stat }) => ({ 31 | book_md5: newBooks.find((book) => book.id === id_book)!.md5, 32 | device_id: this.UNKNOWN_DEVICE_ID, 33 | ...stat, 34 | })); 35 | 36 | return { newBooks, newPageStats }; 37 | } 38 | 39 | static uploadStatisticData(booksToImport: KoReaderBook[], newPageStats: PageStat[]) { 40 | return db.transaction(async (trx) => { 41 | // Insert books 42 | const newBooks: Partial[] = booksToImport.map((book) => ({ 43 | id: book.id, 44 | md5: book.md5, 45 | title: book.title, 46 | authors: book.authors, 47 | series: book.series, 48 | language: book.language, 49 | })); 50 | 51 | await Promise.all( 52 | newBooks.map(({ id, ...book }) => trx('book').insert(book).onConflict('md5').ignore()) 53 | ); 54 | 55 | const hasUnknownDevices = 56 | newPageStats.length > 0 && newPageStats[0].device_id === this.UNKNOWN_DEVICE_ID; 57 | 58 | if (hasUnknownDevices) { 59 | let unknownDevice = await trx('device') 60 | .where({ id: this.UNKNOWN_DEVICE_ID }) 61 | .first(); 62 | 63 | if (!unknownDevice) { 64 | console.log('Creating unknown device'); 65 | await trx('device').insert({ 66 | id: this.UNKNOWN_DEVICE_ID, 67 | model: 'Manual Upload', 68 | }); 69 | } 70 | } 71 | 72 | const newBookDevices: Omit[] = booksToImport.map((book) => ({ 73 | device_id: newPageStats[0].device_id, 74 | book_md5: book.md5, 75 | last_open: book.last_open, 76 | pages: book.pages, 77 | notes: book.notes, 78 | highlights: book.highlights, 79 | total_read_pages: book.total_read_pages, 80 | total_read_time: book.total_read_time, 81 | })); 82 | 83 | await Promise.all( 84 | newBookDevices.map((bookDevice) => 85 | trx('book_device') 86 | .insert(bookDevice) 87 | .onConflict(['book_md5', 'device_id']) 88 | .merge([ 89 | 'last_open', 90 | 'pages', 91 | 'notes', 92 | 'highlights', 93 | 'total_read_time', 94 | 'total_read_pages', 95 | ]) 96 | ) 97 | ); 98 | 99 | // Insert page stats 100 | await Promise.all( 101 | newPageStats.map((pageStat) => 102 | trx('page_stat') 103 | .insert(pageStat) 104 | .onConflict(['device_id', 'book_md5', 'page', 'start_time']) 105 | .merge(['duration', 'total_pages']) 106 | ) 107 | ); 108 | 109 | await trx.commit(); 110 | }); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /apps/server/src/books/books-service.ts: -------------------------------------------------------------------------------- 1 | import { Book, BookDevice, BookWithData, PageStat } from '@koinsight/common/types'; 2 | import { startOfDay } from 'date-fns'; 3 | import { GenreRepository } from '../genres/genre-repository'; 4 | import { StatsRepository } from '../stats/stats-repository'; 5 | import { normalizeRanges, Range, totalRangeLength } from '../utils/ranges'; 6 | import { BooksRepository } from './books-repository'; 7 | 8 | export class BooksService { 9 | static getTotalPages(book: Book, bookDevices: BookDevice[]): number { 10 | return book.reference_pages || Math.max(...bookDevices.map((device) => device.pages || 0)); 11 | } 12 | 13 | static getTotalReadTime(bookDevices: BookDevice[]): number { 14 | return bookDevices.reduce((acc, device) => acc + device.total_read_time, 0); 15 | } 16 | 17 | static getStartedReading(stats: PageStat[]): number { 18 | return stats.reduce((acc, stat) => Math.min(acc, stat.start_time), Infinity); 19 | } 20 | 21 | static getLastOpen(bookDevices: BookDevice[]): number { 22 | return bookDevices.reduce((acc, device) => Math.max(acc, device.last_open), 0); 23 | } 24 | 25 | static getReadPerDay(stats: PageStat[]): Record { 26 | return stats.reduce( 27 | (acc, stat) => { 28 | const day = startOfDay(stat.start_time).getTime(); 29 | acc[day] = (acc[day] || 0) + stat.duration; 30 | 31 | return acc; 32 | }, 33 | {} as Record 34 | ); 35 | } 36 | 37 | static getUniqueReadPages(book: Book, stats: PageStat[]): number { 38 | const readPages: Range[] = []; 39 | 40 | stats.forEach((stat) => { 41 | if (book.reference_pages) { 42 | const startRefPage = (Math.max(stat.page - 1, 0) * book.reference_pages) / stat.total_pages; 43 | const endRefPage = (stat.page * book.reference_pages) / stat.total_pages; 44 | 45 | const range = [startRefPage, endRefPage] as Range; 46 | 47 | readPages.push(range); 48 | } else { 49 | readPages.push([Math.max(stat.page - 1, 0), stat.page]); 50 | } 51 | }); 52 | 53 | return Math.round(totalRangeLength(normalizeRanges(readPages))); 54 | } 55 | 56 | static getTotalReadPages(book: Book, stats: PageStat[]): number { 57 | return Math.round( 58 | stats.reduce((acc, stat) => { 59 | if (book.reference_pages) { 60 | return acc + (1 / stat.total_pages) * book.reference_pages; 61 | } else { 62 | return acc + 1; 63 | } 64 | }, 0) 65 | ); 66 | } 67 | 68 | static async withData(book: Book): Promise { 69 | const stats = await StatsRepository.getByBookMD5(book.md5); 70 | const bookDevices = await BooksRepository.getBookDevices(book.md5); 71 | const genres = await GenreRepository.getByBookMd5(book.md5); 72 | 73 | const total_pages = this.getTotalPages(book, bookDevices); 74 | const total_read_time = this.getTotalReadTime(bookDevices); 75 | const started_reading = this.getStartedReading(stats); 76 | const last_open = this.getLastOpen(bookDevices); 77 | const read_per_day = this.getReadPerDay(stats); 78 | const total_read_pages = this.getTotalReadPages(book, stats); 79 | const unique_read_pages = this.getUniqueReadPages(book, stats); 80 | 81 | const response: BookWithData = { 82 | ...book, 83 | stats, 84 | device_data: bookDevices, 85 | started_reading, 86 | read_per_day, 87 | total_read_time, 88 | total_read_pages, 89 | unique_read_pages, 90 | total_pages, 91 | last_open, 92 | genres, 93 | notes: bookDevices.reduce((acc, device) => acc + device.notes, 0), 94 | highlights: bookDevices.reduce((acc, device) => acc + device.highlights, 0), 95 | }; 96 | 97 | return response; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /apps/server/src/devices/device-repository.test.ts: -------------------------------------------------------------------------------- 1 | import { createDevice, fakeDevice } from '../db/factories/device-factory'; 2 | import { db } from '../knex'; 3 | import { DeviceRepository } from './device-repository'; 4 | 5 | describe(DeviceRepository, () => { 6 | describe(DeviceRepository.getAll, () => { 7 | it('returns an empty array when no devices exist', async () => { 8 | const devices = await DeviceRepository.getAll(); 9 | expect(devices).toHaveLength(0); 10 | }); 11 | 12 | it('returns all devices', async () => { 13 | await createDevice(db, { model: 'Test Device' }); 14 | await createDevice(db, { model: 'Test Device 2' }); 15 | 16 | const devices = await DeviceRepository.getAll(); 17 | 18 | expect(devices).toHaveLength(2); 19 | expect(devices[0].model).toBe('Test Device'); 20 | expect(devices[1].model).toBe('Test Device 2'); 21 | }); 22 | }); 23 | 24 | describe(DeviceRepository.getById, () => { 25 | it('gets a device by id', async () => { 26 | const device = await createDevice(db, { model: 'Test Device' }); 27 | const foundDevice = await DeviceRepository.getById(device.id); 28 | 29 | expect(foundDevice?.model).toEqual(device.model); 30 | }); 31 | 32 | it('returns undefined for non-existent device', async () => { 33 | const foundDevice = await DeviceRepository.getById('999'); 34 | expect(foundDevice).toBeUndefined(); 35 | }); 36 | }); 37 | 38 | describe(DeviceRepository.getByModel, () => { 39 | it('gets a device by model', async () => { 40 | const device = await createDevice(db, { model: 'Test Device' }); 41 | const foundDevice = await DeviceRepository.getByModel(device.model); 42 | 43 | expect(foundDevice?.model).toEqual(device.model); 44 | }); 45 | 46 | it('returns undefined for non-existent device', async () => { 47 | const foundDevice = await DeviceRepository.getById('999'); 48 | expect(foundDevice).toBeUndefined(); 49 | }); 50 | }); 51 | 52 | describe(DeviceRepository.insertIfNotExists, () => { 53 | it('inserts a new device if it does not exist', async () => { 54 | let foundDevice = await DeviceRepository.getByModel('Test Device'); 55 | expect(foundDevice).toBeUndefined(); 56 | 57 | const device = fakeDevice({ model: 'Test Device' }); 58 | await DeviceRepository.insertIfNotExists(device); 59 | 60 | foundDevice = await DeviceRepository.getByModel('Test Device'); 61 | expect(foundDevice?.model).toEqual(device.model); 62 | }); 63 | 64 | it('does not insert a device if it already exists', async () => { 65 | const device = await createDevice(db, { model: 'Test Device' }); 66 | let foundDevice = await DeviceRepository.getByModel('Test Device'); 67 | expect(foundDevice?.model).toEqual(device.model); 68 | expect(await DeviceRepository.getAll()).toHaveLength(1); 69 | 70 | await DeviceRepository.insertIfNotExists(device); 71 | expect(await DeviceRepository.getAll()).toHaveLength(1); 72 | }); 73 | }); 74 | 75 | describe(DeviceRepository.findOrCreateByModel, () => { 76 | it('creates a device by model', async () => { 77 | expect(await DeviceRepository.getAll()).toHaveLength(0); 78 | const foundDevice = await DeviceRepository.findOrCreateByModel('Test Device 1'); 79 | 80 | expect(foundDevice?.model).toEqual('Test Device 1'); 81 | expect(await DeviceRepository.getAll()).toHaveLength(1); 82 | }); 83 | 84 | it('returns an existing device by model', async () => { 85 | const device = await createDevice(db, { model: 'Test Device' }); 86 | expect(await DeviceRepository.getAll()).toHaveLength(1); 87 | const foundDevice = await DeviceRepository.findOrCreateByModel(device.model); 88 | 89 | expect(foundDevice?.model).toEqual(device.model); 90 | expect(await DeviceRepository.getAll()).toHaveLength(1); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /apps/web/src/components/calendar/calendar.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex } from '@mantine/core'; 2 | import { MonthPickerInput } from '@mantine/dates'; 3 | import { IconArrowLeft, IconArrowRight } from '@tabler/icons-react'; 4 | import clsx from 'clsx'; 5 | import { addDays } from 'date-fns/addDays'; 6 | import { addMonths } from 'date-fns/addMonths'; 7 | import { endOfMonth } from 'date-fns/endOfMonth'; 8 | import { endOfWeek } from 'date-fns/endOfWeek'; 9 | import { format } from 'date-fns/format'; 10 | import { isSameMonth } from 'date-fns/isSameMonth'; 11 | import { isToday } from 'date-fns/isToday'; 12 | import { startOfMonth } from 'date-fns/startOfMonth'; 13 | import { startOfWeek } from 'date-fns/startOfWeek'; 14 | import { subMonths } from 'date-fns/subMonths'; 15 | import { JSX, ReactNode, useEffect, useState } from 'react'; 16 | import { CalendarWeek } from './calendar-week'; 17 | 18 | import style from './calendar.module.css'; 19 | 20 | export type CalendarEvent = { 21 | date: Date; 22 | title?: string; 23 | data?: T; 24 | }; 25 | 26 | export type CalendarProps = { 27 | events: Record>; 28 | dayRenderer?: (data: T) => ReactNode; 29 | }; 30 | 31 | export function Calendar({ events, dayRenderer }: CalendarProps): JSX.Element { 32 | const [currentDate, setCurrentDate] = useState(new Date()); 33 | 34 | const startDate = startOfWeek(startOfMonth(currentDate), { 35 | locale: { options: { weekStartsOn: 1 } }, 36 | }); 37 | const endDate = endOfWeek(endOfMonth(currentDate), { locale: { options: { weekStartsOn: 1 } } }); 38 | const dates = []; 39 | 40 | let day = startDate; 41 | while (day <= endDate) { 42 | const isCurrentMonth = isSameMonth(day, currentDate); 43 | const isCurrentDay = isToday(day); 44 | const key = day.toISOString(); 45 | const event = events[key]; 46 | const dayNum = format(day, 'd'); 47 | 48 | dates.push( 49 |
57 |
{event ? {dayNum} : dayNum}
58 | {event && ( 59 | <> 60 |
61 | {event.title} 62 | {event?.data && dayRenderer?.(event.data!)} 63 |
64 | 65 | )} 66 |
67 | ); 68 | day = addDays(day, 1); 69 | } 70 | 71 | function bindShortcuts(e: KeyboardEvent) { 72 | switch (e.key) { 73 | case 'ArrowLeft': 74 | e.preventDefault(); 75 | setCurrentDate(subMonths(currentDate, 1)); 76 | break; 77 | case 'ArrowRight': 78 | e.preventDefault(); 79 | setCurrentDate(addMonths(currentDate, 1)); 80 | break; 81 | } 82 | } 83 | 84 | useEffect(() => { 85 | window.addEventListener('keydown', bindShortcuts); 86 | return () => { 87 | window.removeEventListener('keydown', bindShortcuts); 88 | }; 89 | }); 90 | 91 | return ( 92 |
93 |
94 | 95 | 103 | 104 | 107 | setCurrentDate(e!)} /> 108 | 109 | 117 |
118 | 119 |
{dates}
120 |
121 | ); 122 | } 123 | --------------------------------------------------------------------------------