├── docs ├── static │ ├── .nojekyll │ └── img │ │ ├── social-card.png │ │ ├── feature_section_01.png │ │ ├── feature_section_02.png │ │ └── feature_section_03.png ├── src │ ├── pages │ │ ├── markdown-page.md │ │ ├── index.module.css │ │ └── index.js │ ├── components │ │ └── HomepageFeatures │ │ │ ├── styles.module.css │ │ │ └── index.js │ └── css │ │ └── custom.css ├── .gitignore ├── docs │ ├── 04 - Features │ │ ├── Demo-mode.md │ │ └── Share-to-Mastodon.md │ ├── 05 - Administration │ │ └── Backup-and-restore.md │ ├── Getting started.md │ ├── 03 - Configuration │ │ ├── Email-verification.md │ │ └── Google-sign-in.md │ ├── 07 - API │ │ ├── create-task.api.mdx │ │ ├── get-list-of-files.api.mdx │ │ ├── get-profile-of-the-logged-in-user.api.mdx │ │ ├── delete-note.api.mdx │ │ ├── get-tasks.api.mdx │ │ ├── download-file.api.mdx │ │ ├── booklogr-api.info.mdx │ │ ├── get-profile-by-name.api.mdx │ │ ├── edit-profile.api.mdx │ │ ├── get-notes-from-book.api.mdx │ │ ├── remove-book-from-list.api.mdx │ │ ├── get-books-in-list.api.mdx │ │ ├── check-if-book-by-isbn-is-already-in-a-list.api.mdx │ │ ├── create-profile-for-logged-in-user.api.mdx │ │ ├── edit-book.api.mdx │ │ ├── edit-note.api.mdx │ │ ├── add-note-to-book.api.mdx │ │ └── add-book-to-list.api.mdx │ └── 06 - Developer │ │ └── Translation.md ├── README.md ├── sidebars.js └── package.json ├── api ├── README.md ├── utils.py ├── auth │ ├── user_route.py │ ├── decorators.py │ └── models.py ├── decorators.py ├── commands │ ├── tasks.py │ └── user.py ├── config.py ├── book_template_export.html ├── routes │ └── settings.py └── app.py ├── web ├── .flowbite-react │ ├── .gitignore │ └── config.json ├── .vscode │ └── extensions.json ├── public │ ├── social-card.png │ ├── fallback-cover.svg │ └── fallback-cover-light.svg ├── postcss.config.js ├── .env.production ├── .env.example ├── src │ ├── services │ │ ├── api.utils.jsx │ │ ├── auth-header.jsx │ │ ├── tasks.service.jsx │ │ ├── github.service.jsx │ │ ├── userSettings.service.jsx │ │ ├── notes.service.jsx │ │ ├── files.service.jsx │ │ ├── openlibrary.service.jsx │ │ ├── profile.service.jsx │ │ ├── books.service.jsx │ │ └── auth.service.jsx │ ├── GlobalRouter.jsx │ ├── pages │ │ ├── Library.jsx │ │ ├── Settings.jsx │ │ └── Verify.jsx │ ├── useLibraryReducer.jsx │ ├── useInterval.jsx │ ├── components │ │ ├── Library │ │ │ ├── PaneTabView.jsx │ │ │ └── EditBookModal.jsx │ │ ├── BookStatsCard.jsx │ │ ├── OpenLibraryButton.jsx │ │ ├── Data │ │ │ ├── DataTab.jsx │ │ │ ├── RequestData.jsx │ │ │ └── FileImport.jsx │ │ ├── NotesIcon.jsx │ │ ├── LanguageSwitcher.jsx │ │ ├── Footer.jsx │ │ ├── Navbar.jsx │ │ ├── RegionSwitcher.jsx │ │ ├── ESCIcon.jsx │ │ ├── RemoveBookModal.jsx │ │ ├── AccountTab.jsx │ │ ├── SortSelector.jsx │ │ ├── AddNoteModal.jsx │ │ ├── InterfaceTab.jsx │ │ ├── AddQuoteModal.jsx │ │ ├── UpdateReadingStatusView.jsx │ │ ├── EditionSelector.jsx │ │ ├── EditNoteModal.jsx │ │ └── GoogleLoginButton.jsx │ ├── index.css │ ├── toast │ │ ├── Container.jsx │ │ ├── useToast.jsx │ │ ├── Toast.jsx │ │ └── Context.jsx │ ├── AnimatedLayout.jsx │ ├── main.jsx │ ├── i18n.jsx │ ├── DateFormat.jsx │ └── App.jsx ├── nginx-custom.conf ├── .dockerignore ├── vite.config.js ├── .gitignore ├── inject-env.sh ├── README.md ├── Dockerfile ├── .eslintrc.cjs ├── index.html └── package.json ├── migrations ├── README ├── script.py.mako ├── versions │ ├── e60887c12d97_add_author_column.py │ ├── ae48d4b4f9d8_add_owner_id_column.py │ ├── 334c6f93b980_add_rating_column.py │ ├── c9e06189519a_add_quote_page_column_to_notes_table.py │ ├── a1a14989ad81_add_created_on_column_to_books_table.py │ ├── 82989182da4a_add_files_table.py │ ├── 5c9e5e91be8f_add_notes_table.py │ ├── ce3cee57bdd9_initial_migration.py │ ├── 9232f4adc495_add_tasks_table.py │ ├── 89b6827408dd_add_user_settings_table.py │ ├── 4f342e92ab1f_add_profiles_table.py │ └── 8f8a46fec66b_add_auth_tables.py ├── alembic.ini └── env.py ├── assets ├── library.png └── mastodon-enable-share.png ├── .gitignore ├── .github ├── release.yml └── workflows │ ├── deploy-docs.yml │ ├── build-docker-image.yml │ └── build-docker-image-web.yml ├── Dockerfile ├── entrypoint.sh ├── .env.example ├── docker-compose.yml ├── pyproject.toml ├── docker-compose.postgres.yml └── README.md /docs/static/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # minimal-reading-api -------------------------------------------------------------------------------- /web/.flowbite-react/.gitignore: -------------------------------------------------------------------------------- 1 | class-list.json 2 | pid -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Single-database configuration for Flask. 2 | -------------------------------------------------------------------------------- /assets/library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mozzo1000/booklogr/HEAD/assets/library.png -------------------------------------------------------------------------------- /web/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bradlc.vscode-tailwindcss" 4 | ] 5 | } -------------------------------------------------------------------------------- /web/public/social-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mozzo1000/booklogr/HEAD/web/public/social-card.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .env 3 | export_data 4 | auth_db_vol 5 | booklogr 6 | instance 7 | book.db 8 | .vscode -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {} 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /assets/mastodon-enable-share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mozzo1000/booklogr/HEAD/assets/mastodon-enable-share.png -------------------------------------------------------------------------------- /docs/static/img/social-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mozzo1000/booklogr/HEAD/docs/static/img/social-card.png -------------------------------------------------------------------------------- /web/.env.production: -------------------------------------------------------------------------------- 1 | VITE_API_ENDPOINT=BL_API_ENDPOINT 2 | VITE_GOOGLE_CLIENT_ID=BL_GOOGLE_ID 3 | VITE_DEMO_MODE=BL_DEMO_MODE -------------------------------------------------------------------------------- /docs/static/img/feature_section_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mozzo1000/booklogr/HEAD/docs/static/img/feature_section_01.png -------------------------------------------------------------------------------- /docs/static/img/feature_section_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mozzo1000/booklogr/HEAD/docs/static/img/feature_section_02.png -------------------------------------------------------------------------------- /docs/static/img/feature_section_03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mozzo1000/booklogr/HEAD/docs/static/img/feature_section_03.png -------------------------------------------------------------------------------- /web/.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_ENDPOINT="http://localhost:5000/" 2 | VITE_GOOGLE_CLIENT_ID=XXX.apps.googleusercontent.com 3 | VITE_DEMO_MODE=false -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: Added 4 | - title: Fixed 5 | - title: Changed 6 | - title: Removed 7 | -------------------------------------------------------------------------------- /web/src/services/api.utils.jsx: -------------------------------------------------------------------------------- 1 | export const getAPIUrl = (endpoint) => { 2 | return new URL(endpoint, import.meta.env.VITE_API_ENDPOINT).toString(); 3 | }; -------------------------------------------------------------------------------- /web/src/GlobalRouter.jsx: -------------------------------------------------------------------------------- 1 | 2 | import { Navigate } from "react-router-dom"; 3 | 4 | const globalRouter = { Navigate }; 5 | 6 | export default globalRouter; 7 | 8 | -------------------------------------------------------------------------------- /docs/src/pages/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown page example 3 | --- 4 | 5 | # Markdown page example 6 | 7 | You don't need React to write simple standalone pages. 8 | -------------------------------------------------------------------------------- /web/.flowbite-react/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/flowbite-react/schema.json", 3 | "components": [], 4 | "dark": true, 5 | "prefix": "", 6 | "path": "src/components", 7 | "tsx": true, 8 | "rsc": true 9 | } -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .featureImg { 9 | height: 200px; 10 | width: 200px; 11 | } 12 | -------------------------------------------------------------------------------- /web/nginx-custom.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | 5 | location / { 6 | root /usr/share/nginx/html; 7 | index index.html index.htm; 8 | try_files $uri $uri/ /index.html =404; 9 | } 10 | } -------------------------------------------------------------------------------- /web/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .dockerignore 4 | 5 | # Build dependencies 6 | dist 7 | build 8 | node_modules 9 | 10 | # Environment (contains sensitive data) 11 | .env 12 | 13 | # Files not required for production 14 | .editorconfig 15 | Dockerfile 16 | README.md -------------------------------------------------------------------------------- /web/src/services/auth-header.jsx: -------------------------------------------------------------------------------- 1 | export default function authHeader() { 2 | const user = JSON.parse(localStorage.getItem('auth_user')); 3 | 4 | if (user && user.access_token) { 5 | return { Authorization: 'Bearer ' + user.access_token }; 6 | } else { 7 | return {}; 8 | } 9 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12.4 2 | WORKDIR /app 3 | 4 | COPY README.md pyproject.toml entrypoint.sh ./ 5 | COPY migrations ./migrations 6 | COPY api ./api 7 | 8 | RUN pip install . 9 | ENV FLASK_APP api.app 10 | ENV FLASK_ENV production 11 | RUN chmod +x entrypoint.sh 12 | 13 | EXPOSE 5000 14 | ENTRYPOINT ["/app/entrypoint.sh"] -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /web/src/services/tasks.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import authHeader from "./auth-header"; 3 | import { getAPIUrl } from "./api.utils"; 4 | 5 | const create = (type, data) => { 6 | return axios.post(getAPIUrl("v1/tasks"), {type, data}, { headers: authHeader() }); 7 | }; 8 | 9 | export default { 10 | create, 11 | }; -------------------------------------------------------------------------------- /web/vite.config.js: -------------------------------------------------------------------------------- 1 | import tailwindcss from "@tailwindcss/vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import { defineConfig } from "vite"; 4 | import flowbiteReact from "flowbite-react/plugin/vite"; 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), tailwindcss(), flowbiteReact()], 9 | }); 10 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Running database migration.." 4 | python -m flask db upgrade 5 | 6 | if [ -n "${AUTH_DEFAULT_USER+1}" ]; then 7 | echo "\$AUTH_DEFAULT_USER is set" 8 | python -m flask user create "$AUTH_DEFAULT_USER" "$AUTH_DEFAULT_USER" user --password "$AUTH_DEFAULT_PASSWORD" 9 | fi 10 | 11 | echo "Starting gunicorn.." 12 | exec gunicorn -w 4 -b :5000 api.app:app -------------------------------------------------------------------------------- /api/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | def validate_date_string(date_str): 4 | formats = [ 5 | "%Y-%m-%d", 6 | "%Y-%m-%d %H:%M:%S", 7 | "%Y-%m-%d %H:%M:%S.%f" 8 | ] 9 | 10 | for fmt in formats: 11 | try: 12 | return datetime.strptime(date_str, fmt) 13 | except ValueError: 14 | continue 15 | return None -------------------------------------------------------------------------------- /web/src/services/github.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const API_URL = "https://api.github.com/"; 4 | 5 | const getLatestRelease = () => { 6 | return axios.get(API_URL + "repos/mozzo1000/booklogr/releases/latest", { headers: {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}}) 7 | } 8 | 9 | 10 | export default { 11 | getLatestRelease 12 | }; -------------------------------------------------------------------------------- /web/inject-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | for i in $(env | grep BL_) 3 | do 4 | key=$(echo $i | cut -d '=' -f 1) 5 | value=$(echo $i | cut -d '=' -f 2-) 6 | echo $key=$value 7 | # sed All files 8 | # find /usr/share/nginx/html -type f -exec sed -i "s|${key}|${value}|g" '{}' + 9 | 10 | # sed JS and CSS only 11 | find /usr/share/nginx/html -type f \( -name '*.js' -o -name '*.css' \) -exec sed -i "s|${key}|${value}|g" '{}' + 12 | done -------------------------------------------------------------------------------- /web/src/services/userSettings.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import authHeader from "./auth-header"; 3 | import { getAPIUrl } from "./api.utils"; 4 | 5 | const get = () => { 6 | return axios.get(getAPIUrl("v1/settings"), { headers: authHeader() }); 7 | }; 8 | 9 | const edit = (data) => { 10 | return axios.patch(getAPIUrl("v1/settings"), data, { headers: authHeader() }); 11 | } 12 | 13 | export default { 14 | get, 15 | edit, 16 | }; -------------------------------------------------------------------------------- /web/src/services/notes.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import authHeader from "./auth-header"; 3 | import { getAPIUrl } from "./api.utils"; 4 | 5 | const edit = (id, data) => { 6 | return axios.patch(getAPIUrl(`v1/notes/${id}`), data, { headers: authHeader() }); 7 | }; 8 | 9 | const remove = (id) => { 10 | return axios.delete(getAPIUrl(`v1/notes/${id}`), { headers: authHeader() }); 11 | }; 12 | 13 | export default { 14 | edit, 15 | remove, 16 | }; -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # React + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | -------------------------------------------------------------------------------- /web/src/pages/Library.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import LibraryPane from '../components/Library/LibraryPane' 3 | import WelcomeModal from '../components/WelcomeModal' 4 | import AnimatedLayout from '../AnimatedLayout' 5 | 6 | function Library() { 7 | return ( 8 | 9 |
10 | 11 | 12 |
13 |
14 | ) 15 | } 16 | 17 | export default Library -------------------------------------------------------------------------------- /web/src/useLibraryReducer.jsx: -------------------------------------------------------------------------------- 1 | export const initialState = { 2 | books: null, 3 | }; 4 | 5 | export const actionTypes = { 6 | BOOKS: "BOOKS", 7 | }; 8 | 9 | const reducer = (state, action) => { 10 | switch (action.type) { 11 | case actionTypes.BOOKS: 12 | return { 13 | ...state, 14 | books: action.books, 15 | }; 16 | 17 | default: 18 | return state; 19 | } 20 | }; 21 | 22 | export default reducer; -------------------------------------------------------------------------------- /web/src/useInterval.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export function useInterval(callback, delay) { 4 | const callbackRef = useRef(); 5 | 6 | useEffect(() => { 7 | callbackRef.current = callback; 8 | }, [callback]); 9 | 10 | useEffect(() => { 11 | function tick() { 12 | callbackRef.current(); 13 | } 14 | if (delay !== null) { 15 | let id = setInterval(tick, delay); 16 | return () => clearInterval(id); 17 | } 18 | }, [callback, delay]); 19 | }; -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build Image 2 | FROM node:22-alpine AS build 3 | RUN apk add git 4 | WORKDIR /app 5 | 6 | COPY package*.json ./ 7 | RUN npm install --force 8 | COPY . . 9 | RUN npm run build 10 | 11 | # Stage 2, use the compiled app, ready for production with Nginx 12 | FROM nginx:1.21.6-alpine 13 | COPY --from=build /app/dist /usr/share/nginx/html 14 | COPY /nginx-custom.conf /etc/nginx/conf.d/default.conf 15 | COPY inject-env.sh /docker-entrypoint.d/inject-env.sh 16 | RUN chmod +x /docker-entrypoint.d/inject-env.sh -------------------------------------------------------------------------------- /web/src/components/Library/PaneTabView.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function PaneTabView({children, view, setView}) { 4 | return ( 5 | <> 6 | {view === "gallery" && 7 |
8 | {children} 9 |
10 | } 11 | 12 | {view === "list" && 13 |
14 | {children} 15 |
16 | } 17 | 18 | ) 19 | } 20 | 21 | export default PaneTabView -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @plugin 'flowbite-react/plugin/tailwindcss'; 3 | @source '../.flowbite-react/class-list.json'; 4 | @plugin "flowbite-typography"; 5 | 6 | /*@config '../tailwind.config.js';*/ 7 | 8 | @custom-variant dark (&:where(.dark, .dark *)); 9 | 10 | @layer base { 11 | html, body { 12 | font-family: "Libre Baskerville", system-ui, sans-serif; 13 | font-weight: 400; 14 | @apply bg-[#FDFCF7] 15 | } 16 | 17 | .dark html, .dark body { 18 | @apply bg-[#121212] 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /web/src/toast/Container.jsx: -------------------------------------------------------------------------------- 1 | import Toast from "./Toast"; 2 | import { useToastStateContext } from "./Context"; 3 | 4 | export default function ToastContainer() { 5 | const { toasts } = useToastStateContext(); 6 | 7 | return ( 8 |
9 |
10 | {toasts && 11 | toasts.map((toast) => )} 12 |
13 |
14 | ); 15 | } -------------------------------------------------------------------------------- /web/src/AnimatedLayout.jsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | 3 | const variants = { 4 | hidden: { opacity: 0 }, 5 | enter: { opacity: 1 }, 6 | exit: { opacity: 0 } 7 | } 8 | 9 | const AnimatedLayout = ({ children }) => { 10 | return ( 11 | 18 | {children} 19 | 20 | ); 21 | }; 22 | 23 | export default AnimatedLayout; -------------------------------------------------------------------------------- /web/src/components/BookStatsCard.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function BookStatsCard(props) { 4 | return ( 5 |
6 |
7 | {props.icon} 8 |
9 |
10 |

{props.number}

11 |
12 |
13 |

{props.text}

14 |
15 |
16 | ) 17 | } 18 | 19 | export default BookStatsCard -------------------------------------------------------------------------------- /web/src/components/OpenLibraryButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Spinner, Button, TextInput } from "flowbite-react"; 3 | import { Link } from "react-router-dom"; 4 | import { HiOutlineBuildingLibrary } from "react-icons/hi2"; 5 | 6 | function OpenLibraryButton(props) { 7 | return ( 8 | 12 | ) 13 | } 14 | 15 | export default OpenLibraryButton -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | settings: { react: { version: '18.2' } }, 13 | plugins: ['react-refresh'], 14 | rules: { 15 | 'react/jsx-no-target-blank': 'off', 16 | 'react-refresh/only-export-components': [ 17 | 'warn', 18 | { allowConstantExport: true }, 19 | ], 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /web/src/toast/useToast.jsx: -------------------------------------------------------------------------------- 1 | import { useToastDispatchContext } from "./Context"; 2 | 3 | export function useToast(delay) { 4 | const dispatch = useToastDispatchContext(); 5 | 6 | function toast(type, message) { 7 | const id = Math.random().toString(36).substr(2, 9); 8 | dispatch({ 9 | type: "ADD_TOAST", 10 | toast: { 11 | type, 12 | message, 13 | id, 14 | }, 15 | }); 16 | 17 | setTimeout(() => { 18 | dispatch({ type: "DELETE_TOAST", id }); 19 | }, delay); 20 | } 21 | 22 | return toast; 23 | } 24 | 25 | export default useToast; -------------------------------------------------------------------------------- /web/src/services/files.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import authHeader from "./auth-header"; 3 | import { getAPIUrl } from "./api.utils"; 4 | 5 | const get = (filename) => { 6 | return axios.get(getAPIUrl(`v1/files/${filename}`), { responseType: "blob", headers: authHeader() }); 7 | }; 8 | 9 | const getAll = () => { 10 | return axios.get(getAPIUrl("v1/files"), { headers: authHeader() }); 11 | }; 12 | 13 | const upload = (formData) => { 14 | return axios.post(getAPIUrl("v1/files"), formData, { headers: Object.assign({}, authHeader(), {"Content-Type": "multipart/form-data"}) }); 15 | }; 16 | 17 | 18 | export default { 19 | get, 20 | getAll, 21 | upload, 22 | }; -------------------------------------------------------------------------------- /web/src/services/openlibrary.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const API_URL = "https://openlibrary.org"; 4 | 5 | const get = (id) => { 6 | return axios.get(API_URL + "/isbn/" + id + ".json") 7 | }; 8 | 9 | const getWorks = (id) => { 10 | return axios.get(API_URL + id + ".json", { }) 11 | } 12 | 13 | const getEditions = (work_id, limit=10, offset=0) => { 14 | return axios.get(API_URL + work_id + "/editions.json?limit=" + limit + "&offset=" + offset) 15 | } 16 | 17 | const getAuthor = (author_id) => { 18 | return axios.get(API_URL + author_id + ".json") 19 | } 20 | 21 | export default { 22 | get, 23 | getWorks, 24 | getEditions, 25 | getAuthor, 26 | }; -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgresql://admin:password@booklogr-db/booklogr 2 | AUTH_SECRET_KEY=this-really-needs-to-be-changed 3 | AUTH_ALLOW_REGISTRATION=True 4 | AUTH_REQUIRE_VERIFICATION=False 5 | GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com 6 | GOOGLE_CLIENT_SECRET=xxx 7 | EXPORT_FOLDER="export_data" 8 | 9 | # UNCOMMENT TO ADD A USER AT STARTUP, REQUIRED FOR DEMO MODE 10 | #AUTH_DEFAULT_USER=demo@booklogr.app 11 | #AUTH_DEFAULT_PASSWORD=demo 12 | 13 | # MAIL SETTINGS 14 | MAIL_SERVER=smtp.example.com 15 | MAIL_USERNAME=username 16 | MAIL_PASSWORD=password 17 | MAIL_SENDER=noreply@example.com 18 | 19 | # docker-compose 20 | POSTGRES_USER=admin 21 | POSTGRES_PASSWORD=password 22 | POSTGRES_DB=booklogr -------------------------------------------------------------------------------- /web/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.jsx' 4 | import './index.css' 5 | import { BrowserRouter } from "react-router-dom"; 6 | import { ToastProvider } from './toast/Context.jsx'; 7 | import { GoogleOAuthProvider } from '@react-oauth/google'; 8 | import './i18n'; 9 | 10 | ReactDOM.createRoot(document.getElementById('root')).render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | , 20 | ) 21 | -------------------------------------------------------------------------------- /web/src/i18n.jsx: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import { initReactI18next } from "react-i18next"; 3 | import LanguageDetector from "i18next-browser-languagedetector"; 4 | import en from "./locales/en/en.json"; 5 | import sv from "./locales/sv/sv.json"; 6 | import ar from "./locales/ar/ar.json"; 7 | import cn from "./locales/zh-CN/zh-CN.json"; 8 | 9 | const resources = { 10 | en: { 11 | translation: en, 12 | }, 13 | sv: { 14 | translation: sv, 15 | }, 16 | ar: { 17 | translation: ar, 18 | }, 19 | cn: { 20 | translation: cn, 21 | }, 22 | }; 23 | 24 | i18n.use(LanguageDetector).use(initReactI18next).init({ 25 | resources, 26 | fallbackLng: "en", 27 | debug: true, 28 | }); 29 | 30 | export default i18n; 31 | -------------------------------------------------------------------------------- /web/src/services/profile.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import authHeader from "./auth-header"; 3 | import { getAPIUrl } from "./api.utils"; 4 | 5 | const create = (data) => { 6 | return axios.post(getAPIUrl("v1/profiles"), data, { headers: authHeader() }) 7 | }; 8 | 9 | const get_by_display_name = (display_name) => { 10 | return axios.get(getAPIUrl(`v1/profiles/${display_name}`)) 11 | } 12 | 13 | const get = () => { 14 | return axios.get(getAPIUrl("v1/profiles"), { headers: authHeader() }); 15 | }; 16 | 17 | const edit = (data) => { 18 | return axios.patch(getAPIUrl("v1/profiles"), data, { headers: authHeader() }); 19 | } 20 | 21 | export default { 22 | create, 23 | get, 24 | get_by_display_name, 25 | edit, 26 | }; -------------------------------------------------------------------------------- /docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | background-color: #fff; 12 | } 13 | 14 | [data-theme='dark'] { 15 | .heroBanner { 16 | padding: 4rem 0; 17 | text-align: center; 18 | position: relative; 19 | overflow: hidden; 20 | background-color: #20201D; 21 | } 22 | 23 | } 24 | 25 | @media screen and (max-width: 996px) { 26 | .heroBanner { 27 | padding: 2rem; 28 | } 29 | } 30 | 31 | .buttons { 32 | display: flex; 33 | align-items: center; 34 | justify-content: center; 35 | gap: 1rem; 36 | padding-top: 2rem; 37 | } 38 | -------------------------------------------------------------------------------- /api/auth/user_route.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify 2 | from api.auth.models import User, UserSchema 3 | from api.auth.decorators import require_role 4 | from flask_jwt_extended import jwt_required, get_jwt_identity 5 | 6 | user_endpoint = Blueprint('user_endpoint', __name__) 7 | 8 | @user_endpoint.route("/v1/users/me", methods=["GET"]) 9 | @jwt_required() 10 | def get_logged_in_user(): 11 | user_schema = UserSchema(many=False) 12 | user = User.query.filter_by(email=get_jwt_identity()).first() 13 | return jsonify(user_schema.dump(user)) 14 | 15 | 16 | @user_endpoint.route('/v1/users', methods=["GET"]) 17 | @require_role("internal_admin") 18 | def get_all_users(): 19 | user_schema = UserSchema(many=True) 20 | users = User.query.all() 21 | return jsonify(user_schema.dump(users)) -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # docker-compose.yml 2 | services: 3 | booklogr-api: 4 | container_name: "booklogr-api" 5 | image: mozzo/booklogr:v1.6.0 6 | environment: 7 | - DATABASE_URL=sqlite:///books.db 8 | - AUTH_SECRET_KEY=this-really-needs-to-be-changed 9 | ports: 10 | - 5000:5000 11 | volumes: 12 | - ./data:/app/instance 13 | 14 | booklogr-web: 15 | container_name: "booklogr-web" 16 | image: mozzo/booklogr-web:v1.6.0 17 | environment: 18 | - BL_API_ENDPOINT=http://localhost:5000/ # CHANGE THIS TO POINT TO THE EXTERNAL ADRESS THE USER CAN ACCESS 19 | - BL_GOOGLE_ID= # Leave empty to disable google login. To enable, write in your Google Client ID. 20 | - BL_DEMO_MODE=false 21 | ports: 22 | - 5150:80 -------------------------------------------------------------------------------- /web/src/components/Data/DataTab.jsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import FileList from './FileList' 3 | import RequestData from './RequestData' 4 | import FileImport from './FileImport'; 5 | 6 | function DataTab() { 7 | const [forceRefresh, setForceRefresh] = useState(); 8 | return ( 9 |
10 |
11 |
12 | setForceRefresh(true)}/> 13 |
14 | 15 |
16 |
17 | setForceRefresh(false)}/> 18 |
19 |
20 | 21 | 22 |
23 | ) 24 | } 25 | 26 | export default DataTab -------------------------------------------------------------------------------- /docs/docs/04 - Features/Demo-mode.md: -------------------------------------------------------------------------------- 1 | # Demo Mode 2 | BookLogr includes a demo mode feature for showcasing the applications functionality. This mode is used for the official demo site at https://demo.booklogr.app 3 | 4 | To try the official demo website, use the following login credentials: 5 | - **Username**: `demo@booklogr.app` 6 | - **Password**: `demo` 7 | 8 | ## Disabled features in demo mode 9 | The following features are disabled when in demo mode, 10 | 11 | - Account registration 12 | - Users cannot create new accounts. Everyone uses the shared demo account. 13 | - Google authentication 14 | - The Google sign-in option is removed from the login interface. 15 | 16 | ## Enabling Demo Mode 17 | If you want to enable demo mode for your own server, set the following environment variable for the `booklogr-web` service: 18 | 19 | ```env 20 | BL_DEMO_MODE=true 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/docs/05 - Administration/Backup-and-restore.md: -------------------------------------------------------------------------------- 1 | # Backup and restore 2 | ## SQLite 3 | SQLite is a file that is stored where you point your `DATABASE_URL` env variable to. If you want to backup you simply copy the file to another folder. 4 | 5 | ## PostgreSQL 6 | ### Backup database 7 | ``` 8 | sudo docker exec -t CONTAINER_ID pg_dumpall -c -U POSTGRES_USER > dump_`date +%Y-%m-%d"_"%H_%M_%S`.sql 9 | ``` 10 | 11 | ### Restore 12 | :::info 13 | The database needs to be empty in order to be able to restore from backup. Stop all containers and delete the volume from the postgres service. Afterwords, only start up the postgres database. E.g `docker compose up booklogr-db -d` 14 | ::: 15 | 16 | ``` 17 | cat dump_2025-01-02_16_06_47.sql | sudo docker exec -i CONTAINER_ID psql -U POSTGRES_USER -d booklogr 18 | ``` 19 | Lastly, start all other containers, `docker compose up -d` 20 | 21 | 22 | -------------------------------------------------------------------------------- /docs/docs/Getting started.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Getting started 6 | 7 | ## Prerequisites 8 | * Linux server (tested with Ubuntu 24.04) 9 | * [Docker](https://www.docker.com) 10 | 11 | ## Steps to set up the server 12 | 13 | ### 1. Create directory 14 | Create a directory to store `docker-compose.yml` file in and move to that directory. 15 | ```sh 16 | mkdir ./booklogr 17 | cd ./booklogr 18 | ``` 19 | 20 | ### 2. Download docker-compose file 21 | Download `docker-compose.yml` file from the [repository](https://github.com/Mozzo1000/booklogr) 22 | ```sh 23 | curl --output docker-compose.yml "https://raw.githubusercontent.com/Mozzo1000/booklogr/refs/heads/main/docker-compose.yml" 24 | ``` 25 | 26 | ### 3. Start the containers 27 | Start all containers 28 | ```sh 29 | docker compose up -d 30 | ``` 31 | 32 | 🎉 Booklogr should now be available on http://localhost:5150 -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "booklogr-api" 3 | version = "1.6.0" 4 | description = "API for Booklogr" 5 | authors = ["Andreas Backström "] 6 | license = "Apache-2.0" 7 | readme = "README.md" 8 | packages = [{include = "api"}] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.10" 12 | Flask = "3.1.1" 13 | python-dotenv = "1.1.0" 14 | Flask-JWT-Extended = "4.7.1" 15 | flask-cors = "6.0.1" 16 | psycopg2 = "2.9.10" 17 | flask-marshmallow = "1.3.0" 18 | flask-sqlalchemy = "3.1.1" 19 | marshmallow-sqlalchemy = "1.4.2" 20 | Flask-Migrate = "4.1.0" 21 | gunicorn = "23.0.0" 22 | flasgger = "0.9.7.1" 23 | requests = "2.32.4" 24 | mastodon-py = "2.0.1" 25 | marshmallow = "4.0.0" 26 | flask-mail = "^0.10.0" 27 | 28 | [tool.poetry.group.dev.dependencies] 29 | pytest = "8.4.1" 30 | 31 | [build-system] 32 | requires = ["poetry-core"] 33 | build-backend = "poetry.core.masonry.api" -------------------------------------------------------------------------------- /api/decorators.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify, request 2 | from functools import wraps 3 | 4 | def required_params(*args): 5 | """Decorator factory to check request data for POST requests and return 6 | an error if required parameters are missing.""" 7 | required = list(args) 8 | 9 | def decorator(fn): 10 | """Decorator that checks for the required parameters""" 11 | 12 | @wraps(fn) 13 | def wrapper(*args, **kwargs): 14 | missing = [r for r in required if r not in request.get_json()] 15 | if missing: 16 | response = { 17 | "status": "error", 18 | "message": "Request JSON is missing some required params", 19 | "missing": missing 20 | } 21 | return jsonify(response), 400 22 | return fn(*args, **kwargs) 23 | return wrapper 24 | return decorator -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | yarn 9 | ``` 10 | 11 | ## Local Development 12 | 13 | ```bash 14 | yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ## Build 20 | 21 | ```bash 22 | yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ## Deployment 28 | 29 | Using SSH: 30 | 31 | ```bash 32 | USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ```bash 38 | GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /migrations/versions/e60887c12d97_add_author_column.py: -------------------------------------------------------------------------------- 1 | """Add author column 2 | 3 | Revision ID: e60887c12d97 4 | Revises: ce3cee57bdd9 5 | Create Date: 2024-03-29 15:18:50.118628 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'e60887c12d97' 14 | down_revision = 'ce3cee57bdd9' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('books', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('author', sa.String(), nullable=True)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('books', schema=None) as batch_op: 30 | batch_op.drop_column('author') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/ae48d4b4f9d8_add_owner_id_column.py: -------------------------------------------------------------------------------- 1 | """Add owner_id column 2 | 3 | Revision ID: ae48d4b4f9d8 4 | Revises: e60887c12d97 5 | Create Date: 2024-06-30 16:43:58.045264 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ae48d4b4f9d8' 14 | down_revision = 'e60887c12d97' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('books', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('owner_id', sa.Integer(), nullable=True)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('books', schema=None) as batch_op: 30 | batch_op.drop_column('owner_id') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/334c6f93b980_add_rating_column.py: -------------------------------------------------------------------------------- 1 | """Add rating column 2 | 3 | Revision ID: 334c6f93b980 4 | Revises: 4f342e92ab1f 5 | Create Date: 2024-07-15 17:46:55.556749 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '334c6f93b980' 14 | down_revision = '4f342e92ab1f' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('books', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('rating', sa.Numeric(precision=3, scale=2), nullable=True)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('books', schema=None) as batch_op: 30 | batch_op.drop_column('rating') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | BookLogr 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /web/src/DateFormat.jsx: -------------------------------------------------------------------------------- 1 | export const formatDate = (date = {}) => { 2 | const displayLocale = localStorage.getItem("region") || navigator.language || "en-GB"; 3 | const defaultOptions = { 4 | year: 'numeric', 5 | month: 'long', 6 | day: 'numeric', 7 | }; 8 | 9 | return new Intl.DateTimeFormat(displayLocale, defaultOptions).format(date); 10 | }; 11 | 12 | export const formatTime = (dateTime) => { 13 | const displayLocale = localStorage.getItem("region") || navigator.language || "en-GB"; 14 | const defaultOptions = { 15 | hour: 'numeric', 16 | minute: '2-digit', 17 | hour12: localStorage.getItem("time_format_24h") === "true" ? false: true, 18 | timeZone: localStorage.getItem("timezone") || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", 19 | }; 20 | 21 | return new Intl.DateTimeFormat(displayLocale, defaultOptions).format(dateTime); 22 | }; 23 | 24 | export const formatDateTime = (date) => { 25 | return `${formatDate(date)} ${formatTime(date)}`; 26 | }; -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) 4 | 5 | /** 6 | * Creating a sidebar enables you to: 7 | - create an ordered group of docs 8 | - render a sidebar for each doc of that group 9 | - provide next/previous navigation 10 | 11 | The sidebars can be generated from the filesystem, or explicitly defined here. 12 | 13 | Create as many sidebars as you want. 14 | 15 | @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} 16 | */ 17 | const sidebars = { 18 | // By default, Docusaurus generates a sidebar from the docs folder structure 19 | docsIDSidebar: [{type: 'autogenerated', dirName: '.'}], 20 | 21 | // But you can create a sidebar manually 22 | /* 23 | tutorialSidebar: [ 24 | 'intro', 25 | 'hello', 26 | { 27 | type: 'category', 28 | label: 'Tutorial', 29 | items: ['tutorial-basics/create-a-document'], 30 | }, 31 | ], 32 | */ 33 | }; 34 | 35 | export default sidebars; 36 | -------------------------------------------------------------------------------- /migrations/versions/c9e06189519a_add_quote_page_column_to_notes_table.py: -------------------------------------------------------------------------------- 1 | """Add quote_page column to notes table 2 | 3 | Revision ID: c9e06189519a 4 | Revises: 89b6827408dd 5 | Create Date: 2024-12-27 14:15:32.832448 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'c9e06189519a' 14 | down_revision = '89b6827408dd' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('notes', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('quote_page', sa.Integer(), nullable=True)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('notes', schema=None) as batch_op: 30 | batch_op.drop_column('quote_page') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /web/src/toast/Toast.jsx: -------------------------------------------------------------------------------- 1 | import { Alert } from "flowbite-react"; 2 | import { useToastDispatchContext } from "./Context"; 3 | import { IoMdInformationCircleOutline } from "react-icons/io"; 4 | import { MdErrorOutline } from "react-icons/md"; 5 | 6 | export default function Toast({ type, message, id }) { 7 | const dispatch = useToastDispatchContext(); 8 | return ( 9 | <> 10 | {type == "success" && ( 11 | { 12 | dispatch({ type: "DELETE_TOAST", id }); 13 | }}> 14 | {message} 15 | 16 | )} 17 | {type == "error" && ( 18 | { 19 | dispatch({ type: "DELETE_TOAST", id }); 20 | }}> 21 | {message} 22 | 23 | )} 24 | 25 | ); 26 | } -------------------------------------------------------------------------------- /migrations/versions/a1a14989ad81_add_created_on_column_to_books_table.py: -------------------------------------------------------------------------------- 1 | """Add created_on column to books table 2 | 3 | Revision ID: a1a14989ad81 4 | Revises: 8f8a46fec66b 5 | Create Date: 2025-07-19 19:44:37.593593 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'a1a14989ad81' 14 | down_revision = '8f8a46fec66b' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('books', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('created_on', sa.DateTime(), nullable=True)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('books', schema=None) as batch_op: 30 | batch_op.drop_column('created_on') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /web/src/components/NotesIcon.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState} from 'react' 2 | import { RiStickyNoteLine } from "react-icons/ri"; 3 | import NotesView from './NotesView'; 4 | import { Tooltip } from 'flowbite-react'; 5 | import { useTranslation, Trans } from 'react-i18next'; 6 | 7 | function NotesIcon({id, notes, allowEditing, overrideNotes}) { 8 | const [openNotesModal, setOpenNotesModal] = useState(); 9 | const { t } = useTranslation(); 10 | 11 | return ( 12 | <> 13 | 14 |
setOpenNotesModal(true)} className="flex flex-row gap-2 items-center hover:bg-gray-100 hover:cursor-pointer dark:text-white dark:hover:bg-gray-500"> 15 | 16 |

