├── .python-version ├── app ├── utils │ ├── initialization.py │ ├── metadata_aggregator.py │ ├── __init__.py │ ├── placeholder_covers.py │ ├── password_policy.py │ └── simple_cache.py ├── services │ └── stats_service.py ├── templates │ ├── people_table.html │ ├── auth │ │ ├── simple_setup.html │ │ ├── login.html │ │ ├── profile.html │ │ ├── forced_password_change.html │ │ └── change_password.html │ ├── import_books_clean.html │ ├── import_books_fixed.html │ ├── settings │ │ └── partials │ │ │ ├── data_backup.html │ │ │ ├── server_dashboard.html │ │ │ ├── data_import_books.html │ │ │ ├── server_system.html │ │ │ ├── privacy_form.html │ │ │ ├── profile_form.html │ │ │ ├── server_users.html │ │ │ ├── password_form.html │ │ │ ├── server_backup.html │ │ │ ├── personal_abs.html │ │ │ ├── reading_prefs.html │ │ │ └── server_users_full.html │ ├── import_reading_quick_add_result.html │ ├── macros │ │ ├── forms.html │ │ ├── cover_input.html │ │ └── custom_field_inline.html │ ├── partials │ │ ├── _accordion_styles.html │ │ ├── _quick_add_preview_summary.html │ │ └── _quick_add_result_summary.html │ ├── import_reading_quick_add_confirm.html │ ├── community_stats │ │ ├── recent_activity.html │ │ ├── books_this_month.html │ │ ├── currently_reading.html │ │ └── active_readers.html │ ├── simple_backup │ │ └── restore_complete.html │ ├── month_wrapup_empty.html │ ├── search_books.html │ ├── onboarding │ │ └── error.html │ ├── series │ │ └── list_series.html │ ├── genres │ │ └── add.html │ ├── components │ │ └── debug_panel.html │ ├── migration │ │ └── success.html │ ├── locations │ │ ├── edit.html │ │ └── add.html │ ├── admin │ │ └── partials │ │ │ └── backup_config.html │ └── edit_book.html ├── static │ ├── bookshelf.webp │ ├── github-mark.png │ ├── quagga.min.js │ ├── bootstrap-icons │ │ ├── bootstrap-icons.woff │ │ └── bootstrap-icons.woff2 │ ├── templates │ │ └── reading_history_template.csv │ ├── default-book-cover-info.md │ ├── default-book-cover.svg │ └── js │ │ └── library_perf.js ├── api │ └── __init__.py ├── domain │ └── __init__.py ├── infrastructure │ └── __init__.py ├── routes │ ├── cover_routes.py │ ├── db_health_routes.py │ ├── genre_taxonomy_routes.py │ └── misc_routes.py ├── template_filters │ ├── cover_filters.py │ └── markdown_filters.py ├── services.py └── models.py ├── scripts ├── setup_data_directories.py ├── migrations │ ├── migrate_db_schema.py │ ├── migrate_security_features.py │ └── migrate_db.py ├── adjust_cover_logging.py ├── setup_data_dir.bat ├── run_postgres_export.sh ├── install_ocr.bat ├── detect_migration.py ├── install_ocr.sh ├── README.md └── setup_data_dir.py ├── migrate.sh ├── requirements.txt ├── .env.docker.example ├── .github ├── workflows │ ├── issue-to-discord.yml │ └── new-commit.yml └── copilot-setup-steps.yaml ├── pyproject.toml ├── LICENSE ├── run.py ├── docs ├── LICENSE ├── CROSS_PLATFORM.md └── RATE_LIMITING.md ├── pytest.ini ├── prompts └── book_extraction.mustache ├── docker-compose.dev.yml ├── docker-compose.yml ├── .dockerignore ├── .env.example ├── restore_covers.py ├── regression_checks └── import_regressions.py ├── DOCKER.md ├── CHANGELOG.md ├── tests └── test_unified_metadata.py └── force_schema_init.py /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /app/utils/initialization.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/services/stats_service.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/templates/people_table.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/setup_data_directories.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/templates/auth/simple_setup.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/templates/import_books_clean.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/templates/import_books_fixed.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/static/bookshelf.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pickles4evaaaa/mybibliotheca/HEAD/app/static/bookshelf.webp -------------------------------------------------------------------------------- /app/static/github-mark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pickles4evaaaa/mybibliotheca/HEAD/app/static/github-mark.png -------------------------------------------------------------------------------- /app/static/quagga.min.js: -------------------------------------------------------------------------------- 1 | // Quagga2 v1.2.6 minified JS (downloaded for self-hosting) 2 | // ...actual minified JS would be here... 3 | -------------------------------------------------------------------------------- /app/static/bootstrap-icons/bootstrap-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pickles4evaaaa/mybibliotheca/HEAD/app/static/bootstrap-icons/bootstrap-icons.woff -------------------------------------------------------------------------------- /app/static/bootstrap-icons/bootstrap-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pickles4evaaaa/mybibliotheca/HEAD/app/static/bootstrap-icons/bootstrap-icons.woff2 -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | API package for Bibliotheca 3 | 4 | Provides RESTful API endpoints for the graph database backend. 5 | Follows API-first architecture principles. 6 | """ 7 | -------------------------------------------------------------------------------- /app/domain/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Domain layer - Core business logic and models. 3 | 4 | This module contains the core domain models and business logic, 5 | isolated from external concerns like databases and frameworks. 6 | """ 7 | -------------------------------------------------------------------------------- /app/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Graph database infrastructure adapters. 3 | 4 | This module contains the Kuzu-based implementations of the repository interfaces, 5 | following the hexagonal architecture pattern. 6 | """ 7 | -------------------------------------------------------------------------------- /app/static/templates/reading_history_template.csv: -------------------------------------------------------------------------------- 1 | Date,Book Name,Start Page,End Page,Pages Read,Minutes Read 2 | 2024-01-15,The Great Gatsby,1,50,50,120 3 | 2024-01-16,To Kill a Mockingbird,,,,90 4 | 2024-01-17,1984,150,200,,135 5 | 2024-01-18,Pride and Prejudice,,,25, 6 | 2024-01-19,,,,,45 7 | -------------------------------------------------------------------------------- /app/templates/settings/partials/data_backup.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Backups 5 |
6 |
7 |

Manage, create, and download backups from the dedicated manager.

8 | Open Backup Manager 9 |
10 |
11 | -------------------------------------------------------------------------------- /app/templates/settings/partials/server_dashboard.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Admin Dashboard 5 |
6 |
7 |
8 | {% for stat in dashboard_stats %} 9 |
10 |
11 |
{{ stat.value }}
12 |
{{ stat.label }}
13 |
14 |
15 | {% endfor %} 16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /app/static/default-book-cover-info.md: -------------------------------------------------------------------------------- 1 | # Default Book Cover Placeholder 2 | 3 | This is a placeholder for the default book cover image. In a real implementation, you would place a default book cover image here at: 4 | 5 | /Users/jeremiah/Documents/Python Projects/bibliotheca/app/static/default-book-cover.png 6 | 7 | For now, the timeline will gracefully handle missing cover images by showing colored rectangles instead. 8 | 9 | You can add a default book cover image by: 10 | 1. Creating or finding a suitable book cover placeholder image 11 | 2. Saving it as `default-book-cover.png` in the `app/static/` directory 12 | 3. The timeline will automatically use it for books without cover images 13 | -------------------------------------------------------------------------------- /app/static/default-book-cover.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 📖 10 | 11 | -------------------------------------------------------------------------------- /app/templates/settings/partials/data_import_books.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Import Books 5 |
6 |
7 |

Import books from CSV/TSV files with field mapping and validation.

8 |

Use the dedicated import workflow page to upload and process your book data files.

9 | 12 |
13 |
14 | -------------------------------------------------------------------------------- /migrate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Bibliotheca Migration Helper 3 | # 4 | # This script has been simplified to focus on web-based migration. 5 | # Use the web interface at http://localhost:5054/migration/wizard for migration. 6 | 7 | echo "🔄 Bibliotheca Migration Helper" 8 | echo "================================" 9 | echo "" 10 | echo "✨ For the best migration experience, use the web interface:" 11 | echo "🌐 Visit: http://localhost:5054/migration/wizard" 12 | echo "" 13 | echo "📋 The web interface provides:" 14 | echo " • Visual progress tracking" 15 | echo " • Error handling with friendly messages" 16 | echo " • Automatic user assignment" 17 | echo " • Backup creation" 18 | echo "" 19 | echo "🚀 To start your application:" 20 | echo " docker-compose up -d" 21 | echo "" 22 | echo "📖 For documentation:" 23 | echo " See docs/MIGRATION.md" 24 | -------------------------------------------------------------------------------- /app/templates/import_reading_quick_add_result.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Quick Add Reading Logs · Result{% endblock %} 3 | {% block content %} 4 |
5 |
6 |
7 |
8 |
9 | 10 | Quick Add Results 11 |
12 |
13 | {% include 'partials/_quick_add_result_summary.html' %} 14 |
15 | Close Window 16 |
17 |
18 |
19 |
20 |
21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /app/routes/cover_routes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify, request 2 | from app.services.cover_service import cover_service 3 | 4 | cover_bp = Blueprint('cover_api', __name__, url_prefix='/api/cover') 5 | 6 | @cover_bp.route('/schedule', methods=['POST']) 7 | def schedule_cover(): 8 | data = request.get_json(force=True, silent=True) or {} 9 | isbn = data.get('isbn') 10 | title = data.get('title') 11 | author = data.get('author') 12 | prefer = data.get('prefer_provider') 13 | job = cover_service.schedule_async_processing(isbn=isbn, title=title, author=author, prefer_provider=prefer) 14 | return jsonify({'job': job}), 202 15 | 16 | @cover_bp.route('/status/', methods=['GET']) 17 | def cover_status(job_id): 18 | job = cover_service.get_job(job_id) 19 | if not job: 20 | return jsonify({'error': 'not_found'}), 404 21 | return jsonify({'job': job}) 22 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==3.1.2 2 | Flask-Login==0.6.3 3 | Flask-WTF==1.2.2 4 | Flask-Session==0.8.0 5 | WTForms==3.2.1 6 | Flask-Mail==0.10.0 7 | Werkzeug==3.1.3 8 | requests==2.32.5 9 | python-dotenv==1.2.1 10 | Pillow>=12.0.0 11 | pytz==2025.2 12 | email-validator==2.3.0 13 | Flask-Compress==1.23 14 | 15 | # OCR and Barcode Detection Dependencies 16 | opencv-python==4.10.0.84 17 | pyzbar==0.1.9 18 | pytesseract==0.3.13 19 | numpy>=1.26.0,<2.0.0 20 | gunicorn==23.0.0 21 | psutil==7.1.3 22 | scrypt==0.9.4 23 | cryptography>=46.0.3 24 | 25 | # Optional Redis for production sessions 26 | redis>=7.0.1 27 | 28 | # Graph Database Dependencies 29 | kuzu>=0.11.3 30 | psycopg[binary]>=3.2.12 31 | 32 | # Testing dependencies 33 | pytest==9.0.1 34 | pytest-flask==1.3.0 35 | pytest-cov==7.0.0 36 | 37 | # AI Service Dependencies 38 | pystache==0.6.8 39 | 40 | # Markdown support for personal notes 41 | mistune==3.0.2 -------------------------------------------------------------------------------- /app/template_filters/cover_filters.py: -------------------------------------------------------------------------------- 1 | """ 2 | Template filters for unified cover management. 3 | """ 4 | from flask import Blueprint 5 | from app.services.unified_cover_manager import cover_manager 6 | 7 | def register_cover_filters(app): 8 | """Register cover-related template filters.""" 9 | 10 | @app.template_filter('cover_info') 11 | def get_cover_info_filter(book): 12 | """Get cover info for a book.""" 13 | return cover_manager.get_cover_info(book) 14 | 15 | @app.template_filter('has_cover') 16 | def has_cover_filter(book): 17 | """Check if book has a valid cover.""" 18 | return cover_manager.get_cover_info(book).has_cover 19 | 20 | @app.template_filter('cover_html') 21 | def cover_html_filter(book, css_classes="", style="", img_id=""): 22 | """Generate cover HTML for a book.""" 23 | return cover_manager.render_cover_html(book, css_classes, style, img_id) 24 | -------------------------------------------------------------------------------- /app/utils/metadata_aggregator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Metadata Aggregator facade. 3 | 4 | This module provides a stable import path for unified book metadata 5 | aggregation. It delegates to app.utils.unified_metadata which merges 6 | Google Books and OpenLibrary data and normalizes dates. 7 | 8 | Use cases: 9 | - fetch_unified_by_isbn(isbn): merged metadata for a single ISBN 10 | - fetch_unified_by_title(title, max_results=10, author=None): ranked search results, author-aware 11 | 12 | Note: Prefer importing from this module in new code to make future 13 | internal changes transparent to callers. 14 | """ 15 | 16 | from __future__ import annotations 17 | 18 | from typing import Any, Dict, List, Optional 19 | 20 | # Re-export from the canonical implementation 21 | from .unified_metadata import ( 22 | fetch_unified_by_isbn, 23 | fetch_unified_by_title, 24 | ) 25 | 26 | __all__ = [ 27 | 'fetch_unified_by_isbn', 28 | 'fetch_unified_by_title', 29 | ] 30 | 31 | -------------------------------------------------------------------------------- /.env.docker.example: -------------------------------------------------------------------------------- 1 | # Docker Environment Configuration for MyBibliotheca 2 | # Copy this file to .env and fill in your values 3 | 4 | # REQUIRED: Security Configuration 5 | # Generate secure values using: python3 -c "import secrets; print(secrets.token_urlsafe(32))" 6 | SECRET_KEY=your-secret-key-here 7 | SECURITY_PASSWORD_SALT=your-salt-here 8 | 9 | # Application Settings 10 | TIMEZONE=UTC 11 | FLASK_DEBUG=false 12 | # Password policy (minimum allowed 8, maximum 128) 13 | PASSWORD_MIN_LENGTH=12 14 | 15 | # KuzuDB Configuration (DO NOT CHANGE for Docker) 16 | KUZU_DB_PATH=/app/data/kuzu 17 | GRAPH_DATABASE_ENABLED=true 18 | 19 | # Worker Configuration (DO NOT CHANGE - KuzuDB requires single worker) 20 | WORKERS=1 21 | 22 | # Logging Configuration 23 | MYBIBLIOTHECA_VERBOSE_INIT=false 24 | 25 | # Migration Settings (usually disabled in Docker) 26 | AUTO_MIGRATE=false 27 | MIGRATION_DEFAULT_USER=admin 28 | 29 | # Optional: Reading streak settings 30 | READING_STREAK_OFFSET=0 31 | -------------------------------------------------------------------------------- /.github/workflows/issue-to-discord.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Notify Discord on New Issues 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | discord: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Send Discord Notification 12 | uses: tsickert/discord-webhook@v5.3.0 13 | with: 14 | webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} 15 | username: "GitHub Issues Bot" 16 | avatar-url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" 17 | embed-title: "🐛 New Issue: ${{ github.event.issue.title }}" 18 | embed-description: | 19 | **Opened by:** ${{ github.event.issue.user.login }} 20 | **Repository:** ${{ github.repository }} 21 | 22 | ${{ github.event.issue.body }} 23 | embed-url: ${{ github.event.issue.html_url }} 24 | embed-color: 15158332 25 | embed-timestamp: ${{ github.event.issue.created_at }} -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "bibliotheca" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.13" 7 | dependencies = [ 8 | "flask>=3.1.2", 9 | "flask-login>=0.6.3", 10 | "flask-wtf>=1.2.2", 11 | "flask-session>=0.8.0", 12 | "wtforms>=3.2.1", 13 | "flask-mail>=0.10.0", 14 | "werkzeug>=3.1.3", 15 | "requests>=2.32.5", 16 | "python-dotenv>=1.2.1", 17 | "pillow>=12.0.0", 18 | "pytz>=2025.2", 19 | "email-validator>=2.3.0", 20 | "flask-compress>=1.23", 21 | "opencv-python>=4.10.0.84", 22 | "pyzbar>=0.1.9", 23 | "pytesseract>=0.3.13", 24 | "numpy>=1.26.0,<2.0.0", 25 | "gunicorn>=23.0.0", 26 | "psutil>=7.1.3", 27 | "scrypt>=0.9.4", 28 | "cryptography>=46.0.3", 29 | "redis>=7.0.1", 30 | "kuzu>=0.11.3", 31 | "psycopg[binary]>=3.2.12", 32 | "pytest>=9.0.1", 33 | "pytest-flask>=1.3.0", 34 | "pytest-cov>=7.0.0", 35 | "pystache>=0.6.8", 36 | ] 37 | -------------------------------------------------------------------------------- /app/templates/macros/forms.html: -------------------------------------------------------------------------------- 1 | {% macro csrf_token() %} 2 | 3 | {% endmacro %} 4 | 5 | {% macro form_start(action="", method="post", class="", id="", enctype="") %} 6 |
7 | {{ csrf_token() }} 8 | {% endmacro %} 9 | 10 | {% macro form_end() %} 11 |
12 | {% endmacro %} 13 | 14 | {% macro submit_button(text="Submit", class="btn btn-primary", id="") %} 15 | 16 | {% endmacro %} 17 | 18 | {% macro delete_button(text="Delete", class="btn btn-danger", id="", onclick="") %} 19 | 20 | {% endmacro %} 21 | -------------------------------------------------------------------------------- /app/templates/settings/partials/server_system.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | System Info 5 |
6 |
7 |
8 |
9 |
10 |
Current Admin
11 |
{{ info.user }}
12 |
13 |
14 |
15 |
16 |
UTC Time
17 |
{{ info.time }}
18 |
19 |
20 |
21 |
22 |
Version
23 |
MyBibliotheca
24 |
25 |
26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /scripts/migrations/migrate_db_schema.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Database Schema Migration Script for MyBibliotheca 4 | 5 | ⚠️ DEPRECATED: This manual migration script is no longer needed. 6 | Database migrations now run automatically when the application starts. 7 | See MIGRATION.md for details. 8 | 9 | This script adds the new security and privacy fields to the user table. 10 | """ 11 | 12 | import sqlite3 13 | import os 14 | import sys 15 | 16 | def main(): 17 | print("⚠️ WARNING: This migration script is deprecated.") 18 | print("Database migrations now run automatically when the application starts.") 19 | print("See MIGRATION.md for details.") 20 | print() 21 | print("The automatic migration system includes:") 22 | print(" - Database backup before migration") 23 | print(" - All security and privacy field additions") 24 | print(" - Multi-user system migration") 25 | print(" - Reading log updates") 26 | print() 27 | print("No manual migration is needed!") 28 | return True 29 | 30 | if __name__ == "__main__": 31 | main() 32 | -------------------------------------------------------------------------------- /app/templates/settings/partials/privacy_form.html: -------------------------------------------------------------------------------- 1 | {% with msgs = get_flashed_messages(with_categories=True) %} 2 | {% if msgs %} 3 |
4 | {% for c,m in msgs %}
{{ m }}
{% endfor %} 5 |
6 | {% endif %} 7 | {% endwith %} 8 |
9 | 10 |
11 | {{ form.share_current_reading(class='form-check-input') }} {{ form.share_current_reading.label(class='form-check-label') }} 12 |
13 |
14 | {{ form.share_reading_activity(class='form-check-input') }} {{ form.share_reading_activity.label(class='form-check-label') }} 15 |
16 | 17 |
18 | 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 pickles 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | 3 | # Load environment variables from .env file 4 | load_dotenv() 5 | 6 | """Application entry point. 7 | 8 | Performs a schema preflight (additive column auto-upgrade with backup) before 9 | creating the Flask app so migrations happen deterministically at startup. 10 | """ 11 | 12 | # Import triggers preflight side-effect (safe no-op if nothing to change) 13 | from app.startup import schema_preflight # noqa: F401 14 | 15 | from app import create_app 16 | 17 | # This app is intended to be run via Gunicorn only 18 | app = create_app() 19 | if __name__ == '__main__': 20 | import os 21 | import sys 22 | 23 | command = [ 24 | "gunicorn", 25 | "-w", "1", 26 | "-b", "0.0.0.0:5054", 27 | "run:app" 28 | ] 29 | 30 | print(f"🚀 Launching Gunicorn with command: {' '.join(command)}") 31 | try: 32 | os.execvp(command[0], command) 33 | except FileNotFoundError: 34 | print("Error: 'gunicorn' command not found.", file=sys.stderr) 35 | print("Please install Gunicorn: pip install gunicorn", file=sys.stderr) 36 | sys.exit(1) 37 | -------------------------------------------------------------------------------- /docs/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Pickles 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /scripts/adjust_cover_logging.py: -------------------------------------------------------------------------------- 1 | """Utility to temporarily downgrade normal cover service logs from ERROR to INFO. 2 | Run this once early in app startup (optional) if you want quieter logs while keeping failures at ERROR. 3 | """ 4 | from app.services.cover_service import cover_service # noqa: F401 5 | from flask import current_app 6 | 7 | def downgrade_cover_logging(): 8 | try: 9 | # Monkey patch logger call inside CoverService to use info for non-fatal paths 10 | import app.services.cover_service as cs 11 | orig = cs.cover_service.fetch_and_cache 12 | def wrapper(*a, **kw): 13 | res = orig(*a, **kw) 14 | # The original implementation logs at ERROR internally; optionally suppress or re-log 15 | # Here we just return result; future refactor could add a verbosity flag. 16 | return res 17 | cs.cover_service.fetch_and_cache = wrapper # type: ignore 18 | current_app.logger.info('[COVER][LOGGING] Downgraded cover service logging wrapper installed') 19 | except Exception as e: 20 | current_app.logger.error(f'[COVER][LOGGING] Failed to downgrade logging: {e}') 21 | -------------------------------------------------------------------------------- /app/templates/settings/partials/profile_form.html: -------------------------------------------------------------------------------- 1 | {% with msgs = get_flashed_messages(with_categories=True) %} 2 | {% if msgs %} 3 |
4 | {% for c,m in msgs %}
{{ m }}
{% endfor %} 5 |
6 | {% endif %} 7 | {% endwith %} 8 |
9 | 10 |
11 | {{ form.username.label(class='form-label') }} 12 | {{ form.username(class='form-control') }} 13 | {% for e in form.username.errors %}
{{ e }}
{% endfor %} 14 |
15 |
16 | {{ form.email.label(class='form-label') }} 17 | {{ form.email(class='form-control') }} 18 | {% for e in form.email.errors %}
{{ e }}
{% endfor %} 19 |
20 | 21 |
22 | -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # Utils package for Bibliotheca 2 | 3 | # Import functions from the book_utils module 4 | from .book_utils import ( 5 | fetch_book_data, 6 | get_google_books_cover, 7 | fetch_author_data, 8 | generate_month_review_image, 9 | normalize_goodreads_value, 10 | search_author_by_name, 11 | search_book_by_title_author, 12 | search_multiple_books_by_title_author, 13 | search_google_books_by_title_author 14 | ) 15 | 16 | # Import functions from the user_utils module 17 | from .user_utils import ( 18 | calculate_reading_streak, 19 | get_reading_streak 20 | ) 21 | 22 | # Unified metadata aggregation 23 | from .unified_metadata import ( 24 | fetch_unified_by_isbn, 25 | fetch_unified_by_title, 26 | ) 27 | 28 | __all__ = [ 29 | 'fetch_book_data', 30 | 'get_google_books_cover', 31 | 'fetch_author_data', 32 | 'generate_month_review_image', 33 | 'normalize_goodreads_value', 34 | 'search_author_by_name', 35 | 'search_book_by_title_author', 36 | 'search_multiple_books_by_title_author', 37 | 'search_google_books_by_title_author', 38 | 'calculate_reading_streak', 39 | 'get_reading_streak', 40 | 'fetch_unified_by_isbn', 41 | 'fetch_unified_by_title', 42 | ] 43 | -------------------------------------------------------------------------------- /scripts/setup_data_dir.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | REM Windows batch script to set up data directory for MyBibliotheca 3 | REM This ensures parity with Docker environment setup on Windows 4 | 5 | echo Setting up data directory for standalone execution on Windows... 6 | echo This ensures parity with Docker environment setup 7 | 8 | REM Create data directory if it doesn't exist 9 | if not exist "data" ( 10 | mkdir data 11 | echo ✓ Data directory created: %CD%\data 12 | ) else ( 13 | echo ✓ Data directory exists: %CD%\data 14 | ) 15 | 16 | REM Create empty database file if it doesn't exist (matches Docker setup) 17 | if not exist "data\books.db" ( 18 | type nul > "data\books.db" 19 | echo ✓ Database file created: %CD%\data\books.db 20 | ) else ( 21 | echo ✓ Database file exists: %CD%\data\books.db 22 | ) 23 | 24 | echo. 25 | echo Data directory setup complete for Windows! 26 | echo Data directory: %CD%\data 27 | echo Database path: %CD%\data\books.db 28 | echo Permissions: Using Windows default file permissions 29 | echo. 30 | echo You can now run MyBibliotheca using: 31 | echo python -m gunicorn -w 4 -b 0.0.0.0:5054 run:app 32 | echo. 33 | echo Or if you have gunicorn installed globally: 34 | echo gunicorn -w 4 -b 0.0.0.0:5054 run:app 35 | 36 | pause 37 | -------------------------------------------------------------------------------- /app/templates/settings/partials/server_users.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | User Management (snapshot) 5 |
6 |
7 |

Showing up to 50 users. Open full user admin for more actions.

8 |
9 | 10 | 11 | 12 | {% for u in users %} 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% endfor %} 20 | 21 |
UsernameEmailRoleCreated
{{ u.username }}{{ u.email }}{% if u.is_admin %}Admin{% else %}User{% endif %}{{ u.created_at.strftime('%Y-%m-%d') if u.created_at else '' }}
22 |
23 | Open Full User Admin 24 |
25 |
26 | -------------------------------------------------------------------------------- /app/templates/partials/_accordion_styles.html: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /app/services.py: -------------------------------------------------------------------------------- 1 | """ 2 | Legacy services module - now imports from the new services package. 3 | 4 | This module is kept for backward compatibility with existing imports. 5 | All service implementations have been moved to the services/ package. 6 | """ 7 | 8 | # Import all services from the new organized structure 9 | from .services import ( 10 | # Main service instances 11 | book_service, 12 | user_service, 13 | 14 | # Service classes for direct instantiation if needed 15 | KuzuServiceFacade, 16 | KuzuUserService, 17 | KuzuBookService, # Alias for KuzuServiceFacade 18 | 19 | # Utility functions 20 | run_async, 21 | 22 | # Stub services for compatibility 23 | reading_log_service, 24 | custom_field_service, 25 | import_mapping_service, 26 | direct_import_service, 27 | job_service 28 | ) 29 | 30 | # Export everything for backward compatibility 31 | __all__ = [ 32 | # Service instances (most commonly used) 33 | 'book_service', 34 | 'user_service', 35 | 'reading_log_service', 36 | 'custom_field_service', 37 | 'import_mapping_service', 38 | 'direct_import_service', 39 | 'job_service', 40 | 41 | # Service classes 42 | 'KuzuServiceFacade', 43 | 'KuzuUserService', 44 | 'KuzuBookService', 45 | 46 | # Utilities 47 | 'run_async' 48 | ] 49 | -------------------------------------------------------------------------------- /app/templates/settings/partials/password_form.html: -------------------------------------------------------------------------------- 1 | {% with msgs = get_flashed_messages(with_categories=True) %} 2 | {% if msgs %} 3 |
4 | {% for c,m in msgs %}
{{ m }}
{% endfor %} 5 |
6 | {% endif %} 7 | {% endwith %} 8 |
9 | 10 |
{{ form.current_password.label(class='form-label') }}{{ form.current_password(class='form-control') }}{% for e in form.current_password.errors %}
{{ e }}
{% endfor %}
11 |
{{ form.new_password.label(class='form-label') }}{{ form.new_password(class='form-control') }}{% for e in form.new_password.errors %}
{{ e }}
{% endfor %}
12 |
{{ form.new_password2.label(class='form-label') }}{{ form.new_password2(class='form-control') }}{% for e in form.new_password2.errors %}
{{ e }}
{% endfor %}
13 | 14 |
15 | -------------------------------------------------------------------------------- /app/template_filters/markdown_filters.py: -------------------------------------------------------------------------------- 1 | """ 2 | Template filters for markdown rendering. 3 | """ 4 | import logging 5 | import mistune 6 | from markupsafe import Markup, escape 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | # Create a markdown instance with safe defaults 11 | # escape=True ensures user-provided HTML is escaped for security 12 | markdown = mistune.create_markdown( 13 | escape=True, # Escape HTML to prevent XSS 14 | plugins=['strikethrough', 'table', 'url'] 15 | ) 16 | 17 | def render_markdown(text): 18 | """ 19 | Convert markdown text to HTML. 20 | 21 | Args: 22 | text: Markdown text to convert (should be a string) 23 | 24 | Returns: 25 | Safe HTML markup 26 | """ 27 | if not text: 28 | return '' 29 | 30 | # Ensure text is a string 31 | if not isinstance(text, str): 32 | text = str(text) 33 | 34 | try: 35 | # Convert markdown to HTML (with HTML escaping enabled for security) 36 | html = markdown(text) 37 | # Return as safe markup so Jinja2 doesn't escape markdown-generated HTML 38 | return Markup(html) 39 | except Exception as e: 40 | # Mistune is very robust and rarely raises exceptions for valid strings 41 | # If it does fail (e.g., plugin issues), log and return escaped plain text 42 | # Note: Invalid markdown syntax doesn't raise exceptions - it renders as-is 43 | logger.warning(f"Markdown rendering failed: {type(e).__name__}: {e}") 44 | return Markup(f'

{escape(str(text))}

') 45 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # Pytest configuration for Phase 2 testing 3 | 4 | # Test discovery (include both package tests directory and root-level test_*.py files) 5 | # Removed explicit testpaths previously limiting discovery; pytest will search current dir. 6 | python_files = test_*.py 7 | python_classes = Test* 8 | python_functions = test_* 9 | 10 | # Output options 11 | addopts = 12 | -v 13 | --tb=short 14 | --strict-markers 15 | --color=yes 16 | 17 | filterwarnings = 18 | ignore:ast.Str is deprecated and will be removed in Python 3.14:DeprecationWarning 19 | ignore:Attribute s is deprecated and will be removed in Python 3.14:DeprecationWarning 20 | ignore:Constant.__init__ got an unexpected keyword argument 's':DeprecationWarning 21 | ignore:Constant.__init__ missing 1 required positional argument:DeprecationWarning 22 | ignore:'before_first_request' is deprecated:DeprecationWarning 23 | 24 | # Test markers 25 | markers = 26 | unit: Unit tests for individual components 27 | integration: Integration tests between components 28 | facade: Tests specific to the KuzuServiceFacade 29 | compatibility: Backward compatibility tests 30 | performance: Performance-related tests 31 | error_handling: Error handling and edge case tests 32 | asyncio: Async tests using event loop 33 | 34 | # Minimum version requirements 35 | minversion = 6.0 36 | 37 | # (Optional) timeouts plugin removed; re-add if pytest-timeout is installed 38 | 39 | # Coverage settings (if pytest-cov is available) 40 | # addopts = --cov=app.services --cov-report=term-missing --cov-report=html 41 | -------------------------------------------------------------------------------- /app/templates/partials/_quick_add_preview_summary.html: -------------------------------------------------------------------------------- 1 | {% if errors %} 2 | 10 | {% else %} 11 |

12 | You're about to add {{ days }} reading log{{ 's' if days != 1 else '' }} covering 13 | {{ start_date.strftime('%Y-%m-%d') if start_date else 'N/A' }} 14 | through 15 | {{ end_date.strftime('%Y-%m-%d') if end_date else 'N/A' }}. 16 |

17 | 22 | {% if sample_dates %} 23 |
24 |
Dates included
25 | 30 | {% if total_dates > sample_dates|length %} 31 |
…plus {{ total_dates - sample_dates|length }} more day{{ 's' if (total_dates - sample_dates|length) != 1 else '' }}.
32 | {% endif %} 33 |
34 | {% endif %} 35 | 38 | {% endif %} 39 | -------------------------------------------------------------------------------- /prompts/book_extraction.mustache: -------------------------------------------------------------------------------- 1 | You are an expert librarian with excellent vision. 2 | Analyze the provided image and extract book information to help identify and catalog the book. 3 | 4 | Please examine the image carefully and extract the following information if visible: 5 | 6 | **Primary Information (Required):** 7 | - Title: The complete book title 8 | - Authors: All authors listed (separate multiple authors with semicolons) 9 | - ISBN: ISBN-10 or ISBN-13 if visible 10 | 11 | **Additional Information (If Available):** 12 | - Subtitle: Any subtitle, typically found below the title 13 | - Edition: Edition information (if specified) 14 | - Contributors: Editors, translators, illustrators, foreword by, etc. Return as an array of objects with "name" and "role" fields 15 | - Series: Book series name and number/position, if given. This may be stated as book series or something like "A story/tale" 16 | - Language: Publication language (if not English) 17 | 18 | **Additional Context:** 19 | - Special Features: Awards, annotations, special editions, etc. 20 | 21 | Please respond with a JSON object containing only the information you can confidently extract from the image. Use null for any fields where information is not visible or uncertain. 22 | 23 | **Example Response Format:** 24 | ```json 25 | { 26 | "title": "", 27 | "subtitle": "", 28 | "edition": "", 29 | "contributors": [ 30 | {"name": "", "role": ""}, // repeat as many times and as many roles as you are able to detect 31 | ], 32 | "series": "", 33 | "genre": "", 34 | "language": "" 35 | } 36 | ``` 37 | 38 | Important: Only include fields where you have high confidence in the accuracy. If text is unclear or partially obscured, use your best judgment but indicate uncertainty. Return "null" for any field you cannot determine from the image. 39 | -------------------------------------------------------------------------------- /app/templates/import_reading_quick_add_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Quick Add Reading Logs · Confirm{% endblock %} 3 | {% block content %} 4 |
5 |
6 |
7 |
8 |
9 | 10 | Confirm Quick Add 11 |
12 |
13 | {% include 'partials/_quick_add_preview_summary.html' %} 14 | {% if errors %} 15 |
16 | Close Window 17 |
18 | {% else %} 19 |
20 | 21 | 22 | 23 | 24 |
25 | 26 | Go Back 27 | Close Window 28 |
29 |
30 | {% endif %} 31 |
32 |
33 |
34 |
35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /app/templates/macros/cover_input.html: -------------------------------------------------------------------------------- 1 | {% macro render_cover_input(book) %} 2 | {# Uses UnifiedCoverManager for consistent cover form handling #} 3 |
4 | 7 | 9 | {% if book.cover_url %} 10 |
11 | ✅ Current cover: {{ book.cover_url[:50] }}{% if book.cover_url|length > 50 %}...{% endif %} 12 |
💡 Leave this field empty to keep the current cover, or enter a new URL to replace it 13 |
14 | {% else %} 15 |
16 | Enter a URL to add a cover image for this book 17 |
18 | {% endif %} 19 |
20 | {% endmacro %} 21 | 22 | {% macro render_cover_display(book, css_classes="", style="", img_id="") %} 23 | {# Uses UnifiedCoverManager for consistent cover display #} 24 | {% if book.cover_url %} 25 | {{ book.title }} 29 | {% else %} 30 |
32 | 33 |
34 | {% endif %} 35 | {% endmacro %} 36 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | # DEVELOPMENT ONLY - Contains hardcoded credentials for testing 2 | # DO NOT USE IN PRODUCTION - Use docker-compose.yml instead 3 | 4 | services: 5 | bibliotheca: 6 | build: . 7 | ports: 8 | - "5054:5054" 9 | volumes: 10 | - ./data:/app/data # Bind mount for development (includes backups) 11 | environment: 12 | # Development settings 13 | - SECRET_KEY=dev-secret-key-change-in-production 14 | - SECURITY_PASSWORD_SALT=dev-salt-change-in-production 15 | - WTF_CSRF_ENABLED=true 16 | 17 | # Database - KuzuDB as primary datastore 18 | - KUZU_DB_PATH=/app/data/kuzu 19 | - GRAPH_DATABASE_ENABLED=true 20 | 21 | # Application settings 22 | - TIMEZONE=UTC 23 | - READING_STREAK_OFFSET=0 24 | - FLASK_DEBUG=true 25 | 26 | # CRITICAL: Single worker for KuzuDB compatibility 27 | - WORKERS=1 28 | restart: unless-stopped 29 | healthcheck: 30 | test: ["CMD", "curl", "-f", "http://localhost:5054/"] 31 | interval: 30s 32 | timeout: 10s 33 | retries: 3 34 | start_period: 40s 35 | 36 | # Test service for running automated tests 37 | bibliotheca-test: 38 | build: . 39 | volumes: 40 | - ./data-test:/app/data 41 | - .:/app/test-source:ro 42 | environment: 43 | - SECRET_KEY=test-secret-key 44 | - SECURITY_PASSWORD_SALT=test-salt 45 | - REDIS_URL=redis://:dev-redis-password@redis-graph:6379/1 46 | - GRAPH_DATABASE_ENABLED=true # Enable for testing 47 | - FLASK_DEBUG=true 48 | # Test admin credentials 49 | - DEV_ADMIN_USERNAME=testadmin 50 | - DEV_ADMIN_PASSWORD=TestPassword123! 51 | command: python3 -m pytest tests/ -v 52 | depends_on: 53 | redis-graph: 54 | condition: service_healthy 55 | profiles: 56 | - test 57 | 58 | volumes: 59 | bibliotheca_data: 60 | -------------------------------------------------------------------------------- /scripts/migrations/migrate_security_features.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Database migration script to add security and privacy fields to User model 4 | 5 | ⚠️ DEPRECATED: This manual migration script is no longer needed. 6 | Database migrations now run automatically when the application starts. 7 | See MIGRATION.md for details. 8 | 9 | Run this script to add the new fields for account lockout and privacy settings 10 | """ 11 | 12 | import os 13 | import sys 14 | 15 | def main(): 16 | print("⚠️ WARNING: This migration script is deprecated.") 17 | print("Database migrations now run automatically when the application starts.") 18 | print("See MIGRATION.md for details.") 19 | print() 20 | print("The automatic migration system includes all security and privacy features!") 21 | return True 22 | 23 | if __name__ == "__main__": 24 | main() 25 | 26 | def migrate_database(): 27 | """This function is deprecated - migrations now run automatically""" 28 | print("⚠️ This migration function is deprecated.") 29 | print("Database migrations now run automatically when the application starts.") 30 | return True 31 | 32 | if __name__ == '__main__': 33 | print("MyBibliotheca - Security & Privacy Features Migration") 34 | print("=" * 60) 35 | print("⚠️ WARNING: This migration script is deprecated.") 36 | print("Database migrations now run automatically when the application starts.") 37 | print("See MIGRATION.md for details.") 38 | print("=" * 60) 39 | 40 | if migrate_database(): 41 | print("\n🎉 Migration completed successfully!") 42 | print("New features available:") 43 | print(" • Account lockout after 5 failed login attempts") 44 | print(" • Admin password reset functionality") 45 | print(" • User privacy settings for sharing preferences") 46 | print(" • Enhanced user activity tracking") 47 | else: 48 | print("\n❌ Migration failed!") 49 | sys.exit(1) 50 | -------------------------------------------------------------------------------- /app/templates/community_stats/recent_activity.html: -------------------------------------------------------------------------------- 1 | {% if logs %} 2 |
3 |
4 | {% for log in logs %} 5 |
6 |
7 |
8 |
10 | 11 |
12 |
13 |
14 |
15 | 17 | {{ log.user.username }} 18 | 19 | logged reading "{{ log.book.title }}" 20 |
21 | 22 | by {{ log.book.author }} • {{ log.date.strftime('%B %d, %Y') }} 23 | 24 |
25 |
26 | 27 | {{ log.created_at.strftime('%I:%M %p') if log.created_at else '' }} 28 | 29 |
30 |
31 |
32 | {% endfor %} 33 |
34 |
35 | {% else %} 36 |
37 | 38 |
No Recent Activity
39 |