{notes}

17 |
18 |
19 | 20 | 21 | ) 22 | } 23 | 24 | export default NotesIcon -------------------------------------------------------------------------------- /api/auth/decorators.py: -------------------------------------------------------------------------------- 1 | from flask_jwt_extended import verify_jwt_in_request, get_jwt 2 | from flask import jsonify 3 | from functools import wraps 4 | 5 | def require_role(role): 6 | def wrapper(fn): 7 | @wraps(fn) 8 | def decorator(*args, **kwargs): 9 | verify_jwt_in_request() 10 | claims = get_jwt() 11 | if claims["role"] == role: 12 | return fn(*args, **kwargs) 13 | else: 14 | return jsonify({'error': 'Permission denied', 'message': 'You do not have the required permission.'}), 403 15 | return decorator 16 | return wrapper 17 | 18 | def disable_route(value="False"): 19 | def wrapper(fn): 20 | @wraps(fn) 21 | def decorator(*args, **kwargs): 22 | if value.lower() in ["true", "yes", "y"]: 23 | return fn(*args, **kwargs) 24 | else: 25 | return jsonify({'error': 'Route disabled', 'message': 'This route has been disabled by the administrator.'}), 403 26 | return decorator 27 | return wrapper -------------------------------------------------------------------------------- /migrations/versions/82989182da4a_add_files_table.py: -------------------------------------------------------------------------------- 1 | """Add files table 2 | 3 | Revision ID: 82989182da4a 4 | Revises: 9232f4adc495 5 | Create Date: 2024-07-26 19:30:06.488079 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '82989182da4a' 14 | down_revision = '9232f4adc495' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('files', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('filename', sa.String(), nullable=True), 24 | sa.Column('owner_id', sa.Integer(), nullable=True), 25 | sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), 26 | sa.PrimaryKeyConstraint('id') 27 | ) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade(): 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_table('files') 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic,flask_migrate 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [logger_flask_migrate] 38 | level = INFO 39 | handlers = 40 | qualname = flask_migrate 41 | 42 | [handler_console] 43 | class = StreamHandler 44 | args = (sys.stderr,) 45 | level = NOTSET 46 | formatter = generic 47 | 48 | [formatter_generic] 49 | format = %(levelname)-5.5s [%(name)s] %(message)s 50 | datefmt = %H:%M:%S 51 | -------------------------------------------------------------------------------- /api/commands/tasks.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | import click 3 | from getpass import getpass 4 | import sys 5 | import re 6 | from api.models import Tasks, db 7 | 8 | tasks_command = Blueprint('tasks_cli', __name__) 9 | 10 | @tasks_command.cli.command("clear") 11 | def clear_tasks(): 12 | print("[CLEAR QUEUED TASKS]") 13 | 14 | all_tasks = Tasks.query.filter(Tasks.status=="fresh", Tasks.worker==None) 15 | if len(all_tasks.all()) > 0: 16 | confirmation = input(f"Are you sure you want to clear {len(all_tasks.all())} tasks? (y/N)") 17 | 18 | if confirmation == "y": 19 | all_tasks.delete() 20 | db.session.commit() 21 | print("Tasks cleared") 22 | else: 23 | print("Cancelled clearing tasks") 24 | sys.exit(1) 25 | else: 26 | print("There are no tasks to clear.") 27 | sys.exit(1) 28 | 29 | @tasks_command.cli.command("queue") 30 | def list_tasks(): 31 | all_tasks = Tasks.query.filter(Tasks.status=="fresh").all() 32 | print(f"IN QUEUE: {len(all_tasks)}") 33 | for i in all_tasks: 34 | print(i.type) -------------------------------------------------------------------------------- /migrations/versions/5c9e5e91be8f_add_notes_table.py: -------------------------------------------------------------------------------- 1 | """Add notes table 2 | 3 | Revision ID: 5c9e5e91be8f 4 | Revises: 334c6f93b980 5 | Create Date: 2024-07-15 20:53:15.158373 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '5c9e5e91be8f' 14 | down_revision = '334c6f93b980' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('notes', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('book_id', sa.Integer(), nullable=True), 24 | sa.Column('content', sa.String(), nullable=False), 25 | sa.Column('visibility', sa.String(), nullable=True), 26 | sa.Column('created_on', sa.DateTime(), server_default=sa.text('now()'), nullable=True), 27 | sa.ForeignKeyConstraint(['book_id'], ['books.id'], ), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.drop_table('notes') 36 | # ### end Alembic commands ### 37 | -------------------------------------------------------------------------------- /migrations/versions/ce3cee57bdd9_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration 2 | 3 | Revision ID: ce3cee57bdd9 4 | Revises: 5 | Create Date: 2024-03-25 20:29:02.174949 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ce3cee57bdd9' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('books', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('title', sa.String(), nullable=True), 24 | sa.Column('isbn', sa.String(), nullable=True), 25 | sa.Column('description', sa.String(), nullable=True), 26 | sa.Column('reading_status', sa.String(), nullable=True), 27 | sa.Column('current_page', sa.Integer(), nullable=True), 28 | sa.Column('total_pages', sa.Integer(), nullable=True), 29 | sa.PrimaryKeyConstraint('id') 30 | ) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade(): 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_table('books') 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /docs/docs/04 - Features/Share-to-Mastodon.md: -------------------------------------------------------------------------------- 1 | # Share to Mastodon 2 | BookLogr has a Mastodon integration that will allow BookLogr to share events onto your Mastodon profile when they happen in realtime. For example when finishing a book, BookLogr will automatically toot out your progress. 3 | 4 | ## Enable via web interface 5 | By default, BookLogr will not share these events to Mastodon. You will first have to enable it in the Settings page on your BookLogr web interface. 6 | 7 | ![](https://raw.githubusercontent.com/Mozzo1000/booklogr/main/assets/mastodon-enable-share.png) 8 | 9 | ## Create app 10 | 1. Go to your Mastodon instance (ex https://mastodon.social) 11 | 2. Click the gear icon (Preferences) icon to access your Mastodon preferences. 12 | 3. Click Development in the left menu. 13 | 4. Click the New Application button. 14 | 5. Enter an Application Name of your choice (ex BookLogr). 15 | 6. Uncheck all the Scopes check boxes, and then check only the following check boxes: write:statuses. 16 | 7. Click the Submit button. 17 | 8. Click on the name of your application. 18 | 9. Copy your access token and paste it into BookLogr. 19 | 20 | ## References 21 | - [Issue opened on 26th of July 2024 - ActivityPub support?](https://github.com/Mozzo1000/booklogr/issues/17) -------------------------------------------------------------------------------- /web/src/components/LanguageSwitcher.jsx: -------------------------------------------------------------------------------- 1 | import { Dropdown, DropdownItem } from 'flowbite-react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | function LanguageSwitcher({fullSize}) { 5 | const { i18n } = useTranslation(); 6 | const languages = [ 7 | {code: "en", label: "English", flag: "🇬🇧"}, 8 | {code: "sv", label: "Svenska", flag: "🇸🇪"}, 9 | {code: "ar", label: "عربي", flag: "🇸🇦"}, 10 | {code: "cn", label: "中文", flag: "🇨🇳"} 11 | ] 12 | 13 | const currentLang = languages.find(lang => lang.code === i18n.language)?.flag || '🌐'; 14 | const fullLang = languages.find(lang => lang.code === i18n.language)?.label; 15 | 16 | const changeLanguage = (lng) => { 17 | i18n.changeLanguage(lng); 18 | } 19 | 20 | return ( 21 | 22 | {languages.map(({ code, label, flag }) => ( 23 | changeLanguage(code)}> 24 | {flag} {label} 25 | 26 | ))} 27 | 28 | ) 29 | } 30 | 31 | export default LanguageSwitcher -------------------------------------------------------------------------------- /migrations/versions/9232f4adc495_add_tasks_table.py: -------------------------------------------------------------------------------- 1 | """Add tasks table 2 | 3 | Revision ID: 9232f4adc495 4 | Revises: 5c9e5e91be8f 5 | Create Date: 2024-07-23 12:45:00.366358 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '9232f4adc495' 14 | down_revision = '5c9e5e91be8f' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('tasks', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('type', sa.String(), nullable=True), 24 | sa.Column('data', sa.String(), nullable=True), 25 | sa.Column('status', sa.String(), nullable=True), 26 | sa.Column('worker', sa.String(), nullable=True), 27 | sa.Column('created_on', sa.DateTime(), server_default=sa.text('now()'), nullable=True), 28 | sa.Column('updated_on', sa.DateTime(), nullable=True), 29 | sa.Column('created_by', sa.Integer(), nullable=True), 30 | sa.PrimaryKeyConstraint('id') 31 | ) 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade(): 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_table('tasks') 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /web/src/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | import { Footer as FFooter, FooterCopyright, FooterLink, FooterLinkGroup, FooterIcon } from 'flowbite-react'; 2 | import { IoLogoGithub } from "react-icons/io"; 3 | import { DarkThemeToggle } from "flowbite-react"; 4 | import LanguageSwitcher from './LanguageSwitcher'; 5 | import CheckUpdate from './CheckUpdate'; 6 | 7 | function Footer() { 8 | return ( 9 | 10 |
11 |
12 |
13 |

v{import.meta.env.VITE_APP_VERSION}

14 | 15 |
16 | 17 |
18 |
19 | 20 | 21 |
22 |
23 |
24 | ) 25 | } 26 | 27 | export default Footer -------------------------------------------------------------------------------- /migrations/versions/89b6827408dd_add_user_settings_table.py: -------------------------------------------------------------------------------- 1 | """Add user settings table 2 | 3 | Revision ID: 89b6827408dd 4 | Revises: 82989182da4a 5 | Create Date: 2024-12-27 11:05:14.002607 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '89b6827408dd' 14 | down_revision = '82989182da4a' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('user_settings', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('owner_id', sa.Integer(), nullable=True), 24 | sa.Column('send_book_events', sa.Boolean(), nullable=True), 25 | sa.Column('mastodon_url', sa.String(), nullable=True), 26 | sa.Column('mastodon_access_token', sa.String(), nullable=True), 27 | sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), 28 | sa.Column('updated_on', sa.DateTime(), nullable=True), 29 | sa.PrimaryKeyConstraint('id'), 30 | sa.UniqueConstraint('owner_id') 31 | ) 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade(): 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_table('user_settings') 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /web/src/toast/Context.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useReducer, useContext } from "react"; 2 | 3 | const ToastStateContext = createContext({ toasts: [] }); 4 | const ToastDispatchContext = createContext(null); 5 | 6 | function ToastReducer(state, action) { 7 | switch (action.type) { 8 | case "ADD_TOAST": { 9 | return { 10 | ...state, 11 | toasts: [...state.toasts, action.toast], 12 | }; 13 | } 14 | case "DELETE_TOAST": { 15 | const updatedToasts = state.toasts.filter((e) => e.id != action.id); 16 | return { 17 | ...state, 18 | toasts: updatedToasts, 19 | }; 20 | } 21 | default: { 22 | throw new Error("unhandled action"); 23 | } 24 | } 25 | } 26 | 27 | export function ToastProvider({ children }) { 28 | const [state, dispatch] = useReducer(ToastReducer, { 29 | toasts: [], 30 | }); 31 | 32 | return ( 33 | 34 | {children} 35 | 36 | ); 37 | } 38 | 39 | export const useToastStateContext = () => useContext(ToastStateContext); 40 | export const useToastDispatchContext = () => useContext(ToastDispatchContext); -------------------------------------------------------------------------------- /api/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import timedelta 3 | 4 | class Config: 5 | CSRF_ENABLED = True 6 | SECRET_KEY = os.environ.get("AUTH_SECRET_KEY", "this-really-needs-to-be-changed") 7 | SQLALCHEMY_TRACK_MODIFICATIONS = False 8 | SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "postgresql://admin:password@localhost/booklogr") 9 | JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=5) 10 | EXPORT_FOLDER = os.environ.get("EXPORT_FOLDER", "export_data") 11 | GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", None) 12 | GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", None) 13 | 14 | ALLOWED_EXTENSIONS = {"csv"} 15 | 16 | SWAGGER = { 17 | "openapi": "3.0.0", 18 | "info": { 19 | "title": "BookLogr API", 20 | "description": "API for accessing BookLogr", 21 | "contact": { 22 | "url": "https://github.com/mozzo1000/booklogr", 23 | }, 24 | "version": "1.0.0" 25 | }, 26 | "components": { 27 | "securitySchemes": { 28 | "bearerAuth": { 29 | "type": "http", 30 | "scheme": "bearer", 31 | "bearerFormat": "JWT" # optional, arbitrary value for documentation purposes 32 | } 33 | } 34 | 35 | }, 36 | "specs_route": "/docs" 37 | } -------------------------------------------------------------------------------- /docker-compose.postgres.yml: -------------------------------------------------------------------------------- 1 | # docker-compose.yml 2 | services: 3 | booklogr-db: 4 | container_name: "booklogr-db" 5 | image: "postgres" # use latest official postgres version 6 | ports: 7 | - 5432:5432 8 | restart: always 9 | healthcheck: 10 | test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] 11 | interval: 10s 12 | timeout: 5s 13 | retries: 5 14 | env_file: 15 | - .env 16 | volumes: 17 | - ./booklogr:/var/lib/postgresql/data # persist data even if container shuts down 18 | 19 | booklogr-api: 20 | container_name: "booklogr-api" 21 | image: mozzo/booklogr:v1.6.0 22 | depends_on: 23 | booklogr-db: 24 | condition: service_healthy 25 | env_file: 26 | - .env 27 | ports: 28 | - 5000:5000 29 | 30 | booklogr-web: 31 | container_name: "booklogr-web" 32 | image: mozzo/booklogr-web:v1.6.0 33 | environment: 34 | - BL_API_ENDPOINT=http://localhost:5000/ # CHANGE THIS TO POINT TO THE EXTERNAL ADRESS THE USER CAN ACCESS 35 | - BL_GOOGLE_ID= # CHANGE THIS TO YOUR OWN GOOGLE ID 36 | - BL_DEMO_MODE=false 37 | ports: 38 | - 5150:80 39 | 40 | volumes: 41 | booklogr: 42 | auth_db_vol: -------------------------------------------------------------------------------- /web/src/services/books.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import authHeader from "./auth-header"; 3 | import { getAPIUrl } from "./api.utils"; 4 | 5 | const add = (data) => { 6 | return axios.post(getAPIUrl("v1/books"), data, { headers: authHeader() }) 7 | }; 8 | 9 | const get = (status, sort, order, page) => { 10 | if (status) { 11 | return axios.get(getAPIUrl(`v1/books?status=${status}&sort_by=${sort}&order=${order}&offset=${page}`), { headers: authHeader() }) 12 | 13 | } else { 14 | return axios.get(API_URL + "v1/books", { headers: authHeader() }) 15 | 16 | } 17 | } 18 | 19 | const edit = (id, data) => { 20 | return axios.patch(getAPIUrl(`v1/books/${id}`), data, { headers: authHeader() }); 21 | }; 22 | 23 | const remove = (id) => { 24 | return axios.delete(getAPIUrl(`v1/books/${id}`), { headers: authHeader() }); 25 | }; 26 | 27 | const notes = (id) => { 28 | return axios.get(getAPIUrl(`v1/books/${id}/notes`), { headers: authHeader() }) 29 | }; 30 | 31 | const addNote = (id, data) => { 32 | return axios.post(getAPIUrl(`v1/books/${id}/notes`), data, { headers: authHeader() }) 33 | }; 34 | 35 | const status = (isbn) => { 36 | return axios.get(getAPIUrl(`v1/books/${isbn}`), {headers: authHeader() }) 37 | } 38 | 39 | export default { 40 | add, 41 | get, 42 | edit, 43 | remove, 44 | notes, 45 | addNote, 46 | status, 47 | }; -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "gen-api-docs": "docusaurus gen-api-docs all" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "3.8.1", 19 | "@docusaurus/preset-classic": "3.8.1", 20 | "@mdx-js/react": "^3.0.0", 21 | "clsx": "^2.0.0", 22 | "docusaurus-plugin-openapi-docs": "^4.5.1", 23 | "docusaurus-theme-openapi-docs": "^4.5.1", 24 | "prism-react-renderer": "^2.3.0", 25 | "react": "^19.0.0", 26 | "react-dom": "^19.0.0" 27 | }, 28 | "devDependencies": { 29 | "@docusaurus/module-type-aliases": "3.8.1", 30 | "@docusaurus/types": "3.8.1" 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.5%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 3 chrome version", 40 | "last 3 firefox version", 41 | "last 5 safari version" 42 | ] 43 | }, 44 | "engines": { 45 | "node": ">=18.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy docs site 2 | 3 | on: 4 | workflow_dispatch: 5 | jobs: 6 | build: 7 | name: Build Docusaurus 8 | runs-on: ubuntu-latest 9 | defaults: 10 | run: 11 | working-directory: ./docs 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 18 19 | cache: npm 20 | cache-dependency-path: ./docs/package-lock.json 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | - name: Build website 25 | run: npm run build 26 | 27 | - name: Upload Build Artifact 28 | uses: actions/upload-pages-artifact@v3 29 | with: 30 | path: docs/build 31 | 32 | 33 | deploy: 34 | name: Deploy to GitHub Pages 35 | needs: build 36 | 37 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment 38 | permissions: 39 | pages: write # to deploy to Pages 40 | id-token: write # to verify the deployment originates from an appropriate source 41 | 42 | # Deploy to the github-pages environment 43 | environment: 44 | name: github-pages 45 | url: ${{ steps.deployment.outputs.page_url }} 46 | 47 | runs-on: ubuntu-latest 48 | steps: 49 | - name: Deploy to GitHub Pages 50 | id: deployment 51 | uses: actions/deploy-pages@v4 52 | -------------------------------------------------------------------------------- /migrations/versions/4f342e92ab1f_add_profiles_table.py: -------------------------------------------------------------------------------- 1 | """Add profiles table 2 | 3 | Revision ID: 4f342e92ab1f 4 | Revises: ae48d4b4f9d8 5 | Create Date: 2024-06-30 17:54:54.623967 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '4f342e92ab1f' 14 | down_revision = 'ae48d4b4f9d8' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('profiles', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('display_name', sa.String(), nullable=True), 24 | sa.Column('visibility', sa.String(), nullable=True), 25 | sa.Column('owner_id', sa.Integer(), nullable=True), 26 | sa.PrimaryKeyConstraint('id'), 27 | sa.UniqueConstraint('display_name'), 28 | sa.UniqueConstraint('owner_id') 29 | ) 30 | with op.batch_alter_table('books', schema=None) as batch_op: 31 | batch_op.create_foreign_key('fk_books_owner_id', 'profiles', ['owner_id'], ['owner_id']) 32 | 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade(): 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | with op.batch_alter_table('books', schema=None) as batch_op: 39 | batch_op.add_column(sa.Column('profile_id', sa.INTEGER(), autoincrement=False, nullable=True)) 40 | batch_op.drop_constraint(None, type_='foreignkey') 41 | 42 | op.drop_table('profiles') 43 | # ### end Alembic commands ### 44 | -------------------------------------------------------------------------------- /web/src/components/Navbar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Navbar, NavbarBrand, NavbarToggle, NavbarCollapse, NavbarLink } from 'flowbite-react' 3 | import { Link, useLocation } from 'react-router-dom'; 4 | import { useTranslation } from 'react-i18next'; 5 | 6 | const customThemeNav = { 7 | root: { 8 | base: "bg-[#FDFCF7] dark:bg-[#121212] px-2 py-2.5 dark:border-gray-700 dark:bg-gray-800 sm:px-4", 9 | }, 10 | link: { 11 | active: { 12 | on: "bg-cyan-700 underline text-white dark:text-white md:bg-transparent md:text-cyan-700" 13 | } 14 | } 15 | }; 16 | 17 | function NavigationMenu() { 18 | let location = useLocation(); 19 | const { t } = useTranslation(); 20 | 21 | return ( 22 | <> 23 |
24 | 25 | 26 | Logo 27 | BookLogr 28 | 29 | 30 | 31 | 32 | {t("forms.login")} 33 | 34 | 35 | {t("forms.register")} 36 | 37 | 38 | 39 |
40 | 41 | ) 42 | } 43 | 44 | export default NavigationMenu -------------------------------------------------------------------------------- /web/src/components/RegionSwitcher.jsx: -------------------------------------------------------------------------------- 1 | import { Dropdown, DropdownItem } from 'flowbite-react'; 2 | import { useState } from 'react'; 3 | 4 | function RegionSwitcher() { 5 | const [selectedRegion, setSelectedRegion] = useState(localStorage.getItem('region') || navigator.language || 'en-GB'); 6 | 7 | const availableRegions = [ 8 | { code: 'en-US', name: 'United States', flag: '🇺🇸' }, 9 | { code: 'en-GB', name: 'United Kingdom', flag: '🇬🇧' }, 10 | { code: 'sv-SE', name: 'Sverige', flag: '🇸🇪'}, 11 | { code: 'en-CA', name: 'Canada', flag: '🇨🇦' }, 12 | { code: 'es-ES', name: 'España', flag: '🇪🇸' }, 13 | { code: 'es-MX', name: 'México', flag: '🇲🇽' }, 14 | { code: 'fr-FR', name: 'France', flag: '🇫🇷' }, 15 | { code: 'de-DE', name: 'Deutschland', flag: '🇩🇪' }, 16 | { code: 'it-IT', name: 'Italia', flag: '🇮🇹' }, 17 | { code: 'pt-BR', name: 'Brasil', flag: '🇧🇷' }, 18 | { code: 'nl-NL', name: 'Nederland', flag: '🇳🇱'}, 19 | ]; 20 | 21 | const currentRegion = availableRegions.find(r => r.code === selectedRegion); 22 | 23 | const changeRegion = (reg) => { 24 | setSelectedRegion(reg) 25 | localStorage.setItem("region", reg) 26 | } 27 | 28 | return ( 29 | 30 | {availableRegions.map(({ code, name, flag }) => ( 31 | changeRegion(code)}> 32 | {flag} {name} 33 | 34 | ))} 35 | 36 | ) 37 | } 38 | 39 | export default RegionSwitcher -------------------------------------------------------------------------------- /docs/docs/03 - Configuration/Email-verification.md: -------------------------------------------------------------------------------- 1 | # Configure Email Verification 2 | BookLogr supports email-based verification of new accounts. When a user signs up, they will receive a verification code via email if this feature is enabled. The user then needs to use this code to verify their account before being able to login. 3 | :::note 4 | This feature is **disabled** by default. 5 | ::: 6 | To enable email verification, set the following environment variables in the `booklogr-api` container: 7 | 8 | 9 | | Variable | Description | Required | Default | 10 | |------------------|------------------------------------------|----------|-------------| 11 | | `AUTH_REQUIRE_VERIFICATION` | Enable verification of new accounts | Yes | `False` | 12 | | `MAIL_SERVER` | SMTP server address | Yes | — | 13 | | `MAIL_USERNAME` | Username for SMTP authentication | Yes | — | 14 | | `MAIL_PASSWORD` | Password for SMTP authentication | Yes | — | 15 | | `MAIL_SENDER` | Default sender email address | Yes | — | 16 | | `MAIL_PORT` | SMTP server port | No | `587` | 17 | | `MAIL_USE_TLS` | Use TLS encryption | No | `True` | 18 | :::tip 19 | See [Environment variables](/docs/Configuration/Environment-variables) for more information. 20 | ::: 21 | 22 | ### Example `.env` configuration 23 | 24 | ```env 25 | AUTH_REQUIRE_VERIFICATION=True 26 | MAIL_SERVER=smtp.example.com 27 | MAIL_USERNAME=my_smtp_username 28 | MAIL_PASSWORD=my_smtp_password 29 | MAIL_SENDER=noreply@example.com 30 | MAIL_PORT=587 31 | MAIL_USE_TLS=True 32 | ``` -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "version": "1.6.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "cross-env VITE_APP_VERSION=$npm_package_version vite", 8 | "build": "cross-env VITE_APP_VERSION=$npm_package_version vite build", 9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "postinstall": "flowbite-react patch" 12 | }, 13 | "dependencies": { 14 | "@react-oauth/google": "0.12.2", 15 | "@tailwindcss/postcss": "^4.1.10", 16 | "axios": "^1.10.0", 17 | "compare-versions": "^6.1.1", 18 | "flowbite-react": "0.11.8", 19 | "framer-motion": "^12.18.1", 20 | "i18next": "^25.3.2", 21 | "i18next-browser-languagedetector": "^8.2.0", 22 | "lodash": "^4.17.21", 23 | "react": "19.1.0", 24 | "react-confetti": "6.4.0", 25 | "react-dom": "19.1.0", 26 | "react-i18next": "^15.6.0", 27 | "react-icons": "^5.5.0", 28 | "react-image": "^4.1.0", 29 | "react-loading-skeleton": "^3.5.0", 30 | "react-markdown": "^10.1.0", 31 | "react-router-dom": "7.6.2", 32 | "remark-gfm": "^4.0.1" 33 | }, 34 | "devDependencies": { 35 | "@tailwindcss/vite": "^4.1.10", 36 | "@types/react": "^19.1.8", 37 | "@types/react-dom": "^19.1.6", 38 | "@vitejs/plugin-react": "4.5.2", 39 | "autoprefixer": "10.4.21", 40 | "cross-env": "^7.0.3", 41 | "eslint": "^9.17.0", 42 | "eslint-plugin-react": "^7.37.3", 43 | "eslint-plugin-react-hooks": "^5.1.0", 44 | "eslint-plugin-react-refresh": "^0.4.16", 45 | "flowbite-typography": "1.0.5", 46 | "postcss": "8.5.6", 47 | "tailwindcss": "^4.1.10", 48 | "vite": "6.3.5" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /docs/docs/07 - API/create-task.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: create-task 3 | title: "Create task" 4 | description: "Create task" 5 | sidebar_label: "Create task" 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJxdUsGOmzAQ/RX0zlYgPfqWVqq0VaVG2lQ9IA4D8QYLsKk9RM1a/vdqCGl394THnvfmvTckBBNn76KJ0Amfqko+ZxO7YGe23kHjRHEoumCIzXmHnBWi6ZZg+QZdJ7SGggmHhXvousmNAtMlQtcrMKJRH/i+rFQFUxygMBnu/Rkas48MhZmECOV1X/KK/z/uuevNdBf6duij+urDRAyNb79Oglq7obdXKPBtlrpnnlcb1r14gXfeMXUsxyWMW0fUZXmx3C/trvNTOfnXV7+vqqpsvR9GfwnIH40djk/Fiw8FdZ2J0bpL8dn74bv0KrDlUaY/rorD8QkKVxPiHb3fVbtKSCWIiZzIcbQ6eJ/Yu6Hp4YrNHy7nkawTjtVH2sKscd2Lgsc6ekla10ippWh+hjFnuf69mCArbRSuFCy1ordu8psdHX88S7S05b6N/hfwdpC/YnsidxOTNC5SpXTvOPnBuJyhMJibaJcauck5/wVRPN0A 9 | sidebar_class_name: "post api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Create task 40 | 41 | 44 | 45 | 46 | 47 | 51 | 52 | 53 | 54 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /migrations/versions/8f8a46fec66b_add_auth_tables.py: -------------------------------------------------------------------------------- 1 | """Add auth tables 2 | 3 | Revision ID: 8f8a46fec66b 4 | Revises: c9e06189519a 5 | Create Date: 2025-01-27 19:53:55.888180 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '8f8a46fec66b' 14 | down_revision = 'c9e06189519a' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('revoked_tokens', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('jti', sa.String(length=120), nullable=True), 24 | sa.PrimaryKeyConstraint('id') 25 | ) 26 | op.create_table('users', 27 | sa.Column('id', sa.Integer(), nullable=False), 28 | sa.Column('email', sa.String(), nullable=False), 29 | sa.Column('name', sa.String(), nullable=False), 30 | sa.Column('password', sa.String(), nullable=False), 31 | sa.Column('role', sa.String(), nullable=True), 32 | sa.Column('status', sa.String(), nullable=True), 33 | sa.PrimaryKeyConstraint('id'), 34 | sa.UniqueConstraint('email') 35 | ) 36 | op.create_table('verification', 37 | sa.Column('id', sa.Integer(), nullable=False), 38 | sa.Column('user_id', sa.Integer(), nullable=True), 39 | sa.Column('status', sa.String(), nullable=True), 40 | sa.Column('code', sa.String(), nullable=True), 41 | sa.Column('code_valid_until', sa.DateTime(), nullable=True), 42 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), 43 | sa.PrimaryKeyConstraint('id') 44 | ) 45 | # ### end Alembic commands ### 46 | 47 | 48 | def downgrade(): 49 | # ### commands auto generated by Alembic - please adjust! ### 50 | op.drop_table('verification') 51 | op.drop_table('users') 52 | op.drop_table('revoked_tokens') 53 | # ### end Alembic commands ### 54 | -------------------------------------------------------------------------------- /web/src/pages/Settings.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Tabs, TabItem} from "flowbite-react"; 3 | import { RiAccountCircleLine } from "react-icons/ri"; 4 | import { RiDatabase2Line } from "react-icons/ri"; 5 | import DataTab from '../components/Data/DataTab'; 6 | import AccountTab from '../components/AccountTab'; 7 | import AnimatedLayout from '../AnimatedLayout'; 8 | import MastodonTab from '../components/MastodonTab'; 9 | import { RiMastodonLine } from "react-icons/ri"; 10 | import { useTranslation, Trans } from 'react-i18next'; 11 | import { RiSlideshowView } from "react-icons/ri"; 12 | import InterfaceTab from '../components/InterfaceTab'; 13 | 14 | function Settings() { 15 | const [activeTab, setActiveTab] = useState(0); 16 | const { t } = useTranslation(); 17 | 18 | return ( 19 | 20 |
21 |
22 |