No reading activity has been logged in the last 7 days.

40 |
41 | {% endif %} 42 | -------------------------------------------------------------------------------- /app/templates/community_stats/books_this_month.html: -------------------------------------------------------------------------------- 1 | {% if books %} 2 |
3 |
4 | {% for book in books %} 5 |
6 |
7 | {% if book.cover_url %} 8 | {{ book.title }} 10 | {% else %} 11 |
12 | 13 |
14 | {% endif %} 15 |
16 |
{{ book.title }}
17 |

by {{ book.author }}

18 | 26 | 27 | Finished: {{ book.finish_date.strftime('%b %d') }} 28 | 29 |
30 |
31 |
32 | {% endfor %} 33 |
34 |
35 | {% else %} 36 |
37 | 38 |
No Books Finished This Month
39 |

No community members have finished books this month yet.

40 |
41 | {% endif %} 42 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | bibliotheca: 3 | build: . 4 | # For beta testing, use a pre-built image instead of building from source: 5 | # image: pickles4evaaaa/mybibliotheca:beta-latest 6 | # See available beta tags at: https://hub.docker.com/repository/docker/pickles4evaaaa/mybibliotheca/tags 7 | ports: 8 | - "5054:5054" 9 | volumes: 10 | # Mount local data directory for migration support, Kuzu database, and backups 11 | - ./data:/app/data 12 | # NOTE: Do NOT bind-mount the static directory on macOS. 13 | # Docker Desktop's osxfs can trigger EDEADLK (Resource deadlock avoided) 14 | # when Gunicorn/Flask read static files via wsgi.file_wrapper. 15 | # Static assets are copied into the image at build time; hot-reload of 16 | # static files should be done with the dev compose file instead. 17 | environment: 18 | # Security - MUST be set via .env file or environment 19 | SECRET_KEY: ${SECRET_KEY} 20 | SECURITY_PASSWORD_SALT: ${SECURITY_PASSWORD_SALT} 21 | 22 | # Database configuration - KuzuDB 23 | KUZU_DB_PATH: /app/data/kuzu 24 | GRAPH_DATABASE_ENABLED: "true" 25 | 26 | # Application settings 27 | SITE_NAME: ${SITE_NAME:-MyBibliotheca} 28 | TIMEZONE: ${TIMEZONE:-UTC} 29 | # CRITICAL: Use single worker for KuzuDB compatibility 30 | WORKERS: "1" 31 | # Central log level (controls Gunicorn and Flask loggers) 32 | LOG_LEVEL: ${LOG_LEVEL} 33 | 34 | # Logging settings - disable verbose initialization to prevent duplicate messages 35 | MYBIBLIOTHECA_VERBOSE_INIT: ${MYBIBLIOTHECA_VERBOSE_INIT:-false} 36 | 37 | # Kuzu recovery options (uncomment as needed) 38 | # KUZU_AUTO_RECOVER_CLEAR: "true" # Full backup + wipe on detected corruption 39 | # KUZU_DISABLE_AUTO_RENAME_CORRUPT: "true" # Disable soft auto-rename fallback 40 | 41 | # Migration settings (DISABLED for manual migration) 42 | AUTO_MIGRATE: "false" 43 | # - MIGRATION_DEFAULT_USER=admin 44 | restart: unless-stopped -------------------------------------------------------------------------------- /app/routes/db_health_routes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify 2 | from ..utils.safe_kuzu_manager import get_safe_kuzu_manager 3 | 4 | # Lightweight health/introspection blueprint to validate DB without side effects 5 | 6 | db_health = Blueprint('db_health', __name__, url_prefix='/api/db') 7 | 8 | @db_health.get('/integrity') 9 | def db_integrity(): 10 | """Return basic integrity info: user/book counts and corruption flags (if any).""" 11 | mgr = get_safe_kuzu_manager() 12 | info = { 13 | 'database_path': getattr(mgr, 'database_path', 'unknown'), 14 | 'initialized': getattr(mgr, '_is_initialized', False), 15 | } 16 | user_count = 0 17 | book_count = 0 18 | try: 19 | from ..infrastructure.kuzu_graph import safe_execute_kuzu_query 20 | uc = safe_execute_kuzu_query("MATCH (u:User) RETURN COUNT(u) as c") 21 | bc = safe_execute_kuzu_query("MATCH (b:Book) RETURN COUNT(b) as c") 22 | def _extract(res): 23 | if not res: return 0 24 | if hasattr(res, 'has_next') and res.has_next(): 25 | row = res.get_next() 26 | if isinstance(row, (list, tuple)) and row: return int(row[0]) 27 | if isinstance(res, list) and res: 28 | first = res[0] 29 | if isinstance(first, dict): 30 | for k in ('c','count','col_0'): 31 | if k in first: 32 | try: return int(first[k]) 33 | except Exception: pass 34 | elif isinstance(first, (list, tuple)) and first: 35 | try: return int(first[0]) 36 | except Exception: pass 37 | return 0 38 | user_count = _extract(uc) 39 | book_count = _extract(bc) 40 | except Exception as e: 41 | info['query_error'] = str(e) 42 | info['user_count'] = user_count 43 | info['book_count'] = book_count 44 | info['empty_database'] = (user_count == 0 and book_count == 0) 45 | return jsonify(info) 46 | -------------------------------------------------------------------------------- /app/templates/partials/_quick_add_result_summary.html: -------------------------------------------------------------------------------- 1 | {% if errors %} 2 | 10 | {% endif %} 11 | {% if created_count > 0 %} 12 | 16 | {% elif not errors %} 17 | 20 | {% endif %} 21 | 26 | {% if success_dates %} 27 |
28 |
Dates updated
29 |
    30 | {% for d in success_dates[:15] %} 31 |
  • {{ d.strftime('%Y-%m-%d') }}
  • 32 | {% endfor %} 33 |