{t("navigation.settings")}

23 |
24 | setActiveTab(tab)} variant="underline" className="pt-1"> 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 |
40 | ) 41 | } 42 | 43 | export default Settings -------------------------------------------------------------------------------- /api/book_template_export.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exported books 5 | 22 | 23 | 24 | 25 |

All books ({{ data|length }})

26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {% for item in data %} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 57 | 58 | {% endfor %} 59 |
TitleAuthorDescriptionISBNReading statusTotal pagesCurrent pageRatingNotes
{{ item.title }}{{ item.author }}{{ item.description }}{{ item.isbn }}{{ item.reading_status }}{{ item.total_pages }}{{ item.current_page }}{{ item.rating }} 49 | {% for note in item.notes %} 50 |

Content: {{ note.content }}
51 | Quote page: {{ note.quote_page }}
52 | Visibility: {{ note.visibility }}
53 | Created: {{ note.created_on }}

54 |
55 | {% endfor %} 56 |
60 | 61 | -------------------------------------------------------------------------------- /web/src/services/auth.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import globalRouter from "../GlobalRouter"; 3 | import { getAPIUrl } from "./api.utils"; 4 | 5 | axios.interceptors.response.use((response) => { 6 | return response 7 | }, async (error) => { 8 | if (error.response.status === 401) { 9 | logout(); 10 | globalRouter.navigate("/login"); 11 | } 12 | return Promise.reject(error) 13 | }); 14 | 15 | const register = (email, name, password) => { 16 | return axios.post(getAPIUrl("/v1/register"), { 17 | email, 18 | name, 19 | password, 20 | }); 21 | }; 22 | 23 | const login = (email, password) => { 24 | return axios 25 | .post(getAPIUrl("/v1/login"), { 26 | email, 27 | password, 28 | }) 29 | .then((response) => { 30 | if (response.data.access_token) { 31 | localStorage.setItem("auth_user", JSON.stringify(response.data)); 32 | } 33 | 34 | return response.data; 35 | }); 36 | }; 37 | 38 | const loginGoogle = (code) => { 39 | return axios.post(getAPIUrl("/v1/authorize/google"), {code}).then((response) => { 40 | if (response.data.access_token) { 41 | localStorage.setItem("auth_user", JSON.stringify(response.data)); 42 | } 43 | return response.data 44 | }) 45 | } 46 | 47 | const logout = () => { 48 | /*TODO: Send logout request to auth-server so the token get invalidated. */ 49 | localStorage.removeItem("auth_user"); 50 | }; 51 | 52 | const getCurrentUser = () => { 53 | return JSON.parse(localStorage.getItem("auth_user")); 54 | }; 55 | 56 | const verify = (email, code) => { 57 | return axios.post(getAPIUrl("/v1/verify"), { 58 | email, 59 | code, 60 | }); 61 | }; 62 | 63 | export default { 64 | register, 65 | login, 66 | logout, 67 | getCurrentUser, 68 | verify, 69 | loginGoogle, 70 | }; -------------------------------------------------------------------------------- /web/src/components/ESCIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function ESCIcon() { 4 | return ( 5 | 13 | 19 | 20 | 27 | 34 | 35 | 50 | 62 | ESC 63 | 64 | 65 | 66 | 67 | ); 68 | } 69 | 70 | export default ESCIcon; 71 | -------------------------------------------------------------------------------- /docs/docs/07 - API/get-list-of-files.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: get-list-of-files 3 | title: "Get list of files" 4 | description: "Get list of files" 5 | sidebar_label: "Get list of files" 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJxtUsFu2zAM/RWBZ8F2hp58y4C16DAUxdZhB8MHWaFtIbboSnSw1NC/D0zsoVl3kkg9PvI9aoGAcSIfMUK5wKeikOOA0QY3sSMPJXxHnoOPanCRFbWqdQPGDJKGu+LuI/yJrghlaR4OqkHV0uwPGaSkIaKdg+MzlNUCDZqAYT9zD2VVp1oDmy5CWcG9EECt/6F+QL6dAjSMyD0doIQOGTRMRtggP+3yDbH1/GF7HK8633feonsKo2Eo4euvF6m6oKFcX0EDnyeJe+bposX5lqTckmdjWa5zGFZELPO8c9zPTWZpzEd6e6NdURR5Q3QcqAvi3626/fOjaikoYy3G6HynPhMdvwlWAzsepPuWUvvnR9BwwhCv1busyAohnSjyaLyM481Fwf9su2m9bNoYf3M+DcZ5YbqoWVZLKzjtQEO7baanyJJdlsZE/BmGlCT9OmOQ7dYaTiY408jUVZ3eLerhi/hrVvPXzn9dXi/yP9Yn48+i1AyzRMtyRbzQEX1KoOGIZxldYkh1SukPW0bzWQ== 9 | sidebar_class_name: "get api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Get list of files 40 | 41 | 44 | 45 | 46 | 47 | 51 | 52 | 53 | 54 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /.github/workflows/build-docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish API 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "New docker build version" 8 | required: false 9 | default: "latest" 10 | type: string 11 | 12 | jobs: 13 | # define job to build and publish docker image 14 | build-and-push-docker-image: 15 | name: Build Docker image and push to repositories 16 | # run only when code is compiling and tests are passing 17 | runs-on: ubuntu-latest 18 | 19 | # steps to perform in job 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up QEMU 25 | uses: docker/setup-qemu-action@v3 26 | 27 | # setup Docker buld action 28 | - name: Set up Docker Buildx 29 | id: buildx 30 | uses: docker/setup-buildx-action@v3 31 | 32 | - name: Login to DockerHub 33 | uses: docker/login-action@v3 34 | with: 35 | username: ${{ secrets.DOCKER_HUB_USR }} 36 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 37 | 38 | # Extract metadata (tags, labels) for Docker 39 | # https://github.com/docker/metadata-action 40 | - name: Extract Docker metadata 41 | id: meta 42 | uses: docker/metadata-action@v5 43 | with: 44 | images: | 45 | docker.io/mozzo/booklogr 46 | tags: | 47 | # set latest tag for default branch 48 | type=raw,value=latest,enable={{is_default_branch}} 49 | type=raw,value=${{ github.event.inputs.version }} 50 | 51 | - name: Build image and push to Docker Hub and GitHub Container Registry 52 | uses: docker/build-push-action@v6 53 | with: 54 | context: . 55 | platforms: linux/amd64,linux/arm64 56 | push: true 57 | tags: ${{ steps.meta.outputs.tags }} 58 | labels: ${{ steps.meta.outputs.labels }} 59 | 60 | - name: Image digest 61 | run: echo ${{ steps.docker_build.outputs.digest }} 62 | -------------------------------------------------------------------------------- /docs/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import Link from '@docusaurus/Link'; 3 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 4 | import Layout from '@theme/Layout'; 5 | import HomepageFeatures from '@site/src/components/HomepageFeatures'; 6 | 7 | import Heading from '@theme/Heading'; 8 | import styles from './index.module.css'; 9 | 10 | function HomepageHeader() { 11 | const {siteConfig} = useDocusaurusContext(); 12 | return ( 13 | <> 14 |
15 |
16 | 17 | 18 | 19 | {siteConfig.title} 20 | 21 |

{siteConfig.tagline}

22 |
23 | 26 | Get started 27 | 28 | 29 | Try demo 30 | 31 | 32 |
33 | 34 |
35 |
36 |
37 | 38 | ); 39 | } 40 | 41 | export default function Home() { 42 | const {siteConfig} = useDocusaurusContext(); 43 | return ( 44 | 47 | 48 |
49 | 50 |
51 |
52 |

And many more features

53 | 56 | Learn more 57 | 58 |
59 |
60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /.github/workflows/build-docker-image-web.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Web 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "New docker (WEB) build version" 8 | required: true 9 | default: "latest" 10 | type: string 11 | 12 | jobs: 13 | # define job to build and publish docker image 14 | build-and-push-docker-image: 15 | name: Build Docker image and push to repositories 16 | # run only when code is compiling and tests are passing 17 | runs-on: ubuntu-latest 18 | 19 | # steps to perform in job 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up QEMU 25 | uses: docker/setup-qemu-action@v3 26 | 27 | # setup Docker buld action 28 | - name: Set up Docker Buildx 29 | id: buildx 30 | uses: docker/setup-buildx-action@v3 31 | 32 | - name: Login to DockerHub 33 | uses: docker/login-action@v3 34 | with: 35 | username: ${{ secrets.DOCKER_HUB_USR }} 36 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 37 | 38 | # Extract metadata (tags, labels) for Docker 39 | # https://github.com/docker/metadata-action 40 | - name: Extract Docker metadata 41 | id: meta 42 | uses: docker/metadata-action@v5 43 | with: 44 | images: | 45 | docker.io/mozzo/booklogr-web 46 | tags: | 47 | # set latest tag for default branch 48 | type=raw,value=latest,enable={{is_default_branch}} 49 | type=raw,value=${{ github.event.inputs.version }} 50 | 51 | - name: Build image and push to Docker Hub and GitHub Container Registry 52 | uses: docker/build-push-action@v6 53 | with: 54 | context: web 55 | platforms: linux/amd64,linux/arm64 56 | push: true 57 | tags: ${{ steps.meta.outputs.tags }} 58 | labels: ${{ steps.meta.outputs.labels }} 59 | 60 | - name: Image digest 61 | run: echo ${{ steps.docker_build.outputs.digest }} 62 | -------------------------------------------------------------------------------- /docs/docs/07 - API/get-profile-of-the-logged-in-user.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: get-profile-of-the-logged-in-user 3 | title: "Get profile of the logged in user" 4 | description: "Get profile of the logged in user" 5 | sidebar_label: "Get profile of the logged in user" 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJyNUk1r3DAQ/StizsL2lpx820IStoSw0IQejA+yd2yLtTWuNF6yMfrvZbzedNNC6Ul6mq/33mgGj2EkFzBAPsOXLJPjgKH2dmRLDnLYuYb8YAQpU9HEijtUo6fG9qioWWBPbYsHZZ2aAvoEooa77O7vZs/0UdnQ5A4JxKghYD15y2fIixkqNB79duIO8qKMpQY2bYC8gP2lMkCp/2j7iPxvRqBhQO7oADm0yKBhNDIA0tMmHa99fzP5Xnc4XEy55XNFD4sjkMO3Hy9StWRDvkZBA59HwR3zuCi0riEpr8mxqVmuk+/XjJCnaWu5m6qkpiEd6P2dNlmWpRXRsafWi52fBW/3O9WQV6auMQTrWvWV6PgkuRrYci/Tr09qu9+BhhP6cKneJFmSSdORAg/GCR1nFgX/4+QnKvNVK+Mbp2NvrJPOi7p5dbmA00Ysv9lfR4ElMM+VCfjq+xjl+eeEXr5BqeFkvDWVCCnKeLO+x3ux3Kz7WId/GL9e5COtIePOIt70k6B5vmS80BFdjKDhiGdhLxhiGWP8Bds5C6s= 9 | sidebar_class_name: "get api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Get profile of the logged in user 40 | 41 | 44 | 45 | 46 | 47 | 51 | 52 | 53 | 54 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /api/commands/user.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | import click 3 | from getpass import getpass 4 | import sys 5 | import re 6 | from api.auth.models import User, Verification 7 | 8 | user_command = Blueprint('user', __name__) 9 | 10 | def _validate_email(ctx, param, value): 11 | if not re.match(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", value): 12 | raise click.UsageError('Incorrect email address given') 13 | else: 14 | return value 15 | 16 | def _validate_role(ctx, param, value): 17 | valid_roles = ["user", "admin"] 18 | if value in valid_roles: 19 | return value 20 | else: 21 | raise click.UsageError(f"Incorrect role given, valid roles are: {valid_roles}") 22 | 23 | 24 | @user_command.cli.command("create") 25 | @click.argument('email', type=str, callback=_validate_email) 26 | @click.argument('name', type=str) 27 | @click.argument('role', type=str, callback=_validate_role) 28 | @click.option("--password", type=str) 29 | def create_user(email, name, role, password): 30 | print("Creating user..") 31 | if password: 32 | set_password = password 33 | else: 34 | password = getpass("Set a password: ") 35 | password_again = getpass("Confirm password: ") 36 | if password == password_again: 37 | set_password = password 38 | else: 39 | print("Passwords do not match, try again.") 40 | sys.exit(1) 41 | existing_user = User.query.filter(User.email==email).first() 42 | if existing_user: 43 | print(f"User with email {email} already exists.") 44 | sys.exit(1) 45 | 46 | new_user = User(email=email, password=User.generate_hash(set_password), name=name, role=role) 47 | 48 | try: 49 | new_user.save_to_db() 50 | try: 51 | new_verification = Verification(user_id=new_user.id, status="verified", code=None, code_valid_until=None) 52 | new_verification.save_to_db() 53 | except: 54 | print("Could not create user because of failure with verification") 55 | print(f"Successfully created a new user:\n\tEmail: {email}\n\tName: {name}\n\tRole: {role}") 56 | except: 57 | print("Could not save new user to database.") 58 | -------------------------------------------------------------------------------- /api/routes/settings.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request, jsonify 2 | from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt 3 | from api.models import UserSettings, UserSettingsSchema 4 | from api.decorators import required_params 5 | import time 6 | from datetime import datetime, timezone 7 | 8 | settings_endpoint = Blueprint('settings', __name__) 9 | 10 | @settings_endpoint.route("/v1/settings", methods=["GET"]) 11 | @jwt_required() 12 | def get_settings(): 13 | claim_id = get_jwt()["id"] 14 | user_settings_schema = UserSettingsSchema(many=False) 15 | user_settings = UserSettings.query.filter(UserSettings.owner_id==claim_id).first() 16 | if user_settings: 17 | return jsonify(user_settings_schema.dump(user_settings)) 18 | else: 19 | return jsonify({ 20 | "error": "Not found", 21 | "message": "User settings not found" 22 | }), 404 23 | 24 | @settings_endpoint.route("/v1/settings", methods=["PATCH"]) 25 | @jwt_required() 26 | def edit_settings(): 27 | claim_id = get_jwt()["id"] 28 | 29 | user_settings = UserSettings.query.filter(UserSettings.owner_id==claim_id).first() 30 | if user_settings: 31 | if request.json: 32 | if "send_book_events" in request.json: 33 | user_settings.send_book_events = request.json["send_book_events"] 34 | if "mastodon_url" in request.json: 35 | user_settings.mastodon_url = request.json["mastodon_url"] 36 | if "mastodon_access_token" in request.json: 37 | user_settings.mastodon_access_token = request.json["mastodon_access_token"] 38 | 39 | user_settings.updated_on = datetime.now(timezone.utc) 40 | user_settings.save_to_db() 41 | return jsonify({'message': 'User settings updated'}), 200 42 | else: 43 | return jsonify({ 44 | "error": "Bad request", 45 | "message": "send_book_events, mastodon_url and/or mastodon_access_token not given." 46 | }), 40 47 | else: 48 | return jsonify({ 49 | "error": "Not found", 50 | "message": "User settings not found." 51 | }), 404 -------------------------------------------------------------------------------- /docs/docs/07 - API/delete-note.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: delete-note 3 | title: "Delete note" 4 | description: "Delete note" 5 | sidebar_label: "Delete note" 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJxtU8Fu2zAM/RXjnTZAiJ2hJ90yNAM6FEOxZdghyEGxmViILbkSHSw19O8DHSdtup4sUY98j4/0gM4E0xJTiNDrAdZBozNcQ8GZlqBhKygEeu5toAqaQ08KsaypNdAD+NSNKMe0p4CUNoKOnXeRogC+FIV8KoplsB1bLxQ/PFMWqPVHqrLYlyXFuOub5jRDUrgr7j5KyZxk7XzvqhlSUohU9sHyaZS+JRMoLHquodcbkcFmL12NZBEb9a7gPTXENBaFQktc+woa1RiGOtugkR/nuWBiPtgq4ZX1l3hw7vEt9+X2zYfWMDS+/1lhckycOr9CXZyrmbuxG+t2XtJL79iULMc+NBMi6jzfW6777az0bd76lxc/L4oi33p/aPw+iG+3/S2eHrKdD5kZ3bVun331/vAoWAW23Aj7JZQtnh6gcKQQz9nzWTErpGjnI7fGiZxpI26NuyG97gPTX867xlgnNcY+hsnRNY5zWa9xKgraVjKb2keWt2HYmki/Q5OShJ97CjLfjcLRBGu2ono9oLJRzhX0zjSR/pMhJpIT+z/9nHb3c/Zq+q28KWjcSSwwTS83KBzodP4B0ia92ZD75eNytYSCmQY+5V8nOx1E54elh+GMWPkDuZSuTCx3IUvpH7p0NU0= 9 | sidebar_class_name: "delete api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Delete note 40 | 41 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /docs/docs/07 - API/get-tasks.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: get-tasks 3 | title: "Get tasks" 4 | description: "Get tasks" 5 | sidebar_label: "Get tasks" 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJxtU8Fu2zAM/RWBpw0QYqfoSbcM2IoOPRRdhh2CHGSbsQXbkitRwVJD/z7QdtKk3ckS9cjH90iPMGiveyT0AdRuBGNBwaCpAQlW9wgKTAUSPL5G47ECRT6ihFA22GtQI9BpmFCWsEYPKe0ZHQZnAwYG3OU5fyoMpTcDGccUL0jR2yCMPTjfa44KXbhIghoUpEO7giThPr//nLvVoRWli10lrCNRoDi4aKsVpCQhYBm9odOkpkDt0W8iNaB2e+6MdM1CpxoB9vJD6QekiTyAhB6pcRUoqJFAzqYoyI7rbEJko6kSvBP+Ykdmxde059uPSSYo+PlnC4t/7Nv8CvLsY0M0TELYGU4vnSVdEh+j7xZEUFlWG2pisSpdn/Xu7c2t8zzPCufaztWezbuVtnl+FAfnhS5LDMHYWnxzrn1irAQy1DH7OSQ2z48g4Yg+zNnrVb7KuejgAvXacjvLflx7dkN52Q3Cv5QNnTaWK0wqxsXPHRzXzL/kK1PxUBoXiN/GsdABf/suJQ6/RvQ82L2Eo/ZGF9zzboTKBD5XoA66C/ipDbYQLZv/5WXZ46/i3fLb9pagtic2QHeRbyChxdP8M6R9ulqOh+88Tr3Mekm+DHU5cJP/rTuOM2LrWrQpXWiI78yU0j8fMjiC 9 | sidebar_class_name: "get api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Get tasks 40 | 41 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /docs/docs/07 - API/download-file.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: download-file 3 | title: "Download file" 4 | description: "Download file" 5 | sidebar_label: "Download file" 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJxtU8Fu2zAM/RWDpw0QYmfALr5lWDt02KHoWuwQ+KDYjC3EFl2KTpca+veBjpMmXU+mqCe+x0d6hN6y7VCQA+TrEZyHHHorDRjwtkPIYetanEIDjM+DY6wgFx7QQCgb7CzkI8ihV2wQdr6GGAsFh558wKD3X7JMPxWGkl0vjpTnAWVgHxJlWEA08PUj1JPfeXrxCTITJ1SWAzNWC4jRQMByYCeHSfsGLSOvBmkgXxcqQWytbcGtazFAYd5V/k4vviVbTQLAQIfSUAU51ChgjjbkkO6XqQJCOp6siPBG/Vs9ODZ5KeB0uiXurEAOP/88wuyYOnW8BXNyrhHpp5ac35I+L8mLLUXDgdsZEfI0rZ00w2ZRUpd29PpKyyzL0g3RrqWa1cXrJlf3d8mWOLFliSE4XyffiHa/FGtAnLTKfkolq/s7MLBHDsfXy0W2yLRoT0E661XOvBfv3buiPW+E4F9J+9Y6r1WmTsbZ2TXsl2Cm/QpgID8vWmGgoSCKGMeNDfjEbYyafh6QddiFgb1lZzeqfj1C5YLGFeRb2wb8T4yaiV7H8Olh3uHPyZv51yLnpPUHtcK2g57AwA4Pl79DLOLFyvy40fHaefZzifOQ50Clflh9HI+IR9qhj/FMJnpWphj/AT8DPME= 9 | sidebar_class_name: "get api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Download file 40 | 41 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /docs/docs/07 - API/booklogr-api.info.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: booklogr-api 3 | title: "BookLogr API" 4 | description: "API for accessing BookLogr" 5 | sidebar_label: Introduction 6 | sidebar_position: 0 7 | hide_title: true 8 | custom_edit_url: null 9 | --- 10 | 11 | import ApiLogo from "@theme/ApiLogo"; 12 | import Heading from "@theme/Heading"; 13 | import SchemaTabs from "@theme/SchemaTabs"; 14 | import TabItem from "@theme/TabItem"; 15 | import Export from "@theme/ApiExplorer/Export"; 16 | 17 | 21 | 22 | 23 | 28 | 29 | 30 | 31 | 32 | API for accessing BookLogr 33 | 34 |
37 | 43 | 46 | 50 | 51 | 52 | 53 | 54 |
55 | 56 | 57 | 58 | 63 | 64 | 69 | 70 | 75 | 76 | 77 |
59 | Security Scheme Type: 60 | 61 | http 62 |
65 | HTTP Authorization Scheme: 66 | 67 | bearer 68 |
71 | Bearer format: 72 | 73 | JWT 74 |
78 |
79 |
80 |
81 |
84 |

87 | Contact 88 |

89 | 90 | 91 | URL: [https://github.com/mozzo1000/booklogr](https://github.com/mozzo1000/booklogr) 92 | 93 |
94 | -------------------------------------------------------------------------------- /docs/docs/07 - API/get-profile-by-name.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: get-profile-by-name 3 | title: "Get profile by name" 4 | description: "Get profile by name" 5 | sidebar_label: "Get profile by name" 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJxtU01v2zAM/SsCTxsgxM7Qk24dsBUZdgi2DjsEwSDbtC3EFl2JDuYa+u8DE3tr2p0sko8f75GeYbDB9sgYIpjDDM6DgcFyCxq87REMVC4OnZ1+XUwNAZ9GF7ACw2FEDbFssbdgZuBpEHzk4HwDKR0FHAfyEaPEP+S5fCqMZXADO5JeO19T6K1YyhY0suIW1RCodh1uIGm4y+/epu2vAOWJVU2jrzaQkga2jfBYwxGO+lXiA/JaXRWTWkj1yC1VYKBBBn0VwEB23mYLNmbzSx0SaIhYjsHx9F0EuDIs0AYM96Nkr9bnCz0w8OXnIyxyiUzXKOhVtpZ5uHBwviZJL8mzLVmeY+gWRDRZ1jhux2JTUp/19PxM2zzPs4Lo1FETRLFbxvf7naopKFuWGKPzjfpIdPoqWA3suJPuq0vd73eg4YwhXrO3m3yTS9GBIvfWyzjLYfxfypvmf4+C8TdnQ2edl1oXPvMi8wHOW9F8XZkGc3NyRw0tRRbgPBc24o/QpSTupxHDBOZw1HC2wdlCqBxmuVh5V2Bq20V8M5Moi1528u7bcs3v1b9N3M66OK2fRBfbjWKBhhNOr3+OdEwvTunh0yOk9AemiSYS 9 | sidebar_class_name: "get api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Get profile by name 40 | 41 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /web/src/components/RemoveBookModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { DropdownItem, Modal, ModalBody, ModalHeader, Button } from 'flowbite-react' 3 | import BooksService from '../services/books.service'; 4 | import { RiErrorWarningLine } from "react-icons/ri"; 5 | import useToast from '../toast/useToast'; 6 | import { useTranslation, Trans } from 'react-i18next'; 7 | 8 | function RemoveBookModal(props) { 9 | const toast = useToast(4000); 10 | const { t } = useTranslation(); 11 | 12 | const removeBook = () => { 13 | BooksService.remove(props.id).then( 14 | response => { 15 | toast("success", response.data.message); 16 | props.onSuccess(); 17 | }, 18 | error => { 19 | const resMessage = 20 | (error.response && 21 | error.response.data && 22 | error.response.data.message) || 23 | error.message || 24 | error.toString(); 25 | toast("error", resMessage); 26 | } 27 | ) 28 | props.close(false); 29 | } 30 | 31 | return ( 32 |
33 | props.close(false)} popup> 34 | 35 | 36 |
37 | 38 |

39 | {t("book.remove_confirmation.title")} 40 |

41 |
42 | 45 | 48 |
49 |
50 |
51 |
52 |
53 | ) 54 | } 55 | 56 | export default RemoveBookModal -------------------------------------------------------------------------------- /docs/docs/03 - Configuration/Google-sign-in.md: -------------------------------------------------------------------------------- 1 | # Configure Google Sign-In 2 | 3 | BookLogr supports Google authentication for account sign-in. To enable it, you will need to set up credentials and apply them to your container environment. 4 | 5 | ## Steps to set up Google Sign-In 6 | ### 1. Create OAuth Credentials 7 | 1. Go to the [Google Cloud Console](https://console.cloud.google.com/) 8 | 2. Select or create a project. 9 | 3. Navigate to **APIs & Services** → **Credentials**. 10 | 4. Set up the OAuth consent screen. 11 | 5. Click **Create Credentials** → **OAuth 2.0 Client ID**. 12 | 6. Choose **Web application** and configure: 13 | - **Authorized JavaScript origin**: 14 | ``` 15 | http://{your-domain-or-ip-of-web} 16 | ``` 17 | - **Authorized redirect URI**: 18 | ``` 19 | http://{your-domain-or-ip-of-api}/v1/authorize/google 20 | ``` 21 | 7. Save and copy the **Client ID** and **Client Secret**. 22 | 23 | :::note 24 | Replace `{your-domain-or-ip-of-web}` with the user facing domain name or IP of your `booklogr-web` server/container. 25 | 26 | Replace `{your-domain-or-ip-of-api}` with the user facing domain name or IP of your `booklogr-api` server/container. 27 | ::: 28 | 29 | ### 2. Configure Environment Variables 30 | Use your **Client ID** and **Client Secret** and set the environment variables accordingly. 31 | :::tip 32 | See [Environment variables](/docs/Configuration/Environment-variables) for more information. 33 | ::: 34 | 35 | #### `booklogr-api` container 36 | ```env 37 | GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com 38 | GOOGLE_CLIENT_SECRET=your-client-secret 39 | ``` 40 | 41 | #### `booklogr-web` container 42 | ```env 43 | BL_GOOGLE_ID=your-client-id.apps.googleusercontent.com 44 | ``` 45 | :::note 46 | If BL_GOOGLE_ID is empty or not set, the Sign in with Google button will not appear on the login screen. 47 | ::: 48 | 49 | ## Troubleshooting 50 | ### Error: Google Sign-In Disabled 51 | If the Google Sign-In button is disabled this indicates that the provided Client ID is in the wrong format. 52 | 53 | **Verify Client ID Format** 54 | 55 | It should match this pattern: 56 | ``` 57 | [numbers]-[string].apps.googleusercontent.com 58 | ``` 59 | :::note 60 | If you Client ID does indeed match this pattern and you are still getting "Google Sign-In Disabled", please open an [issue](https://github.com/mozzo1000/booklogr/issues). 61 | ::: 62 | 63 | -------------------------------------------------------------------------------- /docs/docs/07 - API/edit-profile.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: edit-profile 3 | title: "Edit profile" 4 | description: "Edit profile" 5 | sidebar_label: "Edit profile" 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJx1U01v2zAM/SsBz1rsDD35lhYb1mGHYMuwQxAMss3YamxRleisqaH/PtAfadpuF1v8EPnI99SDx8cOA99SeYash4Iso2U5aucaU2g2ZJOHQFZ8oaix1XJynhx6NhjEKk1wjT7/trpFsfnsEDII7I2tICo4mWBy0xg+/yMc1eyh/AELhiguQWY8lpAddBNw8ARHNowtP6bp0BlD4Y0TlJDBxtPBNLjoXKkZy6W0vklv/p9oiRcH6qykRgUBi84PIHc95Kg9+nXHNWS7fdwrYF0FyHbz7QB79abup9Lwwo1hUNAi11RCBk5zUYOSfw0ZJKdV4uYiL21/yHrH8a6bz9Zn8q1myODrr63cGrIhm6Jw2WLN7IZxhLbvLwR/etKta/A9YTMTr3m6os/YA83y0MUgj843U6uQJUlluO7yZUFt0tLzM63SNE1yomNDlZcKr9e03twvDuQXuigwBGOrxS3R8ZvkKmDDAhJm12K9uRdo6MN4e7VMl6kUdRS41YMyp0He7P9V14vuGJ84cY02VooMg/QTMzs4rYSmK4JrCiyBvs91wJ++iVHcjx160clewUl7o3PBvNtHBTXqEv0goSPKHu/GR/VhK+0lvekExrsHJhK7KGaz3t59AQX59DJbKuWS139ADd8MQAENsw2KGXw9NNpWna4kdywqQtCTjqYFXAQzHQTqFNL2fAWx78eMLR3RxghqmojFhriPMf4FEMh16A== 9 | sidebar_class_name: "patch api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Edit profile 40 | 41 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /docs/docs/07 - API/get-notes-from-book.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: get-notes-from-book 3 | title: "Get notes from book" 4 | description: "Get notes from book" 5 | sidebar_label: "Get notes from book" 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJx1k09v2zAMxb+K8E4tIMTO0JNvLbYVGYauaDvsUOSg2EwsxJZciQ6WGvruA223a9buZP15JB9/ogd0JpiWmEJE8TigolgG27H1DgVWn5Xfqo33e3V28+NBre6vbs6hYeWyM1xDw5mWUMBW0Aj01NtAFQoOPWnEsqbWoBjAx25UOaYdBaS0FnXsvIsURfApz+VzWv+OuA8uKueZojLMpqypUuwV1zT6WiBpXOQX72NvxpjS900l8WpDaut7Vy2Qkkaksg+Wj2PTGzKBwmXPNYrHtVhjsxMeuPJ+H7HW/+S+Jp49bYNvRyPQaIlrX6HAjhh64lMgOywzEcRssFXKxjD8NXAviCYEb2287L760BpGgW+/HjADFZDTLfQL2Jq5GxuzbuslvPSOTcmy7EMzK2KRZTvLdb9ZlL7NWv/87Jd5no8GG78LQvO01cvbldr6oExZUozW7ZQw+S5aDbbcSPWXI3V5u4LGgUKcopeLfJFL0s5Hbo0TO/PAfMzwpPjr2DD95qxrjHWSa+xnmPk+4rCExkgYGsU4hxPktUbtI4tmGDYm0s/QpCTHTz0Fefq1xsEEazbShUy/jbKuUGxNE+mdHYFKTp7j7G4e9XP1v5/kQ+/zoXFH4WSaXnbQ2NNx+onSOr2ZpOsv8upmHok5+PXt54U4/zDvMEyKB78nl9JrGZa9VErpD7agVj4= 9 | sidebar_class_name: "get api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Get notes from book 40 | 41 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /web/public/fallback-cover.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | BOOK COVERNOT AVAILABLE 51 | -------------------------------------------------------------------------------- /docs/docs/07 - API/remove-book-from-list.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: remove-book-from-list 3 | title: "Remove book from list" 4 | description: "Remove book from list" 5 | sidebar_label: "Remove book from list" 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJx1U01v2zAM/SsCTy0gxM7Qk2/pkgEZiq5oM+wQ5KDYdCxEllyJzuYa+u8DbTdr1vakr0e+x0eqh0Z5VSOhD5Bteygw5F43pJ2FDNZL4Uqxd+4oru5/bMT66fb+GiRofmwUVSDBqhohA12ABI/PrfZYQEa+RQkhr7BWkPVAXTOgLOEBPcS4Y3RonA0YGPAlTXm55L9lZo+1O2EhQpvnGELZGtPNIEq4SW/eh3x1rSmEdSRKbQuhbDfq/62pElShCG3TGI2FWC9nEKOEgHnrNXVD/XtUHv2ipQqy7Y5VkjqwNYOWADv5H9/joG7kKL2rhdGBQEKNVLkCMijQICHI0a8MktM8YXRIel1E+Mf/xGaNZrxV8Xr65nytCDL4/msDk7Vs6fgK8tXiiqgZ6tK2dByeO0sqJ9623kyIkCXJQVPV7me5q5Pavby4eZqmgzTjDp4Nvqx08bAWpfNCDW3Q9iDYkjvGSiBNBqeO8ZVYPKxBwgl9GKPns3SWctLGBaqVZTnT6Hxm4QX9eYQI/1DSGKUtZxsq6idvt3Cag4TBXZCQ6YL7VblA/Nb3exXwpzcx8vVzi557vpNwUl6rPevnH6AD7wvISmUCvpPBdqLlRlw9TuN+LT77KB9qni6V7dghZVo+gYQjduNHirv4ZoCWq7vVZgUS1DQPU/y58dOGxX+Yuu9HxMYd0cZ4ZiI+M1mMfwHlVlkm 9 | sidebar_class_name: "delete api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Remove book from list 40 | 41 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /api/auth/models.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | from flask_marshmallow import Marshmallow, fields 3 | import uuid 4 | from werkzeug.security import generate_password_hash, check_password_hash 5 | from api.models import db, ma 6 | 7 | class Verification(db.Model): 8 | __tablename__ = "verification" 9 | id = db.Column(db.Integer, primary_key=True) 10 | user_id = db.Column(db.Integer, db.ForeignKey("users.id")) 11 | status = db.Column(db.String, default="unverified") 12 | code = db.Column(db.String, nullable=True) 13 | code_valid_until = db.Column(db.DateTime, nullable=True) 14 | 15 | def save_to_db(self): 16 | db.session.add(self) 17 | db.session.commit() 18 | 19 | class VerificationSchema(ma.SQLAlchemyAutoSchema): 20 | class Meta: 21 | model = Verification 22 | fields = ("status",) 23 | 24 | 25 | class User(db.Model): 26 | __tablename__ = 'users' 27 | id = db.Column(db.Integer, primary_key=True) 28 | email = db.Column(db.String, nullable=False, unique=True) 29 | name = db.Column(db.String, nullable=False) 30 | password = db.Column(db.String, nullable=False) 31 | role = db.Column(db.String, default="user") 32 | status = db.Column(db.String, default="active") 33 | verification = db.relationship("Verification", uselist=False, backref="verification") 34 | 35 | 36 | def save_to_db(self): 37 | db.session.add(self) 38 | db.session.commit() 39 | 40 | @classmethod 41 | def find_by_email(cls, email): 42 | return cls.query.filter_by(email=email).first() 43 | 44 | @staticmethod 45 | def generate_hash(password): 46 | return generate_password_hash(password) 47 | 48 | @staticmethod 49 | def verify_hash(password, hash): 50 | return check_password_hash(hash, password) 51 | 52 | class UserSchema(ma.SQLAlchemyAutoSchema): 53 | verification = ma.Nested(VerificationSchema()) 54 | class Meta: 55 | model = User 56 | fields = ("id", "name", "email", "role", "status", "verification") 57 | 58 | class RevokedTokenModel(db.Model): 59 | __tablename__ = 'revoked_tokens' 60 | 61 | id = db.Column(db.Integer, primary_key=True) 62 | jti = db.Column(db.String(120)) 63 | 64 | def add(self): 65 | db.session.add(self) 66 | db.session.commit() 67 | 68 | @classmethod 69 | def is_jti_blacklisted(cls, jti): 70 | query = cls.query.filter_by(jti=jti).first() 71 | return bool(query) -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | background-color: #fff; 10 | --ifm-color-primary: #0891B2; /* Base cyan */ 11 | --ifm-color-primary-dark: #067c9b; /* Slightly darker */ 12 | --ifm-color-primary-darker: #056b86; /* More contrast */ 13 | --ifm-color-primary-darkest: #04556b; /* Deepest tone */ 14 | --ifm-color-primary-light: #0ba9cc; /* Slightly lighter */ 15 | --ifm-color-primary-lighter: #FDFCF7; 16 | --ifm-color-primary-lightest: #a5e9f3;/* Very light, almost pastel */ 17 | --ifm-code-font-size: 95%; 18 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.05); 19 | 20 | } 21 | 22 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 23 | [data-theme='dark'] { 24 | background-color: #20201D; 25 | --ifm-color-primary: #0891B2; 26 | --ifm-color-primary-dark: #155e75; 27 | --ifm-color-primary-darker: #164e63; 28 | --ifm-color-primary-darkest: #0f3a4a; 29 | --ifm-color-primary-light: #22b8cf; 30 | --ifm-color-primary-lighter: #67d4e3; 31 | --ifm-color-primary-lightest: #b2ebf2; 32 | --ifm-code-font-size: 95%; 33 | --docusaurus-highlighted-code-line-bg: rgba(255, 255, 255, 0.05); 34 | } 35 | 36 | /* typography.css */ 37 | h1 { 38 | font-size: 3rem; /* 48px */ 39 | line-height: 1.2; /* ~57.6px */ 40 | font-weight: 700; 41 | } 42 | 43 | h2 { 44 | font-size: 2.25rem; /* 36px */ 45 | line-height: 1.25; /* ~45px */ 46 | font-weight: 600; 47 | } 48 | 49 | h3 { 50 | font-size: 1.875rem; /* 30px */ 51 | line-height: 1.33; /* ~40px */ 52 | font-weight: 600; 53 | } 54 | 55 | h4 { 56 | font-size: 1.5rem; /* 24px */ 57 | line-height: 1.5; /* 36px */ 58 | font-weight: 500; 59 | } 60 | 61 | h5 { 62 | font-size: 1.25rem; /* 20px */ 63 | line-height: 1.5; /* 30px */ 64 | font-weight: 500; 65 | } 66 | 67 | h6 { 68 | font-size: 1.125rem; /* 18px */ 69 | line-height: 1.5; /* 27px */ 70 | font-weight: 500; 71 | } 72 | 73 | p { 74 | font-size: 1rem; /* 16px */ 75 | line-height: 1.5; /* 24px */ 76 | } 77 | 78 | .center-both { 79 | display: flex; 80 | justify-content: center; /* horizontal */ 81 | align-items: center; /* vertical */ 82 | height: 200px; /* or any height */ 83 | text-align: center; 84 | } 85 | 86 | p.lead { 87 | font-size: 1.5rem; 88 | font-weight: 300; 89 | margin: 20 0 20 0; 90 | } -------------------------------------------------------------------------------- /docs/docs/07 - API/get-books-in-list.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: get-books-in-list 3 | title: "Get books in list" 4 | description: "Get books in list" 5 | sidebar_label: "Get books in list" 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJy1U01r20AQ/SvLnFJYbLlH3VJoQkoPoUnpwZiwksbSEmlXmRmZOkL/vYwkJ45xA23pSZrdt++9+eqhdeQaFCSGdN2DD5DCU4e0BwvBNQgpsDjpGCwQPnWesIB062pGC5xX2DhIe5B9O0HJhxKGwfZQIOfkW/FROa881oWRaDiSmGxvLlqKJSGzNa4osHgonKA14qVGa1wnVSRryIkPpTWErvChfJi8WOM5Cx/AnvcbSR6y/T8avouk0iZSgWQuHOcmklHM73RH5J+pbhTNbQyMrPcfk0Q/b518Q+kosMlifGTjg6k9i1oGxrwjL/uxdRk6QrrspIJ0vVFqcaV2FT7pQ9jYE95rlBNOCw1KFQtIoUQNW6dssNytliMSXjXvNKnJ9bHyIbqK1DiBFL78uIe5BJr6dAv2UIpKpB1z8WEb9Xkeg7hc9LejekZwulyWXqouW+SxWTbx+TmukiQZXdWxJBhOs7u8vTHbSMblOTJrJ7UMXxVrYZwySOFwZC5vb8DCDomn16tFskiUtI0sjQtqZ27zubK9kX5ps+BPWba180GZxmz6uaRr2K3AQnboTBVZ9LTvM8f4neph0ONpwrS7hWeX1UdD9Yj7493cubpTTRgn+RR84k9rjEH+31qeLcDs+GU7/9Ly+4v5jvJhP191NxqQV+FxZ47m//qzjq2bZ3pmfRne+UcbM1+5cJxS30+I+/iIYRjgYEE0hmEzDMMvecPc9Q== 9 | sidebar_class_name: "get api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Get books in list 40 | 41 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /docs/docs/07 - API/check-if-book-by-isbn-is-already-in-a-list.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: check-if-book-by-isbn-is-already-in-a-list 3 | title: "Check if book (by isbn) is already in a list." 4 | description: "Check if book (by isbn) is already in a list." 5 | sidebar_label: "Check if book (by isbn) is already in a list." 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJyVU8Fu2zAM/RWBpxYQYmfoSbd02IYMw1C0HXYIfJBtxhZiS65EZ3MN/ftA20mbrjvsZIkm+R4fn0botNctEvoAajdCiaHwpiPjLCjYPtx+F24vcucOIMFwrNNUgwSrWwQFJuQWJHh86o3HEhT5HiWEosZWgxqBhm7Ks4QVeogx4+zQORswcMKHNOXPJfA9Uu9tmICFKYW2pfhVaxKNCSQMCROEsSuIEm7Sm3/Xo/fOT9UthqArlMK6ueve9bYUxgptB+FRl8ZWU/sVxCghYNF7Q8OkSo7ao9/0VIPaZTwC6YoFg1vnDgEy+Qb/Y43FQZhZOXGVD4KFumbaumGwYUJe8CS0SLUrQUGFBHLWWEFyXCfcICQjl0d4ofXAAs8CviZ3un12vtUECr7+fIRlHbyG+S/I01pqom4a19i94/LCWdIF8bH3zZIRVJJUhuo+XxWuTVr3/OzWaZpO5BpXed7DpQCbu63Ys/RFgSGwtKzUN86VQIYaRj+FxOZuCxKO6MNcvV6lq5Sbdi5Qqy3TWQz3v8pe0DrbkfA3JV2jjWWUadJxUX0HxzVImHQHCWpyeCahdoH47zjmOuAP38TI4acePZskk3DU3uicJ+OHZAKfS1B73QT8iwgLjZZXdHW/PJ5r8ea9vUt2CWo7sGS66fkGEg44nN5jzOIrS335xBbQiz+W8rMRlgNTfrfzOM4Zj+6ANsYzEPGdkWL8A+xbbvY= 9 | sidebar_class_name: "get api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Check if book (by isbn) is already in a list. 40 | 41 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /docs/docs/07 - API/create-profile-for-logged-in-user.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: create-profile-for-logged-in-user 3 | title: "Create profile for logged in user" 4 | description: "Create profile for logged in user" 5 | sidebar_label: "Create profile for logged in user" 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJyNU02L2zAQ/Stmzm7slF7q2+7SwpZCQzelhxDK2J7Y2siSVpLTZI3+exnZ+XCXQi9GGs/He/OeBrD00pPz97o+QTFApZUn5fmIxkhRoRdaZc9OK465qqUO+WSsNmS9IMe3Wjgj8fRLYUd89ydDUIDzVqgGQgoH4UQppPBxSk077KWHAlpR16Qg/bsipBGZsFRDsZn3316ydflMlYcwT9+hdBQjzmjlRoTv83yc7CorDJOCAlZW74SkpLKEnuoFI/2Qf/x3IkpLWJ8SOgrn3SLidFT1NhLbDFASWrJ3vW+h2GwDQ8XGMYWphWP48+YPcXpiphk7bROpm4bqRKikd2QhhY58q2sowGjnIQWDPAGywzIz58ZXKE8s08j7FtD59lnbDnn7X36uuSpmQzH9vYrRem8iRZb/+9Uon47YGUlvhT/LN9f7LHJIQaidPtsMq2iz3spplCuyrBG+7ctFpbus06+vepnneVZqvZe6sdxhvrq71WPcF1YVOSdUk9xrvf/KuSl44RkknEPJ3eqRoZF1Y/VykS9ybspL7TA6fCLyP5rMoFw87+noMyNRRMKR3TDJtYHDkrW7cULLchYbGIYSHf2wMgQOv/Rk2VDbFA5oBZZMZLMNKbSENdnotT3xch/GF/tuzeM5XfYM483rZS9eXLT69sTKl9Or73TNNRZ/Qxq/BUAKOlKLLoqxASSqpseGc8eebA6cvDXxv5hoOjDS6Req0w3CYRgz1npPKgRIJ0Ke7xC2IYQ/mJ+Wyg== 9 | sidebar_class_name: "post api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Create profile for logged in user 40 | 41 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /api/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_jwt_extended import JWTManager, jwt_required 3 | from flask_cors import CORS 4 | from api.config import Config 5 | from flask_migrate import Migrate 6 | from api.models import db, ma 7 | from api.routes.books import books_endpoint 8 | from api.routes.profiles import profiles_endpoint 9 | from api.routes.notes import notes_endpoint 10 | from api.routes.tasks import tasks_endpoint 11 | from api.routes.files import files_endpoint 12 | from api.routes.settings import settings_endpoint 13 | from flasgger import Swagger 14 | from api.auth.auth_route import auth_endpoint, mail 15 | from api.auth.user_route import user_endpoint 16 | from api.commands.tasks import tasks_command 17 | from api.commands.user import user_command 18 | from pathlib import Path 19 | import tomllib 20 | import os 21 | from flask_mail import Mail 22 | from dotenv import load_dotenv 23 | 24 | load_dotenv() 25 | 26 | app = Flask(__name__) 27 | CORS(app) 28 | 29 | app.config.from_object(Config) 30 | swagger = Swagger(app) 31 | 32 | db.init_app(app) 33 | ma.init_app(app) 34 | migrate = Migrate(app, db) 35 | jwt = JWTManager(app) 36 | 37 | if os.getenv("AUTH_REQUIRE_VERIFICATION", "false").lower() == "true": 38 | app.config.update( 39 | MAIL_SERVER = os.environ.get("MAIL_SERVER", None), 40 | MAIL_PORT = os.environ.get("MAIL_PORT", 587), 41 | MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS", True), 42 | MAIL_USERNAME = os.environ.get("MAIL_USERNAME", None), 43 | MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD", None), 44 | MAIL_DEFAULT_SENDER = os.environ.get("MAIL_SENDER", os.environ.get("MAIL_USERNAME")), 45 | ) 46 | mail.init_app(app) 47 | else: 48 | mail = None 49 | 50 | if not os.path.exists(app.config["EXPORT_FOLDER"]): 51 | os.makedirs(app.config["EXPORT_FOLDER"]) 52 | 53 | app.register_blueprint(tasks_command) 54 | app.register_blueprint(user_command) 55 | 56 | app.register_blueprint(books_endpoint) 57 | app.register_blueprint(profiles_endpoint) 58 | app.register_blueprint(notes_endpoint) 59 | app.register_blueprint(tasks_endpoint) 60 | app.register_blueprint(files_endpoint) 61 | app.register_blueprint(settings_endpoint) 62 | 63 | app.register_blueprint(auth_endpoint) 64 | app.register_blueprint(user_endpoint) 65 | 66 | @app.route("/") 67 | def index(): 68 | version = "unknown" 69 | pyproject_toml_file = Path(__file__).parent.parent / "pyproject.toml" 70 | print(pyproject_toml_file) 71 | if pyproject_toml_file.exists() and pyproject_toml_file.is_file(): 72 | with open(pyproject_toml_file, "rb") as f: 73 | version = tomllib.load(f)["tool"]["poetry"]["version"] 74 | return { 75 | "name": "booklogr-api", 76 | "version": version 77 | } -------------------------------------------------------------------------------- /docs/docs/07 - API/edit-book.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: edit-book 3 | title: "Edit book" 4 | description: "Edit book" 5 | sidebar_label: "Edit book" 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJx1lF9vmzAQwL+KdU+txAKZtofxlnadlmpqqzbVHqpoMnABN2BT+0ibIr77dIaQdMmeMOf7fz9fC7W0skJC6yB+aiFDl1pVkzIaYph/F2YlEmPW4uzmdiHmDxc35xCA4staUgEBaFkhxKAyCMDiS6MsZhCTbTAAlxZYSYhboG3ttTRhjha6btlro6MLk21ZJTWaUBMfZV2XKpWcRfjsOJX2wFdtTY2WFDpv1liLmv7UMsdTkQKwkpTO+W5lbCUJYliVRhIEO2XdVEmv60hS4w78OLJs3HWjskmeMSXoWLQveCVLh17iaqNdn9vnKOLPx6ZecDvTQuocM+GaNEXnVk1ZbiecwJfo27HJPaYKN5gJbcT1w+2NUFokJustvp4K8qjX2rxqgdYaK0zqu5RNfB0O08Yq2vqBJygt2llDBcRPSx4LyZxZ8Hk6WAb/eL7KFHkkIIAKqTBZz0LKMHgmYgg305BVXNiqrIN9yAceYt+bw8C7vx+7+Vz/XsCAD7e8v93PqyCqfSkMx/0eo6s3WdUlHmMR7SmI9kMeh8tEr8wOQpl6CBtbDqFcHIa5oqJJJqmpwsq8v5tpFEW+xtLknpyPXZrdzcXKWCH9eJXOBbfzF+sGQIo4SdiJxOxuDgFs0LreejqJJhE7rY2jSnr+h3d22P4PIUdiCd8orEupNHvwVbTDYJ5gM4UA/GgggFhlPN/COOK7tk2kw0dbdh2LXxq0zMgygI20SiacM68I5fi8Y/4ojfEdw9n98DzOxf82ycmcB6HUW+6KLBv+gwDWuO03TbfsAihQZmh9Sv3FZR/404LN94ZHy4QhH8m9my0uf/qe9FuoMhkbWfnK+0y+9pGNr85z62UtlFLnjWcLeqeMoxxoHgoYsR0OnOrJ2tq211iYNequG0sl/udqu+4v0Mbo6A== 9 | sidebar_class_name: "patch api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Edit book 40 | 41 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /web/src/components/AccountTab.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Avatar, Button, Checkbox, Label, TextInput } from "flowbite-react"; 3 | import { RiMailLine } from "react-icons/ri"; 4 | import { HR } from "flowbite-react"; 5 | import { useTranslation, Trans } from 'react-i18next'; 6 | 7 | function AccountTab() { 8 | const [disableSaveButton, setDisableSaveButton] = useState(true); 9 | const { t } = useTranslation(); 10 | 11 | return ( 12 |
13 |
14 | 15 |
16 | 17 |
18 | 19 |
20 | 21 | 22 |
23 | 24 |
25 | 26 |
27 | 28 |
29 |
30 |