34 | {% if success_dates|length > 15 %} 35 |
…plus {{ success_dates|length - 15 }} more day{{ 's' if (success_dates|length - 15) != 1 else '' }}.
36 | {% endif %} 37 |
38 | {% endif %} 39 | {% if failed_dates %} 40 | 48 | {% endif %} 49 | -------------------------------------------------------------------------------- /app/templates/community_stats/currently_reading.html: -------------------------------------------------------------------------------- 1 | {% if books %} 2 |
3 |
4 | {% for book in books %} 5 |
6 |
7 | {% if book.cover_url %} 8 | {{ book.title }} 10 | {% else %} 11 |
12 | 13 |
14 | {% endif %} 15 |
16 |
{{ book.title }}
17 |

by {{ book.author }}

18 | 26 | 27 | Started: {{ book.start_date.strftime('%b %d') if book.start_date else 'Unknown' }} 28 | 29 |
30 |
31 |
32 | {% endfor %} 33 |
34 |
35 | {% else %} 36 |
37 | 38 |
No One is Currently Reading
39 |

No community members are currently reading books that they're sharing.

40 |
41 | {% endif %} 42 | -------------------------------------------------------------------------------- /app/templates/simple_backup/restore_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% set active_page = "admin" %} 3 | 4 | {% block title %}Restore Complete - MyBibliotheca{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
10 |
11 |
12 |

13 | 14 | Restore Complete 15 |

16 |
17 |
18 |
19 | 20 |
21 | 22 |
Your backup has been restored successfully!
23 |

The application is restarting to finalize the restore process.

24 |

You will be automatically logged out in a few seconds.

25 | 26 |
27 | 28 | Next steps: Once the restart is complete, log back in to access your restored library. 29 |
30 | 31 | 32 | 33 | Go to Login Page 34 | 35 |
36 |
37 |
38 |
39 |
40 | 41 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /scripts/migrations/migrate_db.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Database migration script to add new metadata columns to existing Book table. 4 | Run this script after updating the models to add the new fields. 5 | """ 6 | 7 | import sqlite3 8 | import os 9 | 10 | def migrate_database(): 11 | # Default database path 12 | db_path = 'books.db' 13 | 14 | # Also check instance directory 15 | if not os.path.exists(db_path): 16 | db_path = 'instance/books.db' 17 | 18 | if not os.path.exists(db_path): 19 | print(f"Database file not found. Checked: books.db and instance/books.db") 20 | return 21 | 22 | print(f"Migrating database: {db_path}") 23 | 24 | # Connect directly to SQLite to add columns 25 | conn = sqlite3.connect(db_path) 26 | cursor = conn.cursor() 27 | 28 | # List of new columns to add 29 | new_columns = [ 30 | ('description', 'TEXT'), 31 | ('published_date', 'VARCHAR(50)'), 32 | ('page_count', 'INTEGER'), 33 | ('categories', 'VARCHAR(500)'), 34 | ('publisher', 'VARCHAR(255)'), 35 | ('language', 'VARCHAR(10)'), 36 | ('average_rating', 'REAL'), 37 | ('rating_count', 'INTEGER') 38 | ] 39 | 40 | # Check which columns already exist 41 | cursor.execute("PRAGMA table_info(book)") 42 | existing_columns = [row[1] for row in cursor.fetchall()] 43 | print(f"Existing columns: {existing_columns}") 44 | 45 | # Add missing columns 46 | for column_name, column_type in new_columns: 47 | if column_name not in existing_columns: 48 | try: 49 | cursor.execute(f"ALTER TABLE book ADD COLUMN {column_name} {column_type}") 50 | print(f"Added column: {column_name}") 51 | except sqlite3.Error as e: 52 | print(f"Error adding column {column_name}: {e}") 53 | else: 54 | print(f"Column {column_name} already exists, skipping...") 55 | 56 | conn.commit() 57 | conn.close() 58 | print("Database migration completed successfully!") 59 | 60 | if __name__ == '__main__': 61 | migrate_database() 62 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git and version control 2 | .git 3 | .gitignore 4 | .gitattributes 5 | 6 | # Documentation 7 | *.md 8 | docs/ 9 | DOCKER.md 10 | MULTI_USER_PROJECT_PLAN.md 11 | 12 | # Environment and config files 13 | .env 14 | .env.* 15 | !.env.docker.example 16 | 17 | # Development files 18 | .vscode/ 19 | .idea/ 20 | *.swp 21 | *.swo 22 | *~ 23 | 24 | # Python 25 | __pycache__/ 26 | *.py[cod] 27 | *$py.class 28 | *.so 29 | .Python 30 | env/ 31 | venv/ 32 | .venv/ 33 | ENV/ 34 | env.bak/ 35 | venv.bak/ 36 | .pytest_cache/ 37 | .coverage 38 | htmlcov/ 39 | *.pyc 40 | *.pyo 41 | *.pyd 42 | 43 | # Data directories (will be mounted as volumes) 44 | data/ 45 | data-test/ 46 | flask_session/ 47 | migration_backups/ 48 | backups/ 49 | 50 | # Static assets that get large 51 | app/static/covers/ 52 | static/covers/ 53 | 54 | # Logs 55 | *.log 56 | logs/ 57 | 58 | # Test files 59 | test_files/ 60 | *_test.py 61 | test_*.py 62 | tests/ 63 | 64 | # Build artifacts 65 | build/ 66 | dist/ 67 | *.egg-info/ 68 | 69 | # OS files 70 | .DS_Store 71 | Thumbs.db 72 | 73 | # Temporary files 74 | *.tmp 75 | *.temp 76 | *.bak 77 | 78 | # Migration and backup files 79 | *.sqlite 80 | *.sqlite3 81 | *.db 82 | migration_backups/ 83 | backups/ 84 | 85 | # Node modules (if any) 86 | node_modules/ 87 | npm-debug.log* 88 | 89 | # Cache directories 90 | .cache/ 91 | .npm/ 92 | .yarn/ 93 | 94 | # Lock files (except requirements) 95 | *.lock 96 | !uv.lock 97 | 98 | # Development tools 99 | scripts/ 100 | !scripts/export_postgres_single_user_to_sqlite.py 101 | !scripts/run_postgres_export.sh 102 | migrations/ 103 | .coverage 104 | htmlcov/ 105 | .tox/ 106 | 107 | # IDE 108 | .vscode/ 109 | .idea/ 110 | *.swp 111 | *.swo 112 | 113 | # OS 114 | .DS_Store 115 | Thumbs.db 116 | 117 | # Local data 118 | data/ 119 | data-test/ 120 | *.db 121 | *.sqlite 122 | *.sqlite3 123 | 124 | # Large directories that shouldn't be in build context 125 | dev_docs/ 126 | migration_backups/ 127 | backups/ 128 | app/static/covers/ 129 | 130 | # Logs 131 | *.log 132 | 133 | # Temporary files 134 | *.tmp 135 | *.bak 136 | -------------------------------------------------------------------------------- /app/templates/month_wrapup_empty.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Month Wrap Up - MyBibliotheca{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Month Wrap Up - {{ month_name }} {{ year }}

9 |
10 |
11 | 12 |
13 |
14 |
15 |
16 |
17 | 18 |
19 |

No Books Finished This Month

20 |

21 | You haven't finished any books in {{ month_name }} {{ year }} yet. 22 | Keep reading to see your amazing monthly wrap-up! 23 |

24 | 25 | 36 |
37 |
38 |
39 |
40 | 41 |
42 |
43 |
44 |
Tip
45 |

46 | Monthly wrap-ups generate beautiful image collages of the books you've completed. 47 | Once you finish some books this month, come back to see your personalized reading summary! 48 |

49 |
50 |
51 |
52 | {% endblock %} 53 | -------------------------------------------------------------------------------- /app/templates/settings/partials/server_backup.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Backup Configuration 5 |
6 |
7 |

Configure where automated backups are stored.

8 |
11 | 12 | 13 |
14 |
Backup Storage
15 |
16 |
17 | 18 | 21 | 22 | Path where backup files will be stored (relative to application root or absolute path). Default: data/backups. 23 | 24 |
25 |
26 |
27 |
28 | 31 |
32 |
33 |
34 | Note:
35 | 36 | • The directory will be created automatically if it doesn't exist.
37 | • Use relative paths (e.g., data/backups) or absolute paths (e.g., /mnt/backups).
38 | • For Docker deployments, ensure the path is within a mounted volume.
39 | • Existing backups remain in their current location until manually moved. 40 |
41 |
42 |
43 |
44 | 63 | -------------------------------------------------------------------------------- /app/routes/misc_routes.py: -------------------------------------------------------------------------------- 1 | # Miscellaneous routes migrated from the original routes.py 2 | from flask import Blueprint, redirect, url_for, jsonify 3 | from flask_login import login_required 4 | 5 | misc_bp = Blueprint('misc', __name__) 6 | 7 | @misc_bp.route('/health') 8 | def health_check(): 9 | """Health check endpoint for monitoring and testing.""" 10 | return jsonify({'status': 'healthy'}), 200 11 | 12 | @misc_bp.route('/fetch_book/') 13 | def fetch_book(isbn): 14 | # Temporary compatibility redirect to book blueprint 15 | from app.routes.book_routes import fetch_book as book_fetch 16 | return book_fetch(isbn) 17 | 18 | @misc_bp.route('/reading_history', methods=['GET']) 19 | @login_required 20 | def reading_history_redirect(): 21 | # Redirect to new stats reading history endpoint 22 | return redirect(url_for('stats.reading_history')) 23 | 24 | @misc_bp.route('/month_wrapup') 25 | @login_required 26 | def month_wrapup(): 27 | # Redirect to stats page 28 | return redirect(url_for('stats.index')) 29 | 30 | @misc_bp.route('/community_activity') 31 | @login_required 32 | def community_activity(): 33 | # Redirect to stats page 34 | return redirect(url_for('stats.index')) 35 | 36 | @misc_bp.route('/bulk_import', methods=['GET', 'POST']) 37 | @login_required 38 | def bulk_import(): 39 | # Redirect to new import interface 40 | return redirect(url_for('import.import_books')) 41 | 42 | @misc_bp.route('/import-books', methods=['GET', 'POST']) 43 | @login_required 44 | def import_books(): 45 | # Legacy import-books route 46 | return redirect(url_for('import.import_books')) 47 | 48 | @misc_bp.route('/import-books/execute', methods=['POST']) 49 | @login_required 50 | def import_books_execute(): 51 | # Legacy import execute 52 | from app.routes.import_routes import import_books_execute as execute 53 | return execute() 54 | 55 | @misc_bp.route('/import-books/progress/') 56 | @login_required 57 | def import_books_progress(task_id): 58 | # Legacy import progress 59 | return redirect(url_for('import.import_books_progress', task_id=task_id)) 60 | 61 | @misc_bp.route('/api/import/progress/') 62 | @login_required 63 | def api_import_progress(task_id): 64 | # Legacy API import progress 65 | from app.routes.import_routes import api_import_progress as api_prog 66 | return api_prog(task_id) 67 | 68 | @misc_bp.route('/api/import/errors/') 69 | @login_required 70 | def api_import_errors(task_id): 71 | # Legacy API import errors 72 | from app.routes.import_routes import api_import_errors as api_err 73 | return api_err(task_id) 74 | 75 | @misc_bp.route('/debug/import-jobs') 76 | @login_required 77 | def debug_import_jobs(): 78 | # Legacy debug import jobs 79 | from app.routes.import_routes import debug_import_jobs as debug_jobs 80 | return debug_jobs() 81 | 82 | @misc_bp.route('/migrate-sqlite', methods=['GET', 'POST']) 83 | @login_required 84 | def migrate_sqlite(): 85 | # Legacy migrate sqlite route 86 | from app.routes.import_routes import migrate_sqlite as migrate 87 | return migrate() 88 | 89 | @misc_bp.route('/migration-results') 90 | @login_required 91 | def migration_results(): 92 | # Legacy migration results 93 | return redirect(url_for('import.migration_results')) 94 | 95 | @misc_bp.route('/detect-sqlite', methods=['POST']) 96 | @login_required 97 | def detect_sqlite(): 98 | # Legacy detect sqlite endpoint 99 | from app.routes.import_routes import detect_sqlite as detect 100 | return detect() 101 | 102 | @misc_bp.route('/direct_import', methods=['GET', 'POST']) 103 | @login_required 104 | def direct_import(): 105 | # Legacy direct import route 106 | from app.routes.import_routes import direct_import as direct_import_bp 107 | return direct_import_bp() 108 | -------------------------------------------------------------------------------- /scripts/install_ocr.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # OCR Dependencies Installation Script for Bibliotheca 4 | # This script installs the required dependencies for OCR barcode scanning 5 | 6 | echo "🔧 Installing OCR dependencies for Bibliotheca..." 7 | 8 | # Check if we're in a virtual environment 9 | if [[ "$VIRTUAL_ENV" != "" ]]; then 10 | echo "✅ Virtual environment detected: $VIRTUAL_ENV" 11 | else 12 | echo "⚠️ Warning: No virtual environment detected. Consider using one." 13 | read -p "Continue anyway? (y/N): " -n 1 -r 14 | echo 15 | if [[ ! $REPLY =~ ^[Yy]$ ]]; then 16 | exit 1 17 | fi 18 | fi 19 | 20 | # Install Python packages 21 | echo "📦 Installing Python packages..." 22 | pip install opencv-python==4.8.1.78 pyzbar==0.1.9 pytesseract==0.3.10 numpy>=1.21.0 23 | 24 | # Check for system dependencies 25 | echo "🔍 Checking system dependencies..." 26 | 27 | # Check for Tesseract OCR 28 | if ! command -v tesseract &> /dev/null; then 29 | echo "⚠️ Tesseract OCR not found. Installing instructions:" 30 | 31 | if [[ "$OSTYPE" == "darwin"* ]]; then 32 | # macOS 33 | echo " For macOS, install using Homebrew:" 34 | echo " brew install tesseract" 35 | 36 | if command -v brew &> /dev/null; then 37 | read -p " Install Tesseract using Homebrew now? (y/N): " -n 1 -r 38 | echo 39 | if [[ $REPLY =~ ^[Yy]$ ]]; then 40 | brew install tesseract 41 | fi 42 | fi 43 | 44 | elif [[ "$OSTYPE" == "linux-gnu"* ]]; then 45 | # Linux 46 | echo " For Ubuntu/Debian:" 47 | echo " sudo apt-get install tesseract-ocr" 48 | echo " For CentOS/RHEL:" 49 | echo " sudo yum install tesseract" 50 | 51 | else 52 | echo " Please install Tesseract OCR manually for your system" 53 | echo " Visit: https://github.com/tesseract-ocr/tesseract" 54 | fi 55 | else 56 | echo "✅ Tesseract OCR found: $(tesseract --version | head -1)" 57 | fi 58 | 59 | # Check for libzbar (required by pyzbar) 60 | echo "🔍 Checking for libzbar..." 61 | 62 | if [[ "$OSTYPE" == "darwin"* ]]; then 63 | # macOS 64 | if ! brew list zbar &> /dev/null; then 65 | echo "⚠️ libzbar not found. Installing with Homebrew..." 66 | if command -v brew &> /dev/null; then 67 | brew install zbar 68 | else 69 | echo " Please install Homebrew first: https://brew.sh/" 70 | echo " Then run: brew install zbar" 71 | fi 72 | else 73 | echo "✅ libzbar found" 74 | fi 75 | 76 | elif [[ "$OSTYPE" == "linux-gnu"* ]]; then 77 | # Linux 78 | if ! ldconfig -p | grep libzbar &> /dev/null; then 79 | echo "⚠️ libzbar not found. Install with:" 80 | echo " Ubuntu/Debian: sudo apt-get install libzbar0" 81 | echo " CentOS/RHEL: sudo yum install zbar" 82 | else 83 | echo "✅ libzbar found" 84 | fi 85 | fi 86 | 87 | echo "" 88 | echo "🧪 Testing OCR functionality..." 89 | python3 -c " 90 | try: 91 | from app.ocr_scanner import is_ocr_available 92 | if is_ocr_available(): 93 | print('✅ OCR functionality is ready!') 94 | else: 95 | print('❌ OCR dependencies not properly installed') 96 | exit(1) 97 | except ImportError as e: 98 | print(f'❌ Import error: {e}') 99 | exit(1) 100 | " 101 | 102 | if [ $? -eq 0 ]; then 103 | echo "" 104 | echo "🎉 Installation complete! OCR barcode scanning is now available." 105 | echo "" 106 | echo "Usage:" 107 | echo " 1. Go to Add Book page" 108 | echo " 2. Click 'Upload Image' button" 109 | echo " 3. Select an image containing a barcode or ISBN text" 110 | echo " 4. The ISBN will be automatically extracted and populated" 111 | else 112 | echo "" 113 | echo "❌ Installation failed. Please check the error messages above." 114 | exit 1 115 | fi 116 | -------------------------------------------------------------------------------- /app/templates/macros/custom_field_inline.html: -------------------------------------------------------------------------------- 1 | 2 | {% set field_name = 'global_' + field.name if field.is_global else 'personal_' + field.name %} 3 | {% set current_value = global_metadata.get(field.name, '') if field.is_global else personal_metadata.get(field.name, '') %} 4 | 5 |
6 | 12 | 13 | {% if field.field_type.value == 'text' %} 14 | 16 | 17 | {% elif field.field_type.value == 'textarea' %} 18 | 20 | 21 | {% elif field.field_type.value == 'number' %} 22 | 24 | 25 | {% elif field.field_type.value == 'boolean' %} 26 |
27 | 29 | 32 |
33 | 34 | {% elif field.field_type.value == 'date' %} 35 | 37 | 38 | {% elif field.field_type.value in ['rating_5', 'rating_10'] %} 39 | {% set max_rating = 5 if field.field_type.value == 'rating_5' else 10 %} 40 | 48 | 49 | {% elif field.field_type.value in ['list', 'tags'] %} 50 | 53 | 54 | {% elif field.field_type.value == 'url' %} 55 | 57 | 58 | {% elif field.field_type.value == 'email' %} 59 | 61 | 62 | {% else %} 63 | 65 | {% endif %} 66 | 67 | {% if field.help_text and field.field_type.value != 'boolean' %} 68 |
{{ field.help_text }}
69 | {% endif %} 70 |
71 | -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | # Default Import Templates 2 | 3 | This directory contains scripts and functionality for managing default import templates in MyBibliotheca. 4 | 5 | ## Overview 6 | 7 | Default import templates are pre-configured mapping templates that make it easy for users to import data from popular book tracking services like Goodreads and StoryGraph without having to manually map fields every time. 8 | 9 | ## Features 10 | 11 | ### Available Default Templates 12 | 13 | 1. **Goodreads Export Template** 14 | - Maps all standard Goodreads library export CSV fields 15 | - Handles ratings, reading status, dates, categories, and reviews 16 | - Automatically detects Goodreads CSV format 17 | 18 | 2. **StoryGraph Export Template** 19 | - Maps all standard StoryGraph library export CSV fields 20 | - Includes unique StoryGraph fields like moods, pace, and character analysis 21 | - Creates custom fields for StoryGraph-specific metadata 22 | 23 | ### System Templates vs User Templates 24 | 25 | - **System Templates**: Pre-installed default templates available to all users 26 | - Cannot be deleted by users 27 | - Marked with "System Default" badge 28 | - Automatically updated with application upgrades 29 | 30 | - **User Templates**: Custom templates created by individual users 31 | - Can be edited and deleted by the owner 32 | - Personal to each user account 33 | - Created when users save their own mapping configurations 34 | 35 | ## Usage 36 | 37 | ### Automatic Initialization 38 | 39 | Default templates are automatically created when the application starts if they don't already exist. This happens during the Flask app initialization process. 40 | 41 | ### Manual Creation 42 | 43 | You can also manually create or recreate the default templates using the provided script: 44 | 45 | ```bash 46 | python scripts/create_default_templates.py 47 | ``` 48 | 49 | ### Template Detection 50 | 51 | When users upload a CSV file, the system automatically: 52 | 1. Analyzes the CSV headers 53 | 2. Attempts to match them against available templates 54 | 3. Suggests the best matching template (usually 70%+ similarity) 55 | 4. Pre-selects the suggested template in the import interface 56 | 57 | ## Technical Details 58 | 59 | ### Template Structure 60 | 61 | Each template contains: 62 | - **ID**: Unique identifier (e.g., "default_goodreads") 63 | - **User ID**: "__system__" for default templates 64 | - **Name**: Display name shown to users 65 | - **Description**: Helpful description of the template's purpose 66 | - **Source Type**: "goodreads", "storygraph", or "custom" 67 | - **Sample Headers**: List of expected CSV column names 68 | - **Field Mappings**: Dictionary mapping CSV columns to book fields 69 | 70 | ### Field Mapping Actions 71 | 72 | Templates can specify different actions for each CSV column: 73 | - `map_existing`: Map to a standard book field 74 | - `create_custom`: Create a new custom metadata field 75 | - `skip`: Ignore this column during import 76 | 77 | ### Custom Field Creation 78 | 79 | StoryGraph template includes custom fields for unique metadata: 80 | - **Moods**: Tags for reading atmosphere (e.g., "dark", "hopeful") 81 | - **Pace**: Reading speed perception (e.g., "fast", "slow") 82 | - **Character vs Plot**: Whether the book is character or plot driven 83 | - **Content Warnings**: Tags for sensitive content 84 | 85 | ## Benefits 86 | 87 | 1. **Faster Imports**: Users don't need to manually map common CSV formats 88 | 2. **Consistency**: Standardized field mappings across all users 89 | 3. **Onboarding**: New users can immediately import from popular services 90 | 4. **Rich Metadata**: Preserves unique metadata from different services 91 | 5. **Extensibility**: Easy to add new default templates for other services 92 | 93 | ## Future Enhancements 94 | 95 | - Add templates for other book tracking services (LibraryThing, Bookly, etc.) 96 | - Allow community sharing of custom templates 97 | - Automatic template updates and versioning 98 | - Template marketplace or repository 99 | - AI-powered template suggestions based on CSV structure 100 | -------------------------------------------------------------------------------- /app/templates/locations/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Edit Location - MyBibliotheca{% endblock %} 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 |
10 |

📍 Edit Location

11 |
12 |
13 |
14 | 15 | 16 |
17 | 18 | 20 |
21 | 22 |
23 | 24 | 31 |
32 | 33 |
34 | 35 | 37 |
38 | 39 |
40 | 41 | 43 |
44 | 45 |
46 |
47 | 49 | 52 |
53 | New books will be automatically assigned to your default location. 54 |
55 |
56 |
57 | 58 |
59 | 62 | 63 | Cancel 64 | 65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | {% endblock %} 73 | -------------------------------------------------------------------------------- /scripts/setup_data_dir.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Cross-platform setup script to ensure data directory and database file exist with proper permissions. 4 | This ensures parity between Docker and standalone execution across Windows, macOS, and Linux. 5 | """ 6 | 7 | import os 8 | import stat 9 | import platform 10 | from pathlib import Path 11 | 12 | def setup_data_directory(): 13 | """Ensure data directory exists with proper permissions for standalone execution""" 14 | 15 | # Get the directory where this script is located (project root) 16 | project_root = Path(__file__).parent.absolute() 17 | data_dir = project_root / 'data' 18 | db_path = data_dir / 'books.db' 19 | 20 | system = platform.system() 21 | print(f"🔧 Setting up data directory for standalone execution on {system}...") 22 | print(" This ensures parity with Docker environment setup") 23 | 24 | # Create data directory if it doesn't exist 25 | try: 26 | if system == "Windows": 27 | # On Windows, just create the directory without specific mode 28 | data_dir.mkdir(exist_ok=True) 29 | else: 30 | # On Unix-like systems, set proper permissions 31 | data_dir.mkdir(mode=0o755, exist_ok=True) 32 | print(f"✅ Data directory created/verified: {data_dir}") 33 | except Exception as e: 34 | print(f"⚠️ Could not create data directory: {e}") 35 | return False 36 | 37 | # Create empty database file if it doesn't exist (matches Docker setup) 38 | try: 39 | if not db_path.exists(): 40 | if system == "Windows": 41 | # On Windows, just create the file 42 | db_path.touch() 43 | else: 44 | # On Unix-like systems, set proper permissions 45 | db_path.touch(mode=0o664) 46 | print(f"✅ Database file created: {db_path}") 47 | else: 48 | print(f"✅ Database file exists: {db_path}") 49 | except Exception as e: 50 | print(f"⚠️ Could not create database file: {e}") 51 | return False 52 | 53 | # Set permissions (only on Unix-like systems) 54 | if system != "Windows": 55 | try: 56 | # Set directory permissions (755 = rwxr-xr-x) 57 | data_dir.chmod(0o755) 58 | 59 | # Set database file permissions (664 = rw-rw-r--) 60 | if db_path.exists(): 61 | db_path.chmod(0o664) 62 | 63 | print("✅ Permissions set correctly (755 for directory, 664 for database)") 64 | 65 | except PermissionError as e: 66 | print(f"⚠️ Could not set permissions: {e}") 67 | print(" This is normal on some systems and won't affect functionality") 68 | except Exception as e: 69 | print(f"⚠️ Permission error: {e}") 70 | else: 71 | print("✅ Running on Windows - skipping Unix permission settings") 72 | 73 | # Verify the setup matches what config.py expects 74 | try: 75 | from config import Config 76 | config_db_path = Path(Config.DATABASE_PATH) 77 | 78 | if config_db_path.resolve() == db_path.resolve(): 79 | print("✅ Database path matches config.py expectations") 80 | else: 81 | print(f"⚠️ Path mismatch - Config expects: {config_db_path}") 82 | print(f" But we created: {db_path}") 83 | except Exception as e: 84 | print(f"⚠️ Could not verify config path: {e}") 85 | 86 | print(f"\n🎉 Data directory setup complete for {system}!") 87 | print(f" Data directory: {data_dir}") 88 | print(f" Database path: {db_path}") 89 | 90 | if system != "Windows": 91 | print(f" Permissions: directory=755, database=664") 92 | else: 93 | print(f" Permissions: Using Windows default file permissions") 94 | 95 | print("\nYou can now run MyBibliotheca using:") 96 | if system == "Windows": 97 | print(" python -m gunicorn -w 4 -b 0.0.0.0:5054 run:app") 98 | else: 99 | print(" gunicorn -w 4 -b 0.0.0.0:5054 run:app") 100 | 101 | return True 102 | 103 | if __name__ == "__main__": 104 | setup_data_directory() 105 | -------------------------------------------------------------------------------- /app/templates/admin/partials/backup_config.html: -------------------------------------------------------------------------------- 1 | {# Partial: Backup Configuration form for unified settings #} 2 |
3 |
4 |
Backup Configuration
5 |
6 |
7 |

Configure backup file storage location.

8 | 9 |
10 | 11 | 12 | 13 |
14 |
Backup Storage
15 |
16 |
17 | 18 | 21 | 22 | Path where backup files will be stored (relative to application root or absolute path). 23 | Default: data/backups 24 | 25 |
26 |
27 |
28 | 29 |
30 | 33 |
34 |
35 | 36 |
37 | Note:
38 | 39 | • The directory will be created automatically if it doesn't exist
40 | • Use relative paths (e.g., data/backups) or absolute paths (e.g., /mnt/backups)
41 | • For Docker deployments, ensure the path is within a mounted volume
42 | • Existing backups will remain in their current location until manually moved 43 |
44 |
45 |
46 |
47 | 48 | 85 | -------------------------------------------------------------------------------- /app/utils/password_policy.py: -------------------------------------------------------------------------------- 1 | """Helpers for managing password strength policy across the application.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import os 7 | from pathlib import Path 8 | from typing import Dict, List, Optional, Tuple, Union 9 | 10 | DEFAULT_MIN_PASSWORD_LENGTH: int = 8 11 | MIN_ALLOWED_PASSWORD_LENGTH: int = 6 12 | MAX_ALLOWED_PASSWORD_LENGTH: int = 128 13 | ENV_PASSWORD_MIN_LENGTH_KEY: str = "PASSWORD_MIN_LENGTH" 14 | _PASSWORD_LENGTH_SOURCES = ("env", "config", "default") 15 | 16 | 17 | def _parse_length(value: Union[str, int, float, None]) -> Optional[int]: 18 | """Convert raw input into a sanitized password length.""" 19 | if value is None: 20 | return None 21 | if isinstance(value, str): 22 | value = value.strip() 23 | if not value: 24 | return None 25 | try: 26 | length = int(value) # type: ignore[arg-type] 27 | except (TypeError, ValueError): 28 | return None 29 | if length < MIN_ALLOWED_PASSWORD_LENGTH: 30 | length = MIN_ALLOWED_PASSWORD_LENGTH 31 | if length > MAX_ALLOWED_PASSWORD_LENGTH: 32 | length = MAX_ALLOWED_PASSWORD_LENGTH 33 | return length 34 | 35 | 36 | def _resolve_data_dir() -> Path: 37 | """Best-effort resolution of the application's data directory.""" 38 | env_dir = os.getenv("MYBIBLIOTHECA_DATA_DIR") or os.getenv("DATA_DIR") 39 | if env_dir: 40 | return Path(env_dir) 41 | try: 42 | from flask import current_app 43 | 44 | data_dir = current_app.config.get("DATA_DIR") # type: ignore[attr-defined] 45 | if data_dir: 46 | return Path(str(data_dir)) 47 | except Exception: 48 | pass 49 | try: 50 | return Path(__file__).resolve().parents[2] / "data" 51 | except Exception: 52 | return Path.cwd() / "data" 53 | 54 | 55 | def _load_system_config() -> Dict[str, object]: 56 | """Load the persisted system configuration if present.""" 57 | config_path = _resolve_data_dir() / "system_config.json" 58 | if not config_path.exists(): 59 | return {} 60 | try: 61 | with config_path.open("r", encoding="utf-8") as fh: 62 | data = json.load(fh) 63 | return data if isinstance(data, dict) else {} 64 | except Exception: 65 | return {} 66 | 67 | 68 | def coerce_min_password_length(value: Union[str, int, float, None]) -> Optional[int]: 69 | """Normalize input into a valid minimum password length.""" 70 | return _parse_length(value) 71 | 72 | 73 | def get_env_password_min_length() -> Optional[int]: 74 | """Return the password length defined via environment variable, if any.""" 75 | return _parse_length(os.getenv(ENV_PASSWORD_MIN_LENGTH_KEY)) 76 | 77 | 78 | def get_persisted_password_min_length() -> Optional[int]: 79 | """Return the password length saved in system configuration (if present).""" 80 | config = _load_system_config() 81 | security_settings = config.get("security_settings") 82 | if isinstance(security_settings, dict): 83 | return _parse_length(security_settings.get("min_password_length")) 84 | return None 85 | 86 | 87 | def resolve_min_password_length(include_source: bool = False) -> Union[int, Tuple[int, str]]: 88 | """Resolve the active minimum password length with precedence: env > config > default.""" 89 | env_value = get_env_password_min_length() 90 | if env_value is not None: 91 | return (env_value, _PASSWORD_LENGTH_SOURCES[0]) if include_source else env_value 92 | 93 | persisted_value = get_persisted_password_min_length() 94 | if persisted_value is not None: 95 | return (persisted_value, _PASSWORD_LENGTH_SOURCES[1]) if include_source else persisted_value 96 | 97 | return (DEFAULT_MIN_PASSWORD_LENGTH, _PASSWORD_LENGTH_SOURCES[2]) if include_source else DEFAULT_MIN_PASSWORD_LENGTH 98 | 99 | 100 | def get_password_requirements() -> List[str]: 101 | """Return a human-readable list of password requirements.""" 102 | min_length = resolve_min_password_length() 103 | return [ 104 | f"At least {min_length} characters long", 105 | "Contains at least one letter (A-Z or a-z)", 106 | "Contains at least one number (0-9) OR one special character (!@#$%^&*()_+-=[]{};':\"\\|,.<>/?)", 107 | "Not a commonly used password" 108 | ] -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Placeholder models file for backward compatibility during Kuzu migration. 3 | 4 | This file provides empty stubs to prevent import errors while the application 5 | is being migrated to Kuzu graph database architecture. All actual data operations 6 | should use the Kuzu-based services and domain models. 7 | """ 8 | 9 | from flask import current_app 10 | 11 | 12 | class _NoOpDatabase: 13 | """No-op database object that doesn't perform any operations.""" 14 | 15 | def __init__(self): 16 | pass 17 | 18 | @property 19 | def session(self): 20 | return _NoOpSession() 21 | 22 | def create_all(self): 23 | """No-op create_all - database creation disabled during Kuzu migration.""" 24 | print("⚠️ SQLite database creation disabled - using Kuzu only") 25 | pass 26 | 27 | def drop_all(self): 28 | """No-op drop_all - database operations disabled during Kuzu migration.""" 29 | pass 30 | 31 | 32 | class _NoOpSession: 33 | """No-op session object that doesn't perform any operations.""" 34 | 35 | def add(self, obj): 36 | """No-op add - database operations disabled during Kuzu migration.""" 37 | print("⚠️ SQLite session.add() disabled - use Kuzu services instead") 38 | pass 39 | 40 | def commit(self): 41 | """No-op commit - database operations disabled during Kuzu migration.""" 42 | print("⚠️ SQLite session.commit() disabled - use Kuzu services instead") 43 | pass 44 | 45 | def rollback(self): 46 | """No-op rollback - database operations disabled during Kuzu migration.""" 47 | print("⚠️ SQLite session.rollback() disabled - use Kuzu services instead") 48 | pass 49 | 50 | def delete(self, obj): 51 | """No-op delete - database operations disabled during Kuzu migration.""" 52 | print("⚠️ SQLite session.delete() disabled - use Kuzu services instead") 53 | pass 54 | 55 | 56 | class _NoOpModel: 57 | """Base class for no-op models that don't perform any operations.""" 58 | 59 | @classmethod 60 | def query(cls): 61 | return _NoOpQuery() 62 | 63 | def __init__(self, **kwargs): 64 | # Set attributes to prevent errors, but don't save anywhere 65 | for key, value in kwargs.items(): 66 | setattr(self, key, value) 67 | 68 | 69 | class _NoOpQuery: 70 | """No-op query object that returns empty results.""" 71 | 72 | def filter_by(self, **kwargs): 73 | return self 74 | 75 | def filter(self, *args): 76 | return self 77 | 78 | def order_by(self, *args): 79 | return self 80 | 81 | def first(self): 82 | return None 83 | 84 | def all(self): 85 | return [] 86 | 87 | def count(self): 88 | return 0 89 | 90 | def paginate(self, **kwargs): 91 | return _NoOpPagination() 92 | 93 | 94 | class _NoOpPagination: 95 | """No-op pagination object.""" 96 | 97 | @property 98 | def items(self): 99 | return [] 100 | 101 | @property 102 | def total(self): 103 | return 0 104 | 105 | @property 106 | def pages(self): 107 | return 0 108 | 109 | @property 110 | def page(self): 111 | return 1 112 | 113 | @property 114 | def per_page(self): 115 | return 20 116 | 117 | @property 118 | def has_prev(self): 119 | return False 120 | 121 | @property 122 | def has_next(self): 123 | return False 124 | 125 | @property 126 | def prev_num(self): 127 | return None 128 | 129 | @property 130 | def next_num(self): 131 | return None 132 | 133 | 134 | # Create no-op database instance 135 | db = _NoOpDatabase() 136 | 137 | 138 | class User(_NoOpModel): 139 | """No-op User model placeholder.""" 140 | pass 141 | 142 | 143 | class Book(_NoOpModel): 144 | """No-op Book model placeholder.""" 145 | pass 146 | 147 | 148 | class ReadingLog(_NoOpModel): 149 | """No-op ReadingLog model placeholder.""" 150 | pass 151 | 152 | 153 | # Note: Legacy SQLite models are disabled - use Kuzu services and domain models instead 154 | # The migration is complete and app.services with app.domain.models should be used 155 | -------------------------------------------------------------------------------- /tests/test_unified_metadata.py: -------------------------------------------------------------------------------- 1 | import importlib.util 2 | import sys 3 | import types 4 | from pathlib import Path 5 | import requests 6 | 7 | 8 | class DummyResponse: 9 | def __init__(self, payload, status_code=200): 10 | self._payload = payload 11 | self.status_code = status_code 12 | 13 | def json(self): 14 | return self._payload 15 | 16 | def raise_for_status(self): 17 | if self.status_code >= 400: 18 | raise requests.HTTPError(f"status {self.status_code}") 19 | 20 | 21 | def load_unified_metadata_module(): 22 | module_name = "app.utils.unified_metadata" 23 | module_path = Path(__file__).resolve().parent.parent / "app" / "utils" / "unified_metadata.py" 24 | 25 | # Stub required app modules to avoid importing Flask-heavy app package 26 | app_mod = types.ModuleType("app") 27 | utils_mod = types.ModuleType("app.utils") 28 | 29 | metadata_settings = types.ModuleType("app.utils.metadata_settings") 30 | metadata_settings.apply_field_policy = lambda entity, field, g, o, m: m 31 | 32 | book_utils = types.ModuleType("app.utils.book_utils") 33 | book_utils.select_highest_google_image = lambda *_args, **_kwargs: None 34 | book_utils.upgrade_google_cover_url = lambda url: url 35 | 36 | sys.modules["app"] = app_mod 37 | sys.modules["app.utils"] = utils_mod 38 | sys.modules["app.utils.metadata_settings"] = metadata_settings 39 | sys.modules["app.utils.book_utils"] = book_utils 40 | 41 | spec = importlib.util.spec_from_file_location(module_name, module_path) 42 | module = importlib.util.module_from_spec(spec) 43 | sys.modules[module_name] = module 44 | spec.loader.exec_module(module) 45 | return module 46 | 47 | 48 | def _google_payload(isbn_value: str): 49 | return { 50 | "items": [ 51 | { 52 | "id": "vol1", 53 | "volumeInfo": { 54 | "title": "Wrong Volume", 55 | "industryIdentifiers": [ 56 | {"type": "ISBN_13", "identifier": isbn_value}, 57 | ], 58 | }, 59 | } 60 | ] 61 | } 62 | 63 | 64 | def test_fetch_google_by_isbn_drops_mismatched_isbn(monkeypatch): 65 | """Ensure Google results with a different ISBN are ignored.""" 66 | unified_metadata = load_unified_metadata_module() 67 | 68 | def fake_get(url, timeout=None, headers=None): 69 | if "googleapis.com/books/v1/volumes?q=isbn:" in url: 70 | return DummyResponse(_google_payload("9781591826071")) 71 | if "googleapis.com/books/v1/volumes/vol1" in url: 72 | return DummyResponse({}) 73 | raise AssertionError(f"Unexpected URL: {url}") 74 | 75 | monkeypatch.setattr(requests, "get", fake_get) 76 | 77 | result = unified_metadata._fetch_google_by_isbn("9781591826040") 78 | assert result == {} 79 | 80 | 81 | def test_unified_fetch_prefers_matching_metadata(monkeypatch): 82 | """When Google mismatches, fallback data should keep the requested ISBN.""" 83 | unified_metadata = load_unified_metadata_module() 84 | 85 | requested_isbn = "9781591826040" 86 | 87 | def fake_get(url, timeout=None, headers=None): 88 | if "googleapis.com/books/v1/volumes?q=isbn:" in url: 89 | return DummyResponse(_google_payload("9781591826071")) 90 | if "googleapis.com/books/v1/volumes/vol1" in url: 91 | return DummyResponse({}) 92 | if "openlibrary.org/api/books" in url: 93 | return DummyResponse( 94 | { 95 | f"ISBN:{requested_isbn}": { 96 | "title": "Fruits Basket, Vol. 2", 97 | "identifiers": {"isbn_13": [requested_isbn]}, 98 | "publish_date": "2004", 99 | "subjects": [{"name": "Comics"}], 100 | } 101 | } 102 | ) 103 | raise AssertionError(f"Unexpected URL: {url}") 104 | 105 | monkeypatch.setattr(requests, "get", fake_get) 106 | 107 | merged, errors = unified_metadata.fetch_unified_by_isbn_detailed(requested_isbn) 108 | 109 | assert merged["isbn13"] == requested_isbn 110 | assert merged["title"] == "Fruits Basket, Vol. 2" 111 | assert merged.get("_isbn_mismatch") is False 112 | assert errors.get("google") == "empty" 113 | -------------------------------------------------------------------------------- /app/templates/locations/add.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Add Location - MyBibliotheca{% endblock %} 3 | 4 | {% block content %} 5 | 25 | 26 |
27 |
28 |
29 |
30 |
31 |