{t("forms.email")}

31 |
32 |
33 | 34 |
35 |
36 | 37 |
38 | 39 |
40 |
41 |

{t("forms.change_password")}

42 |
43 |
44 |
45 | 46 | 47 |
48 | 49 |
50 | 51 | 52 |
53 | 54 |
55 | 56 | 57 |
58 | 59 | 60 |
61 |
62 | 63 |
64 | ) 65 | } 66 | 67 | export default AccountTab -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 | 5 | 6 | 7 | 8 |

BookLogr

9 | 10 |

11 | A simple, self-hosted service to keep track of your personal library. 12 |
13 | 🗒️Explore the docs » 14 |
15 |
16 | 💻View Demo | 17 | 🐞Report Bug | 18 | ✨Request Feature | 19 | 👷Service status 20 |

21 |
22 | 23 | ## 👉About the project 24 | BookLogr is a web app designed to help you manage your personal book library with ease. This self-hosted service ensures that you have complete control over your data, providing a secure and private way to keep track of all the books you own, read, or wish to read. 25 | Optionally you can also display your library proudly to the public, sharing it with your friends and family. 26 | 27 | 28 | 29 | 30 | > [!IMPORTANT] 31 | > * This project is under **active** development. 32 | > * Expect bugs and breaking changes. 33 | 34 | ## ✨Features 35 | * Easily look up books by title or isbn. Powered by [OpenLibrary](https://openlibrary.org/) 36 | * Add books to predefined lists: _Reading_, _Already Read_ and _To Be Read_. 37 | * Track your current page in the book you are reading. 38 | * Share a public profile of your library with others. 39 | * Rate books you have read using a 0.5 to 5-star scale. 40 | * Take short notes and save quotes from the books you read. 41 | * Automatically share your reading progress to Mastodon. 42 | * Export your data in multiple formats, including CSV, JSON, and HTML. 43 | * Supports SQLite (default) or PostgreSQL as databases. 44 | 45 | ## 🖥 Install 46 | BookLogr is made to be self-hosted and run on your own hardware. 47 | 48 | Check out the [Getting Started guide](https://booklogr.app/docs/Getting%20started) for step-by-step instructions. 49 | 50 | ## 🛠️Development 51 | See [development instructions](https://github.com/Mozzo1000/booklogr/wiki/Development) on the wiki to get started. 52 | 53 | ## 🙌Contributing 54 | All contributions, from bug reports to feature requests and code submissions are welcome! 55 | 56 | If you’d like to contribute translations: 57 | - Visit our [translation guide](https://booklogr.app/docs/Developer/Translation) to get started. 58 | - Submit translations via pull request or get in touch through an issue. 59 | 60 | ## 🧾License 61 | This project is licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for the full license text. 62 | -------------------------------------------------------------------------------- /web/src/components/SortSelector.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Dropdown, DropdownHeader, DropdownItem, DropdownDivider, Modal, ModalBody, ModalHeader, ModalFooter, Button, Popover } from 'flowbite-react' 3 | import { RiArrowUpDownLine } from "react-icons/ri"; 4 | import { RiArrowDownLine } from "react-icons/ri"; 5 | import { RiArrowUpLine } from "react-icons/ri"; 6 | import { useTranslation } from 'react-i18next'; 7 | 8 | function SortSelector({sort, setSort, order, setOrder}) { 9 | const { t } = useTranslation(); 10 | 11 | const handleSort = (item) => { 12 | setSort(item); 13 | localStorage.setItem("last_sorted", JSON.stringify(item)); 14 | } 15 | 16 | const handleOrder = () => { 17 | if (order === "asc") { 18 | setOrder("desc"); 19 | localStorage.setItem("last_ordered", "desc"); 20 | } else { 21 | setOrder("asc"); 22 | localStorage.setItem("last_ordered", "asc"); 23 | } 24 | } 25 | 26 | const dropdownLabel = ( 27 | <> 28 | 29 |