Location Details

32 |
33 |
34 |
35 | 36 | 37 |
38 | 39 | 41 |
42 | 43 |
44 | 45 | 52 |
53 | 54 |
55 | 56 | 58 |
59 | 60 |
61 | 62 | 64 |
65 | 66 |
67 |
68 | 69 | 72 |
73 | New books will be automatically assigned to your default location. 74 |
75 |
76 |
77 | 78 |
79 | 82 | 83 | Cancel 84 | 85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 | {% endblock %} 93 | -------------------------------------------------------------------------------- /app/utils/simple_cache.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | import functools 4 | import asyncio 5 | import hashlib 6 | from typing import Any, Optional, Tuple, Dict, Callable, Union 7 | 8 | 9 | class TTLCache: 10 | """Very small in-process TTL cache suitable for single-worker setups.""" 11 | def __init__(self): 12 | self._store: Dict[str, Tuple[Any, float]] = {} 13 | self._lock = threading.Lock() 14 | 15 | def get(self, key: str) -> Optional[Any]: 16 | now = time.time() 17 | with self._lock: 18 | item = self._store.get(key) 19 | if not item: 20 | return None 21 | value, exp = item 22 | if exp < now: 23 | self._store.pop(key, None) 24 | return None 25 | return value 26 | 27 | def set(self, key: str, value: Any, ttl_seconds: int = 60) -> None: 28 | exp = time.time() + max(1, int(ttl_seconds)) 29 | with self._lock: 30 | self._store[key] = (value, exp) 31 | 32 | def delete(self, key: str) -> None: 33 | with self._lock: 34 | self._store.pop(key, None) 35 | 36 | def clear(self) -> None: 37 | with self._lock: 38 | self._store.clear() 39 | 40 | 41 | _cache = TTLCache() 42 | _user_versions: Dict[str, int] = {} 43 | _version_lock = threading.Lock() 44 | 45 | 46 | def cache_get(key: str) -> Optional[Any]: 47 | return _cache.get(key) 48 | 49 | 50 | def cache_set(key: str, value: Any, ttl_seconds: int = 60) -> None: 51 | _cache.set(key, value, ttl_seconds) 52 | 53 | 54 | def get_user_library_version(user_id: str) -> int: 55 | with _version_lock: 56 | return int(_user_versions.get(user_id, 0)) 57 | 58 | 59 | def bump_user_library_version(user_id: str) -> int: 60 | with _version_lock: 61 | current = int(_user_versions.get(user_id, 0)) + 1 62 | _user_versions[user_id] = current 63 | return current 64 | 65 | 66 | def cached(ttl_seconds: int = 60, key_builder: Optional[Callable] = None): 67 | """ 68 | Decorator to cache function results. 69 | Supports both sync and async functions. 70 | """ 71 | def decorator(func): 72 | @functools.wraps(func) 73 | def wrapper(*args, **kwargs): 74 | # Build cache key 75 | if key_builder: 76 | key = key_builder(*args, **kwargs) 77 | else: 78 | # Simple default key builder 79 | key_parts = [func.__module__, func.__name__] 80 | key_parts.extend([str(arg) for arg in args]) 81 | key_parts.extend([f"{k}={v}" for k, v in sorted(kwargs.items())]) 82 | key_str = ":".join(key_parts) 83 | key = hashlib.md5(key_str.encode()).hexdigest() 84 | 85 | # Check cache 86 | cached_value = cache_get(key) 87 | if cached_value is not None: 88 | return cached_value 89 | 90 | # Call function 91 | result = func(*args, **kwargs) 92 | 93 | # Store result 94 | cache_set(key, result, ttl_seconds) 95 | return result 96 | 97 | @functools.wraps(func) 98 | async def async_wrapper(*args, **kwargs): 99 | # Build cache key (same logic) 100 | if key_builder: 101 | key = key_builder(*args, **kwargs) 102 | else: 103 | key_parts = [func.__module__, func.__name__] 104 | key_parts.extend([str(arg) for arg in args]) 105 | key_parts.extend([f"{k}={v}" for k, v in sorted(kwargs.items())]) 106 | key_str = ":".join(key_parts) 107 | key = hashlib.md5(key_str.encode()).hexdigest() 108 | 109 | # Check cache 110 | cached_value = cache_get(key) 111 | if cached_value is not None: 112 | return cached_value 113 | 114 | # Call function 115 | result = await func(*args, **kwargs) 116 | 117 | # Store result 118 | cache_set(key, result, ttl_seconds) 119 | return result 120 | 121 | if asyncio.iscoroutinefunction(func): 122 | return async_wrapper 123 | else: 124 | return wrapper 125 | return decorator 126 | 127 | 128 | def cache_delete(key: str) -> None: 129 | _cache.delete(key) 130 | -------------------------------------------------------------------------------- /force_schema_init.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Force KuzuDB schema initialization. 4 | 5 | This script forces a complete schema initialization to fix missing table issues. 6 | """ 7 | 8 | import os 9 | import sys 10 | import logging 11 | from typing import Any 12 | 13 | # Add the app directory to the Python path 14 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app')) 15 | 16 | # Set up logging 17 | logging.basicConfig(level=logging.INFO) 18 | logger = logging.getLogger(__name__) 19 | 20 | def _safe_get_row_value(row: Any, index: int) -> Any: 21 | """Safely extract a value from a KuzuDB row at the given index.""" 22 | if isinstance(row, list): 23 | return row[index] if index < len(row) else None 24 | elif isinstance(row, dict): 25 | keys = list(row.keys()) 26 | return row[keys[index]] if index < len(keys) else None 27 | else: 28 | try: 29 | return row[index] # type: ignore 30 | except (IndexError, KeyError, TypeError): 31 | return None 32 | 33 | def force_schema_init(): 34 | """Force schema initialization.""" 35 | try: 36 | logger.info("🔧 Starting forced schema initialization...") 37 | 38 | # Run additive schema preflight before any checks so new columns are added 39 | try: 40 | logger.info("Running schema preflight (additive upgrade)...") 41 | # Importing runs preflight as a side-effect; force run explicitly too 42 | from app.startup.schema_preflight import run_schema_preflight # type: ignore 43 | # Allow forcing even if marker says up-to-date 44 | os.environ['SCHEMA_PREFLIGHT_FORCE'] = 'true' 45 | run_schema_preflight() 46 | except Exception as e: 47 | logger.warning(f"Schema preflight phase failed or skipped: {e}") 48 | 49 | # Use thread-safe connection instead of deprecated singleton 50 | from app.utils.safe_kuzu_manager import SafeKuzuManager 51 | manager = SafeKuzuManager() 52 | 53 | # Force reset by setting environment variable 54 | os.environ['KUZU_FORCE_RESET'] = 'true' 55 | 56 | # Connect and initialize schema using safe connection 57 | logger.info("Connecting to database...") 58 | with manager.get_connection(operation="schema_init") as connection: 59 | 60 | # Test that tables exist 61 | logger.info("Testing schema...") 62 | 63 | # Test User table 64 | try: 65 | result = connection.execute("MATCH (u:User) RETURN COUNT(u) as count LIMIT 1") 66 | # Handle both single QueryResult and list[QueryResult] 67 | if isinstance(result, list): 68 | result = result[0] if result else None 69 | if result and result.has_next(): 70 | count = _safe_get_row_value(result.get_next(), 0) 71 | logger.info(f"✅ User table exists with {count} users") 72 | else: 73 | logger.info("✅ User table exists (empty)") 74 | except Exception as e: 75 | logger.error(f"❌ User table test failed: {e}") 76 | 77 | # Test Book table 78 | try: 79 | result = connection.execute("MATCH (b:Book) RETURN COUNT(b) as count LIMIT 1") 80 | # Handle both single QueryResult and list[QueryResult] 81 | if isinstance(result, list): 82 | result = result[0] if result else None 83 | if result and result.has_next(): 84 | count = _safe_get_row_value(result.get_next(), 0) 85 | logger.info(f"✅ Book table exists with {count} books") 86 | else: 87 | logger.info("✅ Book table exists (empty)") 88 | except Exception as e: 89 | logger.error(f"❌ Book table test failed: {e}") 90 | 91 | logger.info("✅ Schema initialization completed successfully!") 92 | 93 | # Connection automatically cleaned up by context manager 94 | 95 | except Exception as e: 96 | logger.error(f"❌ Schema initialization failed: {e}") 97 | import traceback 98 | traceback.print_exc() 99 | sys.exit(1) 100 | 101 | if __name__ == "__main__": 102 | force_schema_init() 103 | -------------------------------------------------------------------------------- /docs/CROSS_PLATFORM.md: -------------------------------------------------------------------------------- 1 | # Cross-Platform Compatibility Guide 2 | 3 | This document outlines how Bibliotheca handles differences between operating systems to ensure consistent behavior across Windows, macOS, and Linux. 4 | 5 | ## 🖥️ Platform-Specific Considerations 6 | 7 | ### **File Permissions** 8 | 9 | **Unix-like Systems (Linux/macOS):** 10 | - Uses octal permissions: `755` for directories, `664` for database files 11 | - Permissions are enforced to ensure proper access control 12 | - Compatible with Docker container permissions 13 | 14 | **Windows:** 15 | - Skips Unix-style permission setting (not applicable) 16 | - Relies on Windows default file permissions 17 | - Directory and file creation still works correctly 18 | 19 | ### **Path Handling** 20 | 21 | **All Platforms:** 22 | - Uses `os.path.join()` and `pathlib.Path` for cross-platform path construction 23 | - Automatically handles forward slashes (Unix) vs backslashes (Windows) 24 | - Database paths are constructed dynamically to work on all systems 25 | 26 | ### **Directory Setup** 27 | 28 | **Automated Setup Scripts:** 29 | 30 | 1. **`setup_data_dir.py`** - Cross-platform Python script (recommended) 31 | - Detects operating system automatically 32 | - Applies appropriate permissions based on platform 33 | - Works identically on Windows, macOS, and Linux 34 | 35 | 2. **`setup_data_dir.bat`** - Windows batch script (alternative) 36 | - Native Windows batch file for users who prefer it 37 | - Creates same directory structure as other platforms 38 | 39 | ## 🔧 Technical Implementation 40 | 41 | ### **Platform Detection** 42 | ```python 43 | import platform 44 | system = platform.system() # Returns: 'Windows', 'Darwin', or 'Linux' 45 | ``` 46 | 47 | ### **Conditional Permission Setting** 48 | ```python 49 | if system != "Windows": 50 | data_dir.chmod(0o755) # Only set Unix permissions on Unix-like systems 51 | db_path.chmod(0o664) 52 | else: 53 | # Skip permission setting on Windows 54 | pass 55 | ``` 56 | 57 | ### **Cross-Platform Database Path** 58 | ```python 59 | # Works on all platforms 60 | DATABASE_PATH = os.path.join(basedir, 'data', 'books.db') 61 | SQLALCHEMY_DATABASE_URI = f"sqlite:///{DATABASE_PATH}" 62 | ``` 63 | 64 | ## 🚀 Running on Different Platforms 65 | 66 | ### **Linux/macOS** 67 | ```bash 68 | # Setup 69 | python3 setup_data_dir.py 70 | 71 | # Run 72 | gunicorn -w 4 -b 0.0.0.0:5054 run:app 73 | ``` 74 | 75 | ### **Windows** 76 | ```cmd 77 | # Setup (choose one) 78 | python setup_data_dir.py 79 | # OR 80 | setup_data_dir.bat 81 | 82 | # Run (choose one) 83 | python -m gunicorn -w 4 -b 0.0.0.0:5054 run:app 84 | # OR 85 | gunicorn -w 4 -b 0.0.0.0:5054 run:app 86 | ``` 87 | 88 | ## 🐳 Docker Consistency 89 | 90 | The standalone setup is designed to match the Docker environment: 91 | 92 | **Docker Environment:** 93 | - Creates `/app/data` directory with `755` permissions 94 | - Creates empty `books.db` file with `664` permissions 95 | - Uses `chown` to set ownership (Linux containers) 96 | 97 | **Standalone Environment:** 98 | - Creates `./data` directory with appropriate platform permissions 99 | - Creates empty `books.db` file with appropriate platform permissions 100 | - Ensures same directory structure and file presence 101 | 102 | ## ✅ Tested Compatibility 103 | 104 | This cross-platform approach has been designed to work on: 105 | 106 | - **Linux distributions** (Ubuntu, Debian, CentOS, etc.) 107 | - **macOS** (Intel and Apple Silicon) 108 | - **Windows 10/11** (with Python 3.8+) 109 | - **Docker** (all platforms with Docker Desktop) 110 | 111 | ## 🔍 Troubleshooting 112 | 113 | ### **Permission Errors on Unix-like Systems** 114 | - The setup script gracefully handles permission errors 115 | - If you encounter issues, ensure you have write access to the project directory 116 | - Consider running with appropriate user permissions 117 | 118 | ### **Gunicorn on Windows** 119 | - If `gunicorn` command fails, use `python -m gunicorn` instead 120 | - Ensure gunicorn is installed: `pip install gunicorn` 121 | - Windows may require additional setup for some WSGI servers 122 | 123 | ### **Path Issues** 124 | - All paths are constructed using `os.path.join()` for cross-platform compatibility 125 | - If you encounter path-related errors, check that the project directory structure is intact 126 | 127 | This approach ensures that whether you're running on Windows, macOS, Linux, or in Docker, Bibliotheca behaves consistently and maintains the same directory structure and permissions appropriate for each platform. 128 | -------------------------------------------------------------------------------- /app/templates/edit_book.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Edit Book - MyBibliotheca{% endblock %} 3 | {% block content %} 4 |