{t("sort.sort_by", {sortType: sort.name ? sort.name.toLowerCase() : t("sort.title").toLowerCase()})}

30 | 31 | ) 32 | 33 | const displayPopoverContent = ( 34 |
35 |
36 |

{t("sort.order_by")}

37 |
38 |
39 | ) 40 | 41 | return ( 42 |
43 | 44 | handleSort({value: "title", name: t("sort.title")})}>{t("sort.title")} 45 | handleSort({value: "author", name: t("sort.author")})}>{t("sort.author")} 46 | handleSort({value: "progress", name: t("sort.progress")})}>{t("sort.progress")} 47 | handleSort({value: "rating", name: t("sort.rating")})}>{t("sort.rating")} 48 | handleSort({value: "created_on", name: t("sort.date_added")})}>{t("sort.date_added")} 49 | 50 | 51 | 58 | 59 |
60 | ) 61 | } 62 | 63 | export default SortSelector -------------------------------------------------------------------------------- /docs/docs/07 - API/edit-note.api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: edit-note 3 | title: "Edit note" 4 | description: "Edit note" 5 | sidebar_label: "Edit note" 6 | hide_title: true 7 | hide_table_of_contents: true 8 | api: eJx1lG1vmzAQgP8Kuk+bRAOJVnXjW7sXrdM0VV2mSYui6gIXcAM2tY90GeK/T2cgNGv3zT7f+z2+Fmq0WBGTdZCsWlAaEqiRCwhBY0WQgMogBEsPjbKUQcK2oRBcWlCFkLTAh9praaacLHTdutcmx1cmO4hKajSTZjliXZcqRVZGR/fOaJFNvmprarKsyP1jlpFLrarFDBL4ZpiC8TUcM3Bslc6hCyG1hEzZXe/+1PYDMgVmG2jvQxSV0cEr49+xfA0hbI2tkCGBDJnOWFX0UpCHxjDd1ZjT8yA3mPsgXFDg9SYHxz6FsFdObVSp+PCkj2OA7mhhNveUMnQimsawxdKRl7jaaNd3bBHH/+tWgTqnLHBNSs5tm7I8zCSH85csfuidNo86IGuNnflcHKWN9ZmuWtgQWrKXDReQrNYycMZc+PGxHKzDfxx+zBT7jkMIFXFhsp6yVDDztCUQ7eeRqLioVVkHU8jvgkdf39PA4+3TOK0vP5cwgClt61+nxhfMtS9FsLudAP34G6u6pBPgximcogSLePHmLL44W7xbzs+T83myeDuLL+a/4JSG+HS0T5hRemvGQJh6shtbDsm5JIpyxUWzmaWmiirz54+Zx3EcbYzZlSb3zJz29fLmOtgaG2AqU1U6D66M2X0V3RBYsZQFoyi4vLmGEPZkXW89n8WzWJzWxnGF/rMMf/7pwE5CHjll+s1RXaLS4sFX0Q6jXMF+LuvDsxBCojIhojCO5a1tN+johy27TsQPDVmhah3CHq3CjeS8aiFTTs4j6c/SmGb16nb4FK+Dadqn6Q1C1AdpAJaN3CCEHR36BdetuxAKwoysj94/vO9jnC3FfDJ8tsPkBxyxvrlcvv8MIWyG5VeZTIwsPsoaxcc+cr9vPNRe1kKJOm88P9A7FVZxQH0o4Mj0cJBUX6ytbXuNpdmR7rpjqSx3qbbr/gKNCgZq 9 | sidebar_class_name: "patch api-method" 10 | info_path: docs/07 - API/booklogr-api 11 | custom_edit_url: null 12 | --- 13 | 14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; 15 | import ParamsDetails from "@theme/ParamsDetails"; 16 | import RequestSchema from "@theme/RequestSchema"; 17 | import StatusCodes from "@theme/StatusCodes"; 18 | import OperationTabs from "@theme/OperationTabs"; 19 | import TabItem from "@theme/TabItem"; 20 | import Heading from "@theme/Heading"; 21 | 22 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 39 | Edit note 40 | 41 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /web/src/components/AddNoteModal.jsx: -------------------------------------------------------------------------------- 1 | import {useState} from 'react' 2 | import { Modal, ModalBody, ModalHeader, Label, Textarea, ToggleSwitch, ModalFooter, Button } from 'flowbite-react' 3 | import BooksService from '../services/books.service'; 4 | import useToast from '../toast/useToast'; 5 | import { useTranslation } from 'react-i18next'; 6 | 7 | function AddNoteModal({bookID, open, setOpen, onSave}) { 8 | const [publicSwitch, setPublicSwitch] = useState(false); 9 | const [noteContent, setNoteContent] = useState(); 10 | 11 | const toast = useToast(4000); 12 | const { t } = useTranslation(); 13 | 14 | const save = () => { 15 | let data = {} 16 | data.content = noteContent; 17 | 18 | if(publicSwitch) { 19 | data.visibility = "public"; 20 | } 21 | 22 | BooksService.addNote(bookID, data).then( 23 | response => { 24 | toast("success", response.data.message) 25 | setNoteContent(); 26 | onSave(); 27 | setOpen(false); 28 | setPublicSwitch(false); 29 | }, 30 | error => { 31 | const resMessage = 32 | (error.response && 33 | error.response.data && 34 | error.response.data.message) || 35 | error.message || 36 | error.toString(); 37 | toast("error", resMessage); 38 | } 39 | ) 40 | } 41 | 42 | return ( 43 | setOpen(false)}> 44 | {t("notes.add_note")} 45 | 46 |
47 |
48 |
49 | 50 |
51 |