Edit Book

5 |
6 | 7 |
8 | 9 | 10 |
11 |
12 | 13 | 14 |
15 |
16 | 17 | 18 |
19 |
20 | 21 | 22 |
Book description or summary (from publisher/database)
23 |
24 |
25 | 26 | 27 |
Your personal notes, thoughts, or comments about this book
28 |
29 |
30 |
31 |
32 | 33 | 34 |
35 |
36 |
37 |
38 | 39 | 40 |
41 |
42 |
43 |
44 |
45 |
46 | 47 | 48 |
49 |
50 |
51 |
52 | 53 | 54 |
55 |
56 |
57 |
58 | 59 | 60 |
Enter categories separated by commas (e.g., Fiction, Science Fiction, Adventure)
61 |
62 |
63 |
64 |
65 | 66 | 67 |
68 |
69 |
70 |
71 | 72 | 73 |
74 |
75 |
76 |
77 | 78 | 79 | {% if book.cover_url %} 80 |
81 | Current Cover 82 |
83 | {% endif %} 84 |
Paste a direct image URL to override the cover.
85 |
86 |
87 | 88 | Cancel 89 |
90 |
91 | {% endblock %} -------------------------------------------------------------------------------- /docs/RATE_LIMITING.md: -------------------------------------------------------------------------------- 1 | # Rate Limiting Implementation for Bibliotheca 2 | 3 | ## Overview 4 | This document describes the rate limiting implementation added to address issue #23: preventing API throttling during bulk imports. 5 | 6 | ## Changes Made 7 | 8 | ### 1. Utils.py - Core Rate Limiting 9 | - **New Configuration Constants**: Added configurable rate limiting parameters at the top of `utils.py` 10 | - `API_RATE_LIMIT_DELAY = 1.0` - seconds between API calls 11 | - `MAX_RETRIES = 3` - maximum retry attempts for failed requests 12 | - `RETRY_DELAY = 2.0` - base delay before retrying (with exponential backoff) 13 | 14 | - **New Function: `rate_limited_request()`** 15 | - Wraps all HTTP requests with rate limiting 16 | - Implements exponential backoff retry logic 17 | - Supports URL parameters 18 | - Comprehensive error logging 19 | - Automatically raises exceptions after max retries 20 | 21 | - **Updated Functions**: 22 | - `fetch_book_data()` - Now uses rate-limited OpenLibrary API calls 23 | - `get_google_books_cover()` - Now uses rate-limited Google Books API calls 24 | - `generate_month_review_image()` - Cover downloads now rate-limited 25 | 26 | ### 2. Routes.py - Bulk Import Improvements 27 | - **Enhanced Progress Logging**: Added detailed logging for bulk import progress 28 | - **Better Error Handling**: Individual book failures don't stop the entire import 29 | - **Improved User Feedback**: Updated flash messages to indicate rate limiting is active 30 | - **Import Counting**: Shows progress as "Processing book X/Y" 31 | 32 | - **Updated Search Function**: Added rate limiting to Google Books API search 33 | 34 | ### 3. Templates - User Interface Updates 35 | - **bulk_import.html**: Added informational alert explaining rate limiting 36 | - **Rate Limiting Notice**: Users are informed that large imports may take time due to rate limiting 37 | 38 | ### 4. Testing 39 | - **test_rate_limiting.py**: Simple test script to verify rate limiting configuration 40 | 41 | ## Technical Details 42 | 43 | ### Rate Limiting Strategy 44 | 1. **Fixed Delay**: 1 second between each API call (configurable) 45 | 2. **Retry Logic**: Up to 3 attempts with exponential backoff (2s, 4s, 6s) 46 | 3. **Error Handling**: Comprehensive logging and graceful degradation 47 | 4. **API Coverage**: Applied to all external API calls: 48 | - OpenLibrary book data 49 | - Google Books API (search and metadata) 50 | - Cover image downloads 51 | 52 | ### Benefits 53 | - **Prevents API throttling** during large bulk imports 54 | - **Automatic retry** for transient network issues 55 | - **Configurable delays** can be adjusted based on API provider requirements 56 | - **Detailed logging** for troubleshooting import issues 57 | - **User-friendly** progress feedback 58 | 59 | ### API Provider Considerations 60 | - **OpenLibrary**: No official rate limits, but recommends being respectful 61 | - **Google Books**: 1000 requests per day for free tier 62 | - **Cover downloads**: Various CDNs, generally permissive 63 | 64 | ## Configuration 65 | Rate limiting can be adjusted by modifying the constants at the top of `app/utils.py`: 66 | 67 | ```python 68 | API_RATE_LIMIT_DELAY = 0.5 # Increase for more aggressive rate limiting (reduced from 1.0s) 69 | MAX_RETRIES = 3 # Increase for unreliable networks 70 | RETRY_DELAY = 2.0 # Increase for stricter backoff 71 | ``` 72 | 73 | ## Future Improvements 74 | - ✅ **Background job processing for bulk imports** - COMPLETED 75 | - Dynamic rate limiting based on API response headers 76 | - Progress bar for bulk imports (replaced with real-time progress tracking) 77 | - Configurable rate limits per API provider 78 | 79 | ## Background Task System 80 | **NEW in this update**: Bulk imports now run as background tasks to prevent web server timeouts! 81 | 82 | ### Features: 83 | - **Real-time Progress Tracking**: Live updates on import progress with success/error counts 84 | - **No Timeout Issues**: Web requests return immediately while imports run in background 85 | - **Task Management**: View and monitor all background tasks through the "Tasks" link in navigation 86 | - **Persistent State**: Task progress is stored in database and survives container restarts 87 | - **Improved UX**: Users can navigate away and return to check progress 88 | 89 | ### Technical Implementation: 90 | - **Task Model**: New `Task` table tracks job status, progress, and results 91 | - **Threading**: Python threading for background job execution with proper Flask app context 92 | - **API Endpoints**: RESTful endpoints for task status monitoring (`/api/task/`) 93 | - **Auto-refresh UI**: JavaScript automatically polls for progress updates 94 | - **Rate Limiting**: Still applies (0.5s delays) but doesn't block the web interface 95 | --------------------------------------------------------------------------------