├── tests ├── __init__.py ├── cli │ ├── __init__.py │ ├── tools │ │ ├── __init__.py │ │ └── test_update_paths.py │ ├── import_command │ │ ├── __init__.py │ │ ├── test_copy_to_library.py │ │ └── test_calibre_meta_file.py │ ├── test_init_command.py │ ├── test_remove_command.py │ ├── test_list_command.py │ └── _cli_test_base.py ├── file_types │ ├── __init__.py │ └── test_base.py ├── test_create_app.py └── models │ └── test_book.py ├── lspace ├── api_v1_blueprint │ ├── resources │ │ ├── __init__.py │ │ ├── version.py │ │ ├── book_file.py │ │ └── book.py │ ├── __init__.py │ ├── resource_helpers.py │ └── models │ │ └── __init__.py ├── cli │ ├── import_command │ │ ├── import_options │ │ │ ├── __init__.py │ │ │ ├── skip_book.py │ │ │ ├── peek.py │ │ │ ├── manual_search.py │ │ │ ├── isbn_lookup.py │ │ │ ├── _options.py │ │ │ └── manual_import.py │ │ ├── add_to_shelf_options │ │ │ ├── __init__.py │ │ │ ├── _options.py │ │ │ ├── put_in_default_shelf.py │ │ │ └── put_in_new_shelf.py │ │ ├── base_helper.py │ │ ├── add_book_to_db.py │ │ ├── __init__.py │ │ ├── check_if_in_library.py │ │ ├── add_to_shelf.py │ │ ├── copy_to_library.py │ │ ├── import_from_api │ │ │ └── __init__.py │ │ ├── import_from_calibre.py │ │ └── _import.py │ ├── version_command.py │ ├── tools_command │ │ ├── rebuild_search_index.py │ │ ├── clear_cache.py │ │ ├── __init__.py │ │ └── update_paths.py │ ├── db_command.py │ ├── __init__.py │ ├── init_command.py │ ├── reimport_command.py │ ├── list_command.py │ ├── remove_command.py │ ├── web_command.py │ └── export_command.py ├── migrations │ ├── README │ ├── alembic.ini │ ├── script.py.mako │ ├── versions │ │ ├── 91732d5540b9_.py │ │ ├── d8f2fb9f2472_.py │ │ ├── e2bbe7fd9bc7_.py │ │ ├── b9f9b5b4e882_.py │ │ └── b142d6d9e8da_.py │ └── env.py ├── models │ ├── __init__.py │ ├── shelf.py │ ├── book_author_association.py │ ├── author.py │ ├── meta_cache.py │ └── book.py ├── frontend_blueprint │ ├── static │ │ └── style.css │ ├── templates │ │ ├── books.html.jinja2 │ │ ├── base.html.jinja2 │ │ └── macros.html.jinja2 │ └── __init__.py ├── file_types │ ├── __init__.py │ ├── epub.py │ ├── pdf.py │ └── _base.py ├── __init__.py └── helpers │ ├── init_logging.py │ ├── __init__.py │ ├── create_app.py │ └── query.py ├── pytest.ini ├── lspace_screenshot.png ├── .gitignore ├── MANIFEST.in ├── .bumpversion.cfg ├── .travis.yml ├── dodo.py ├── setup.py ├── README.md └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/cli/tools/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/file_types/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/cli/import_command/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lspace/api_v1_blueprint/resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lspace/cli/import_command/import_options/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lspace/cli/import_command/add_to_shelf_options/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lspace/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --cov=lspace --cov-report html 3 | 4 | -------------------------------------------------------------------------------- /lspace_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/puhoy/lspace/HEAD/lspace_screenshot.png -------------------------------------------------------------------------------- /lspace/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .author import Author 2 | from .book import Book 3 | from .shelf import Shelf 4 | 5 | from .book_author_association import book_author_association_table 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | __pycache__ 4 | env 5 | env_* 6 | *.egg-info 7 | build 8 | dist 9 | .doit.db 10 | .coverage 11 | htmlcov 12 | *.pyc 13 | node_modules 14 | dist 15 | -------------------------------------------------------------------------------- /lspace/cli/version_command.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from lspace import __version__ 4 | from lspace.cli import cli_bp 5 | 6 | 7 | @cli_bp.cli.command(help='print the version') 8 | def version(): 9 | click.echo(__version__) 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | 2 | include README.md 3 | recursive-include lspace/migrations * 4 | recursive-include lspace/frontend_blueprint/templates * 5 | recursive-include lspace/frontend_blueprint/static * 6 | global-exclude __pycache__ 7 | global-exclude *.py[co] 8 | -------------------------------------------------------------------------------- /lspace/api_v1_blueprint/resources/version.py: -------------------------------------------------------------------------------- 1 | from flask_restx import Resource 2 | 3 | 4 | class Version(Resource): 5 | 6 | def get(self, **kwargs): 7 | from lspace import __version__, APP_NAME 8 | return dict(name=APP_NAME, version=__version__) 9 | -------------------------------------------------------------------------------- /lspace/cli/import_command/import_options/skip_book.py: -------------------------------------------------------------------------------- 1 | from lspace.cli.import_command.base_helper import BaseHelper 2 | 3 | 4 | class SkipBook(BaseHelper): 5 | explanation = 'skip this book' 6 | 7 | @classmethod 8 | def function(cls, *args, **kwargs): 9 | return False 10 | -------------------------------------------------------------------------------- /lspace/frontend_blueprint/static/style.css: -------------------------------------------------------------------------------- 1 | 2 | #import_string { 3 | white-space: pre-wrap; /* css-3 */ 4 | white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ 5 | white-space: -pre-wrap; /* Opera 4-6 */ 6 | white-space: -o-pre-wrap; /* Opera 7 */ 7 | word-wrap: break-word; /* Internet Explorer 5.5+ */ 8 | } -------------------------------------------------------------------------------- /lspace/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | -------------------------------------------------------------------------------- /lspace/cli/tools_command/rebuild_search_index.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from lspace import whooshee 4 | from lspace.cli.tools_command import tools 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | @tools.command(help='rebuild the search index for your library') 10 | def rebuild_search_index(): 11 | whooshee.reindex() 12 | -------------------------------------------------------------------------------- /lspace/cli/import_command/base_helper.py: -------------------------------------------------------------------------------- 1 | class BaseHelper: 2 | explanation = '' 3 | 4 | @classmethod 5 | def function(cls, *args, **kwargs): 6 | raise NotImplementedError 7 | 8 | @classmethod 9 | def get_dict(cls): 10 | return dict(function=cls.function, 11 | explanation=cls.explanation) -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.4.8 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:lspace/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | -------------------------------------------------------------------------------- /lspace/cli/import_command/add_to_shelf_options/_options.py: -------------------------------------------------------------------------------- 1 | from lspace.cli.import_command.add_to_shelf_options.put_in_default_shelf import PutInDefaultShelf 2 | from lspace.cli.import_command.add_to_shelf_options.put_in_new_shelf import PutInNewShelf 3 | 4 | choose_shelf_other_choices = { 5 | 'n': PutInNewShelf.get_dict(), 6 | 'd': PutInDefaultShelf.get_dict() 7 | } 8 | -------------------------------------------------------------------------------- /lspace/cli/db_command.py: -------------------------------------------------------------------------------- 1 | from . import cli_bp 2 | 3 | from flask_migrate import init, migrate, upgrade 4 | 5 | 6 | @cli_bp.cli.group() 7 | def db(): 8 | pass 9 | 10 | 11 | @db.command(name='init') 12 | def _init(): 13 | init() 14 | 15 | 16 | @db.command(name='migrate') 17 | def _migrate(): 18 | migrate() 19 | 20 | 21 | @db.command(name='upgrade') 22 | def _upgrade(): 23 | upgrade() 24 | -------------------------------------------------------------------------------- /lspace/cli/import_command/import_options/peek.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from lspace.cli.import_command.base_helper import BaseHelper 4 | 5 | 6 | class Peek(BaseHelper): 7 | explanation = 'peek in the text' 8 | 9 | @classmethod 10 | def function(cls, file_type_object, old_choices, *args, **kwargs): 11 | click.echo_via_pager(file_type_object.get_text()) 12 | return old_choices 13 | -------------------------------------------------------------------------------- /tests/test_create_app.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from lspace import create_app 4 | 5 | 6 | class TestCreateApp(unittest.TestCase): 7 | def test_create_app(self): 8 | """ 9 | basic first test: just check if it breaks when app is created and commands imported 10 | 11 | :return: 12 | """ 13 | app = create_app() 14 | from lspace.cli import cli as cli_group 15 | assert app 16 | -------------------------------------------------------------------------------- /lspace/cli/tools_command/clear_cache.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import click 4 | 5 | from lspace import db 6 | from lspace.cli.tools_command import tools 7 | from lspace.models.meta_cache import MetaCache 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | @tools.command(help='clear metadata cache') 12 | def clear_cache(): 13 | num = MetaCache.query.delete() 14 | db.session.commit() 15 | click.echo('deleted %s rows' % num) 16 | -------------------------------------------------------------------------------- /lspace/cli/import_command/add_to_shelf_options/put_in_default_shelf.py: -------------------------------------------------------------------------------- 1 | from lspace.cli.import_command.base_helper import BaseHelper 2 | from lspace.models import Book 3 | 4 | 5 | class PutInDefaultShelf(BaseHelper): 6 | explanation = 'add to default shelf' 7 | 8 | @classmethod 9 | def function(cls, book, *args, **kwargs): 10 | # type: (Book, list, dict) -> Book 11 | book.shelf = None 12 | return book 13 | 14 | -------------------------------------------------------------------------------- /lspace/models/shelf.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from sqlalchemy import Column, Integer, String 4 | from sqlalchemy.orm import relationship 5 | 6 | from lspace import db 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Shelf(db.Model): 12 | __tablename__ = 'shelves' 13 | 14 | id = Column(Integer, primary_key=True) 15 | name = Column(String(100)) 16 | 17 | books = relationship("Book", back_populates="shelf", cascade="") 18 | -------------------------------------------------------------------------------- /lspace/cli/import_command/import_options/manual_search.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from lspace.cli.import_command.base_helper import BaseHelper 4 | from lspace.helpers.query import query_google_books 5 | 6 | 7 | class ManualSearch(BaseHelper): 8 | explanation = 'run manual query' 9 | 10 | @classmethod 11 | def function(cls, *args, **kwargs): 12 | search_string = click.prompt('search string') 13 | return query_google_books(search_string) 14 | -------------------------------------------------------------------------------- /tests/cli/test_init_command.py: -------------------------------------------------------------------------------- 1 | from lspace.cli.init_command import init 2 | from tests.cli._cli_test_base import BaseCliTest 3 | 4 | 5 | class TestInitCommand(BaseCliTest): 6 | 7 | def test_init_command(self): 8 | runner = self.app.test_cli_runner() 9 | 10 | # invoke the command directly 11 | result = runner.invoke(init, []) 12 | assert result.exit_code == 0 13 | assert 'written config file to ' in result.output 14 | assert self.test_dir in result.output 15 | -------------------------------------------------------------------------------- /tests/cli/test_remove_command.py: -------------------------------------------------------------------------------- 1 | from lspace.cli import remove_command 2 | from tests.cli._cli_test_base import BaseCliTest 3 | 4 | 5 | class TestListCommand(BaseCliTest): 6 | 7 | def test_remove(self): 8 | runner = self.app.test_cli_runner() 9 | 10 | result = runner.invoke(remove_command.remove, [self.test_title]) 11 | 12 | assert result.exit_code == 0 13 | 14 | assert '{author} - {title}'.format(author=self.test_author_name, title=self.test_title) in result.output 15 | -------------------------------------------------------------------------------- /lspace/file_types/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from ._base import FileTypeBase 5 | from .pdf import PDF 6 | from .epub import Epub 7 | 8 | mapping = { 9 | '.pdf': PDF, 10 | '.epub': Epub 11 | } 12 | 13 | 14 | def get_file_type_object(path): 15 | # type: (Path) -> FileTypeBase 16 | file_extension = path.suffix 17 | 18 | file_class = mapping.get(file_extension, None) 19 | if file_class: 20 | return file_class(str(path)) 21 | else: 22 | return None 23 | -------------------------------------------------------------------------------- /lspace/cli/import_command/import_options/isbn_lookup.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from lspace.cli.import_command.base_helper import BaseHelper 4 | from lspace.helpers.query import query_isbn_data 5 | 6 | 7 | class ISBNLookup(BaseHelper): 8 | explanation = 'search by isbn' 9 | 10 | @classmethod 11 | def function(cls, *args, **kwargs): 12 | isbn_str = click.prompt( 13 | 'specify isbn (without whitespaces, only the number)') 14 | result = query_isbn_data(isbn_str) 15 | if result: 16 | return [result] 17 | return [] 18 | -------------------------------------------------------------------------------- /lspace/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | #from flask.cli import AppGroup 4 | from flask import Blueprint 5 | 6 | cli_bp = Blueprint('cli', __name__, cli_group=None) 7 | 8 | from .import_command import import_command 9 | from .init_command import init 10 | from .list_command import _list 11 | from .tools_command import tools 12 | from .remove_command import remove 13 | from .reimport_command import reimport 14 | from .export_command import export 15 | from .web_command import web 16 | from .version_command import version 17 | 18 | if os.environ.get('LSPACE_DEV', None) == '1': 19 | from .db_command import db 20 | -------------------------------------------------------------------------------- /lspace/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /lspace/cli/import_command/import_options/_options.py: -------------------------------------------------------------------------------- 1 | from lspace.cli.import_command.import_options.isbn_lookup import ISBNLookup 2 | from lspace.cli.import_command.import_options.manual_import import ManualImport 3 | from lspace.cli.import_command.import_options.manual_search import ManualSearch 4 | from lspace.cli.import_command.import_options.peek import Peek 5 | from lspace.cli.import_command.import_options.skip_book import SkipBook 6 | 7 | other_choices = { 8 | 'q': ManualSearch.get_dict(), 9 | 'i': ISBNLookup.get_dict(), 10 | 'p': Peek.get_dict(), 11 | 'm': ManualImport.get_dict(), 12 | 's': SkipBook.get_dict(), 13 | } 14 | -------------------------------------------------------------------------------- /lspace/models/book_author_association.py: -------------------------------------------------------------------------------- 1 | from lspace import db 2 | 3 | book_author_association_table = db.Table('book_author_association', 4 | db.Column('book_id', 5 | db.Integer, 6 | db.ForeignKey('books.id'), 7 | ), 8 | db.Column('author_id', 9 | db.Integer, 10 | db.ForeignKey('authors.id')) 11 | ) 12 | -------------------------------------------------------------------------------- /lspace/frontend_blueprint/templates/books.html.jinja2: -------------------------------------------------------------------------------- 1 | {% extends "base.html.jinja2" %} 2 | {% import 'macros.html.jinja2' as macros %} 3 | 4 | {% block content %} 5 | 6 |
7 |
8 | {{ macros.pagination(paginated_books ) }} 9 |
10 |
11 | {% for book in paginated_books['items'] %} 12 |
13 | {{ macros.book(book) }} 14 |
15 | {% endfor %} 16 |
17 |
18 | {{ macros.pagination(paginated_books ) }} 19 |
20 | 21 |
22 | 23 | {% endblock %} -------------------------------------------------------------------------------- /lspace/models/author.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | from sqlalchemy.orm import relationship 3 | 4 | from lspace import db, whooshee 5 | 6 | 7 | @whooshee.register_model('name') 8 | class Author(db.Model): 9 | __tablename__ = 'authors' 10 | __searchable__ = ['name'] 11 | 12 | id = Column(Integer, primary_key=True) 13 | name = Column(String(100)) 14 | books = relationship("Book", 15 | secondary="book_author_association", 16 | back_populates="authors", 17 | cascade="" 18 | ) 19 | 20 | def from_dict(self, d): 21 | self.name = d.get('name', None) 22 | 23 | def __repr__(self): 24 | return '<{name}>'.format(name=self.name) 25 | -------------------------------------------------------------------------------- /lspace/api_v1_blueprint/resources/book_file.py: -------------------------------------------------------------------------------- 1 | from flask_restx import Resource 2 | 3 | from flask import send_file 4 | from lspace.models import Book 5 | 6 | import logging 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class BookFile(Resource): 12 | 13 | def get(self, md5sum, **kwargs): 14 | book = Book.query.filter_by(md5sum=md5sum).first() 15 | download_name = '{authors_slug}-{title_slug}{extension}'.format( 16 | authors_slug=book.author_names_slug, 17 | title_slug=book.title_slug, 18 | extension=book.extension 19 | ) 20 | logger.debug(f"sending {book.full_path}") 21 | return send_file(book.full_path, 22 | as_attachment=True, 23 | download_name=download_name) 24 | -------------------------------------------------------------------------------- /lspace/api_v1_blueprint/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask_restx import Api 3 | 4 | api_blueprint = Blueprint('api', __name__, 5 | template_folder='templates') 6 | 7 | api = Api(api_blueprint, version='1.0', title='L-Space API', 8 | description='L-Space API', 9 | ) 10 | 11 | from lspace.api_v1_blueprint.resources.book import BookItem, BookCollection 12 | from lspace.api_v1_blueprint.resources.book_file import BookFile 13 | 14 | from lspace.api_v1_blueprint.resources.version import Version 15 | 16 | api.add_resource(BookItem, '/books/') 17 | api.add_resource(BookCollection, '/books/') 18 | 19 | api.add_resource(BookFile, '/files/books/') 20 | # api.add_resource('', '/files/covers/') 21 | 22 | api.add_resource(Version, '/version/') 23 | -------------------------------------------------------------------------------- /lspace/cli/import_command/add_book_to_db.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | 4 | from lspace.file_types import FileTypeBase 5 | from lspace.models import Book 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def add_book_to_db(file_type_object, result, file_path, is_external_path): 11 | # type: (FileTypeBase, Book, str, bool) -> Book 12 | """ 13 | 14 | :param file_type_object: wrapper for the file we want to import 15 | :param result: chosen metadata result 16 | :param path_in_library: the path after import 17 | :return: 18 | """ 19 | 20 | result.md5sum = file_type_object.get_md5() 21 | result.path = file_path 22 | result.is_external_path = is_external_path 23 | 24 | logger.info('adding book %s' % result.to_dict()) 25 | 26 | result.save() 27 | 28 | return result 29 | -------------------------------------------------------------------------------- /lspace/migrations/versions/91732d5540b9_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 91732d5540b9 4 | Revises: e2bbe7fd9bc7 5 | Create Date: 2019-06-06 22:52:21.057872 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '91732d5540b9' 14 | down_revision = 'e2bbe7fd9bc7' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('books', sa.Column('metadata_source', sa.String(length=20), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('books', 'metadata_source') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /lspace/cli/import_command/__init__.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from lspace.cli import cli_bp 4 | from lspace.cli.import_command._import import import_wizard 5 | 6 | 7 | @cli_bp.cli.command(name='import', help='import ebooks into your database') 8 | @click.argument('document_path', type=click.Path(), nargs=-1) 9 | @click.option('--skip-library-check', help='dont check if this file is in the library already', default=False, 10 | is_flag=True) 11 | @click.option('--move', help='move imported files instead copying', default=False, is_flag=True) 12 | @click.option('--inplace', help='add this file to the library, but keep it where it is', default=False, is_flag=True) 13 | def import_command(document_path, skip_library_check, move, inplace): 14 | for path in document_path: 15 | import_wizard(path, skip_library_check, move, inplace) 16 | -------------------------------------------------------------------------------- /lspace/models/meta_cache.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | 4 | from sqlalchemy import Column, String, DateTime, Text, Integer 5 | 6 | from lspace import db 7 | 8 | 9 | class MetaCache(db.Model): 10 | __tablename__ = 'meta_cache' 11 | 12 | id = Column(Integer, primary_key=True) 13 | isbn = Column(String(13)) 14 | service = Column(String(10)) 15 | date = Column(DateTime(), default=datetime.datetime.now()) 16 | 17 | # sqlite doesnt like json, and we dont want to run queries on json keys, 18 | # so we just use text and parse. 19 | data = Column(Text()) 20 | 21 | @property 22 | def results(self): 23 | results = json.loads(self.data) 24 | return results 25 | 26 | @results.setter 27 | def results(self, results): 28 | data = json.dumps(results) 29 | self.data = data 30 | -------------------------------------------------------------------------------- /lspace/cli/init_command.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import click 4 | import yaml 5 | 6 | from lspace.helpers import get_default_config 7 | from lspace.cli import cli_bp 8 | 9 | 10 | @cli_bp.cli.command(help='generate a new config') 11 | def init(): 12 | from flask import current_app 13 | 14 | app_dir = current_app.config['APP_DIR'] 15 | config_path = current_app.config['CONFIG_PATH'] 16 | 17 | if not os.path.isdir(app_dir): 18 | os.makedirs(app_dir) 19 | 20 | if os.path.exists(config_path): 21 | if not click.confirm('config exists - override?'): 22 | return 23 | 24 | default_config = get_default_config(app_dir=app_dir) 25 | with open(config_path, 'w') as config: 26 | yaml.dump(default_config, config) 27 | click.echo('written config file to %s' % config_path) 28 | 29 | return config_path 30 | -------------------------------------------------------------------------------- /lspace/cli/import_command/check_if_in_library.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from typing import Set 4 | 5 | from lspace import db 6 | from lspace.models import Book 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | 12 | def check_if_in_library(result): 13 | # type: (Book) -> Set[Book] 14 | """ 15 | 16 | :param file_type_object: wrapper for the file we want to import 17 | :param result: chosen metadata result 18 | :param path_in_library: the path after import 19 | :return: 20 | """ 21 | 22 | title = result.title 23 | isbn13 = result.isbn13 24 | publisher = result.publisher 25 | year = result.year 26 | language = result.language 27 | 28 | 29 | if isbn13: 30 | books = Book.query.filter_by(isbn13=isbn13).all() 31 | else: 32 | books = [] 33 | books += Book.query.filter(Book.title.ilike(title.replace(' ', '%')) ).distinct() 34 | return set(books) 35 | -------------------------------------------------------------------------------- /lspace/migrations/versions/d8f2fb9f2472_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: d8f2fb9f2472 4 | Revises: b9f9b5b4e882 5 | Create Date: 2022-07-18 21:50:24.181433 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'd8f2fb9f2472' 14 | down_revision = 'b9f9b5b4e882' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('books', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('is_external_path', sa.Boolean(), nullable=True)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('books', schema=None) as batch_op: 30 | batch_op.drop_column('is_external_path') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /lspace/cli/import_command/add_to_shelf_options/put_in_new_shelf.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from lspace.cli.import_command.base_helper import BaseHelper 4 | from lspace.models import Book, Shelf 5 | 6 | 7 | class PutInNewShelf(BaseHelper): 8 | explanation = 'add to new shelf' 9 | 10 | @classmethod 11 | def function(cls, book, *args, **kwargs): 12 | # type: (Book, list, dict) -> Book 13 | while True: 14 | shelf_name = click.prompt( 15 | 'whats the name of the new shelf?') 16 | existing_shelf = Shelf.query.filter_by(name=shelf_name).first() 17 | if not existing_shelf: 18 | shelf = Shelf(name=shelf_name) 19 | else: 20 | if click.confirm('shelf with this name already exists - put book in this shelf?'): 21 | shelf = existing_shelf 22 | else: 23 | continue 24 | book.shelf = shelf 25 | return book 26 | 27 | -------------------------------------------------------------------------------- /lspace/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.4.8' 2 | 3 | import os 4 | os.environ['FLASK_APP'] = os.path.join(os.path.dirname(__file__), 'app.py') 5 | 6 | import click 7 | from flask.cli import FlaskGroup 8 | 9 | from flask_migrate import Migrate 10 | from flask_sqlalchemy import SQLAlchemy 11 | from flask_whooshee import Whooshee 12 | from sqlalchemy import MetaData 13 | 14 | 15 | # fix migration for sqlite: https://github.com/miguelgrinberg/Flask-Migrate/issues/61#issuecomment-208131722 16 | naming_convention = { 17 | "ix": 'ix_%(column_0_label)s', 18 | "uq": "uq_%(table_name)s_%(column_0_name)s", 19 | "ck": "ck_%(table_name)s_%(column_0_name)s", 20 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 21 | "pk": "pk_%(table_name)s" 22 | } 23 | db = SQLAlchemy(metadata=MetaData(naming_convention=naming_convention)) 24 | 25 | migrate = Migrate() 26 | whooshee = Whooshee() 27 | 28 | from lspace.helpers.create_app import create_app 29 | 30 | 31 | @click.group(cls=FlaskGroup, create_app=create_app, add_default_commands=False) 32 | def cli(): 33 | pass 34 | 35 | -------------------------------------------------------------------------------- /lspace/migrations/versions/e2bbe7fd9bc7_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: e2bbe7fd9bc7 4 | Revises: b142d6d9e8da 5 | Create Date: 2019-05-20 23:22:16.784309 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'e2bbe7fd9bc7' 14 | down_revision = 'b142d6d9e8da' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('meta_cache', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('isbn', sa.String(length=13), nullable=True), 24 | sa.Column('service', sa.String(length=10), nullable=True), 25 | sa.Column('date', sa.DateTime(), nullable=True), 26 | sa.Column('data', sa.Text(), nullable=True), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade(): 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | op.drop_table('meta_cache') 35 | # ### end Alembic commands ### 36 | -------------------------------------------------------------------------------- /lspace/cli/reimport_command.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from lspace.cli.import_command import import_wizard 4 | from lspace.cli import cli_bp 5 | from lspace import db 6 | from lspace.helpers.query import query_db 7 | 8 | 9 | @cli_bp.cli.command(help='reimport books in library') 10 | @click.argument('query', nargs=-1) 11 | def reimport(query): 12 | if not query: 13 | exit() 14 | results = query_db(query) 15 | 16 | for result in results: 17 | click.echo('\n') 18 | click.echo('{result.authors_names} - {result.title}'.format(result=result)) 19 | click.echo('{result.full_path}'.format(result=result)) 20 | if click.confirm('reimport this?'): 21 | # delete without commit to exclude this book from db check 22 | db.session.delete(result) 23 | new_result = import_wizard(result.full_path, skip_library_check=True, move=True) 24 | if new_result: 25 | new_result.save() 26 | else: 27 | click.secho('no new entry - keeping the old one!') 28 | db.session.rollback() 29 | -------------------------------------------------------------------------------- /lspace/helpers/init_logging.py: -------------------------------------------------------------------------------- 1 | 2 | from logging.config import dictConfig 3 | 4 | def init_logging(loglevel='info'): 5 | dictConfig({ 6 | 'version': 1, 7 | 'disable_existing_loggers': False, 8 | 'formatters': { 9 | 'default': { 10 | 'format': '%(name)s [%(levelname)s]: %(message)s' 11 | }, 12 | }, 13 | 'handlers': { 14 | 'default': { 15 | 'level': loglevel.upper(), 16 | 'class': 'logging.StreamHandler', 17 | 'formatter': 'default' 18 | }, 19 | }, 20 | 'loggers': { 21 | '': { 22 | 'handlers': ['default'], 23 | 'level': loglevel.upper(), 24 | 'propagate': True 25 | }, 26 | 'alembic': { 27 | 'handlers': ['default'], 28 | 'level': 'WARNING', 29 | 'propagate': True 30 | } 31 | 32 | #'sqlalchemy.engine': { 33 | # 'handlers': ['default'], 34 | # 'level': loglevel.upper(), 35 | # 'propagate': False 36 | #} 37 | } 38 | }) 39 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | matrix: 4 | include: 5 | - name: "xenial 3.5" 6 | python: "3.5" 7 | dist: xenial 8 | 9 | - name: "xenial 3.6" 10 | python: "3.6" 11 | dist: xenial 12 | 13 | - name: "xenial 3.7" 14 | python: "3.7" 15 | dist: xenial 16 | 17 | - name: "trusty 3.5" 18 | python: "3.5" 19 | dist: trusty 20 | 21 | - name: "trusty 3.6" 22 | python: "3.6" 23 | dist: trusty 24 | 25 | - name: "osx 3.7" 26 | os: osx 27 | osx_image: xcode10.2 # Python 3.7.2 running on macOS 10.14.3 28 | language: shell # 'language: python' is an error on Travis CI macOS 29 | python: "3.7" 30 | 31 | - name: "osx 3.6" 32 | os: osx 33 | osx_image: xcode10.2 # Python 3.7.2 running on macOS 10.14.3 34 | language: shell # 'language: python' is an error on Travis CI macOS 35 | python: "3.6" 36 | 37 | install: pip3 install -e .[test] || pip install -e .[test] 38 | 39 | # https://docs.travis-ci.com/user/languages/python/#running-python-tests-on-multiple-operating-systems 40 | script: python3 -m pytest || python -m pytest 41 | 42 | # Push the results back to codecov 43 | after_success: 44 | - codecov 45 | -------------------------------------------------------------------------------- /lspace/cli/list_command.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from lspace.cli import cli_bp 4 | from lspace.helpers.query import query_db 5 | 6 | 7 | @cli_bp.cli.command(name='list', help='query your database') 8 | @click.argument('query', nargs=-1) 9 | @click.option('--path', is_flag=True) 10 | @click.option('--details', is_flag=True) 11 | def _list(query, path, details): 12 | results = query_db(query) 13 | 14 | if path: 15 | for result in results: 16 | click.echo( 17 | result.full_path) 18 | return 19 | 20 | if details: 21 | for result in results: 22 | head = '{result.authors_names} - {result.title}'.format(result=result) 23 | if result.shelf: 24 | head += ' ({result.shelf.name})'.format(result=result) 25 | click.echo(head) 26 | 27 | click.echo('{result.full_path}'.format(result=result)) 28 | click.echo('language: {result.language}'.format(result=result)) 29 | click.echo('year: {result.year}'.format(result=result)) 30 | click.echo('isbn: {result.isbn13}'.format(result=result)) 31 | click.echo() 32 | return 33 | 34 | for result in results: 35 | click.echo('{result.authors_names} - {result.title}'.format(result=result)) 36 | -------------------------------------------------------------------------------- /lspace/cli/remove_command.py: -------------------------------------------------------------------------------- 1 | import os 2 | import click 3 | 4 | from lspace.cli import cli_bp 5 | from lspace.helpers.query import query_db 6 | from lspace import db 7 | 8 | 9 | @cli_bp.cli.command(help='remove books from library') 10 | @click.argument('query', nargs=-1) 11 | def remove(query): 12 | if not query: 13 | exit() 14 | results = query_db(query) 15 | 16 | for result in results: 17 | click.echo('\n') 18 | click.echo('{result.authors_names} - {result.title}'.format(result=result)) 19 | click.echo('{result.full_path}'.format(result=result)) 20 | if click.confirm('delete this book from library?'): 21 | if result.is_external_path: 22 | if click.confirm(f'this file is not part of the library - should i try to delete the file at "{result.full_path}"?'): 23 | os.unlink(result.full_path) 24 | db.session.delete(result) 25 | db.session.commit() 26 | else: 27 | click.echo("deleting metadata only...") 28 | db.session.delete(result) 29 | db.session.commit() 30 | else: 31 | os.unlink(result.full_path) 32 | db.session.delete(result) 33 | db.session.commit() 34 | -------------------------------------------------------------------------------- /tests/cli/test_list_command.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from lspace.cli import list_command 4 | from tests.cli._cli_test_base import BaseCliTest 5 | 6 | 7 | class TestList(BaseCliTest): 8 | 9 | def test_list_command_simple(self): 10 | runner = self.app.test_cli_runner() 11 | 12 | result = runner.invoke(list_command._list, []) 13 | 14 | assert result.exit_code == 0 15 | 16 | print(result.output) 17 | 18 | assert '{author} - {title}'.format(author=self.test_author_name, title=self.test_title) in result.output 19 | 20 | def test_list_command_details(self): 21 | runner = self.app.test_cli_runner() 22 | 23 | result = runner.invoke(list_command._list, ['--details']) 24 | 25 | assert result.exit_code == 0 26 | 27 | assert '{author} - {title}'.format(author=self.test_author_name, title=self.test_title) in result.output 28 | assert 'language: ' in result.output 29 | assert 'year: ' in result.output 30 | assert 'isbn: ' in result.output 31 | 32 | def test_list_command_path(self): 33 | runner = self.app.test_cli_runner() 34 | 35 | result = runner.invoke(list_command._list, ['--path']) 36 | 37 | assert result.exit_code == 0 38 | 39 | assert os.path.join(self.test_dir, self.test_path) in result.output 40 | -------------------------------------------------------------------------------- /lspace/cli/tools_command/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import click 4 | import isbnlib 5 | import yaml 6 | 7 | from lspace.cli import cli_bp 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | @cli_bp.cli.group(help='tools you probably never need... :P') 13 | def tools(): 14 | pass 15 | 16 | 17 | from lspace.cli.tools_command.clear_cache import clear_cache 18 | from lspace.cli.tools_command.rebuild_search_index import rebuild_search_index 19 | from lspace.cli.tools_command.update_paths import _update_paths 20 | 21 | 22 | @tools.command(help='convert isbn-10 to isbn-13') 23 | @click.argument('dirtyisbn') 24 | def convert_to_isbn13(dirtyisbn): 25 | click.echo(isbnlib.to_isbn13(dirtyisbn)) 26 | 27 | 28 | @tools.command(help='find metadata for books by words') 29 | @click.argument('words') 30 | def find_meta_by_text(words): 31 | if isbnlib.is_isbn13: 32 | click.echo('%s looks like isbn!' % words) 33 | results = isbnlib.meta(words, service='openl') 34 | else: 35 | results = isbnlib.goom(words) 36 | click.echo(yaml.dump(results)) 37 | 38 | 39 | @tools.command(help='start ipython') 40 | def shell(): 41 | import pdb 42 | import rlcompleter, readline 43 | readline.parse_and_bind('tab: complete') 44 | 45 | pdb.Pdb.complete = rlcompleter.Completer(locals()).complete 46 | 47 | breakpoint() 48 | -------------------------------------------------------------------------------- /lspace/migrations/versions/b9f9b5b4e882_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: b9f9b5b4e882 4 | Revises: 91732d5540b9 5 | Create Date: 2019-06-14 10:39:42.779170 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'b9f9b5b4e882' 14 | down_revision = '91732d5540b9' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('shelves', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('name', sa.String(length=100), nullable=True), 24 | sa.PrimaryKeyConstraint('id', name=op.f('pk_shelves')) 25 | ) 26 | with op.batch_alter_table('books', schema=None) as batch_op: 27 | batch_op.add_column(sa.Column('shelve_id', sa.Integer(), nullable=True)) 28 | batch_op.create_foreign_key(batch_op.f('fk_books_shelve_id_shelves'), 'shelves', ['shelve_id'], ['id']) 29 | 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | with op.batch_alter_table('books', schema=None) as batch_op: 36 | batch_op.drop_constraint(batch_op.f('fk_books_shelve_id_shelves'), type_='foreignkey') 37 | batch_op.drop_column('shelve_id') 38 | 39 | op.drop_table('shelves') 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /lspace/cli/tools_command/update_paths.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | import click 5 | 6 | from lspace.cli.import_command.copy_to_library import copy_to_library 7 | from lspace.cli.tools_command import tools 8 | from lspace.file_types import get_file_type_object 9 | from lspace.models import Book 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def update_path(book): 15 | source_path = book.full_path 16 | 17 | file_type_object = get_file_type_object(source_path) 18 | 19 | new_path = copy_to_library(file_type_object.path, book, True) 20 | 21 | book.path = new_path 22 | book.save() 23 | 24 | return new_path 25 | 26 | 27 | @tools.command(help='update paths after changing file_format setting in config', 28 | name='update_paths') 29 | def _update_paths(): 30 | books = Book.query.all() 31 | for book in books: 32 | 33 | source_abs = book.full_path 34 | source_in_library = book.path 35 | new_path = update_path(book) 36 | 37 | source_folder = os.path.dirname(source_abs) 38 | if not os.listdir(source_folder): 39 | click.echo('removing empty folder {source_folder}'.format(source_folder=source_folder)) 40 | os.rmdir(source_folder) 41 | 42 | try: 43 | click.echo('moved {source} to {new_path}'.format(source=source_in_library, new_path=new_path)) 44 | except Exception as e: 45 | logger.exception('error moving file', exc_info=True) 46 | -------------------------------------------------------------------------------- /tests/cli/tools/test_update_paths.py: -------------------------------------------------------------------------------- 1 | from lspace.cli.tools_command import update_paths as update_paths_module 2 | 3 | from lspace.file_types import FileTypeBase 4 | from lspace.models import Book 5 | from tests.cli._cli_test_base import BaseCliTest 6 | 7 | from unittest.mock import MagicMock 8 | 9 | 10 | class TestToolsUpdatePathsCommand(BaseCliTest): 11 | 12 | def test_update_path(self): 13 | with self.app.app_context(): 14 | book = Book.query.first() 15 | 16 | test_new_path = 'new_path' 17 | file_type_object_mock = FileTypeBase(book.path) 18 | 19 | update_paths_module.copy_to_library = MagicMock(return_value=test_new_path) 20 | update_paths_module.get_file_type_object = MagicMock(return_value=file_type_object_mock) 21 | 22 | new_path = update_paths_module.update_path(book) 23 | 24 | assert test_new_path == new_path 25 | assert book.path == new_path 26 | 27 | def test_update_paths(self): 28 | with self.app.app_context(): 29 | update_paths_module.update_path = MagicMock(return_value='asdasd') 30 | book = Book.query.first() 31 | 32 | runner = self.app.test_cli_runner() 33 | 34 | result = runner.invoke(update_paths_module._update_paths) 35 | 36 | assert result.exit_code == 0 37 | 38 | update_paths_module.update_path.assert_called_with(book) 39 | 40 | assert 'moved ' in result.output 41 | -------------------------------------------------------------------------------- /lspace/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import isbnlib 5 | import yaml 6 | import typing 7 | 8 | if typing.TYPE_CHECKING: 9 | pass 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def get_default_config(app_dir): 15 | default_config = { 16 | 'database_path': 'sqlite:///{APP_DIR}/lspace.db'.format( 17 | APP_DIR=app_dir), 18 | 'library_path': '~/library', 19 | 'file_format': '{SHELF}/{AUTHORS}/{TITLE}', 20 | 'loglevel': 'error', 21 | 'default_shelf': 'misc', 22 | 'default_author': 'no author', 23 | 'default_language': 'no language', 24 | 'default_publisher': 'no publisher' 25 | } 26 | return default_config 27 | 28 | 29 | def read_config(config_path, app_dir): 30 | if os.path.isfile(config_path): 31 | with open(config_path, 'r') as config: 32 | conf = yaml.load(config, Loader=yaml.SafeLoader) 33 | 34 | else: 35 | conf = {} 36 | 37 | config = get_default_config(app_dir) 38 | config.update(conf) 39 | return config 40 | 41 | 42 | def preprocess_isbns(isbns): 43 | """ 44 | 45 | :param isbns: isbns in different formats 46 | :return: canonical isbn13s 47 | """ 48 | canonical_isbns = [] 49 | for isbn in isbns: 50 | if not isbnlib.notisbn(isbn, level='strict'): 51 | if isbnlib.is_isbn10(isbn): 52 | isbn = isbnlib.to_isbn13(isbn) 53 | isbn = isbnlib.get_canonical_isbn(isbn) 54 | canonical_isbns.append(isbn) 55 | canonical_isbns = set(canonical_isbns) 56 | return list(canonical_isbns) 57 | -------------------------------------------------------------------------------- /lspace/file_types/epub.py: -------------------------------------------------------------------------------- 1 | 2 | import ebooklib 3 | import html2text 4 | import isbnlib 5 | from ebooklib import epub 6 | 7 | from ._base import FileTypeBase 8 | 9 | 10 | class Epub(FileTypeBase): 11 | extension = '.epub' 12 | 13 | def __init__(self, path): 14 | super().__init__(path) 15 | # todo: ignore_ncx will default to true in next version, 16 | # throws warning if not set 17 | self.book = epub.read_epub(path, options=dict(ignore_ncx=True)) 18 | 19 | def get_text(self): 20 | # type: () -> [str] 21 | text = [] 22 | 23 | for doc in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): 24 | content = doc.get_content().decode('utf-8') 25 | text.append(html2text.html2text(content)) 26 | if True in [bool(t) for t in text]: 27 | return text 28 | return [] 29 | 30 | def get_author(self): 31 | _author = self.book.get_metadata('DC', 'creator') 32 | # [('Firstname Lastname ', {})] 33 | if _author: 34 | return _author[0][0] 35 | return None 36 | 37 | def get_title(self): 38 | _title = self.book.get_metadata('DC', 'title') 39 | # [('Ratio', {})] 40 | if _title: 41 | return _title[0][0] 42 | return None 43 | 44 | def get_isbn(self): 45 | _isbn = self.book.get_metadata('DC', 'identifier') 46 | # [('Ratio', {})] 47 | if _isbn: 48 | isbn = _isbn[0][0] 49 | if not isbn: 50 | return None 51 | if isbnlib.notisbn(isbn): 52 | return None 53 | if isbnlib.is_isbn10(isbn): 54 | return isbnlib.to_isbn13(isbn) 55 | return isbn 56 | return None 57 | -------------------------------------------------------------------------------- /tests/cli/_cli_test_base.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | import unittest 5 | from pathlib import Path 6 | 7 | from flask_migrate import upgrade 8 | 9 | from lspace import create_app 10 | from lspace.models import Book, Author 11 | 12 | 13 | def get_test_app(test_dir): 14 | return create_app(app_dir=test_dir) 15 | 16 | 17 | def get_temp_dir(): 18 | return tempfile.mkdtemp() 19 | 20 | 21 | class BaseCliTest(unittest.TestCase): 22 | 23 | def setUp(self): 24 | 25 | self.test_dir = get_temp_dir() 26 | self.app = get_test_app(self.test_dir) 27 | self.app.config['USER_CONFIG']['library_path'] = os.path.join(self.test_dir, 'library') 28 | 29 | print(self.app.config['USER_CONFIG']) 30 | 31 | self.test_author_name = 'testname' 32 | self.test_title = 'testtitle' 33 | self.test_extenstion = '.txt' 34 | self.test_path = os.path.join(self.app.config['USER_CONFIG']['library_path'], 35 | self.test_author_name, 36 | self.test_title + self.test_extenstion) 37 | if not os.path.isdir(os.path.dirname(self.test_path)): 38 | os.makedirs(os.path.dirname(self.test_path)) 39 | 40 | Path(self.test_path).touch() 41 | 42 | self.test_author = Author(name=self.test_author_name) 43 | self.test_book = Book(authors=[self.test_author], title=self.test_title, path=self.test_path) 44 | 45 | with self.app.app_context(): 46 | upgrade() 47 | from lspace import db 48 | db.session.add(self.test_author) 49 | db.session.add(self.test_book) 50 | db.session.commit() 51 | 52 | def tearDown(self): 53 | shutil.rmtree(self.test_dir) 54 | -------------------------------------------------------------------------------- /lspace/cli/web_command.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | import click 5 | import gunicorn.app.base 6 | from flask import current_app 7 | 8 | from lspace.api_v1_blueprint import api_blueprint 9 | from lspace.cli import cli_bp 10 | from lspace.frontend_blueprint import frontend_blueprint 11 | 12 | 13 | @cli_bp.cli.command(help='start a webserver') 14 | @click.option('--port', default=5000) 15 | @click.option('--host', default='0.0.0.0') 16 | @click.option('--debug', default=False, is_flag=True) 17 | def web(host, port, debug): 18 | current_app.register_blueprint(api_blueprint, url_prefix='/api/v1') 19 | current_app.register_blueprint(frontend_blueprint, url_prefix='') 20 | 21 | # todo: flask_env removed in 2.3.0 22 | if (os.environ.get('LSPACE_DEV', None) == '1') or debug: 23 | os.environ['FLASK_ENV'] = 'development' 24 | print(os.environ['FLASK_ENV']) 25 | current_app.run(debug=True, host=host, port=port) 26 | else: 27 | options = { 28 | 'bind': '{HOST}:{PORT}'.format(HOST=host, PORT=port) 29 | } 30 | StandaloneApplication(current_app, options).run() 31 | 32 | 33 | # http://docs.gunicorn.org/en/latest/custom.html 34 | class StandaloneApplication(gunicorn.app.base.BaseApplication): 35 | 36 | def __init__(self, app, options=None): 37 | self.options = options or {} 38 | self.application = app 39 | super(StandaloneApplication, self).__init__() 40 | 41 | def load_config(self): 42 | config = dict([(key, value) for key, value in self.options.items() 43 | if key in self.cfg.settings and value is not None]) 44 | for key, value in config.items(): 45 | self.cfg.set(key.lower(), value) 46 | 47 | def load(self): 48 | return self.application 49 | 50 | -------------------------------------------------------------------------------- /lspace/api_v1_blueprint/resource_helpers.py: -------------------------------------------------------------------------------- 1 | from apispec import APISpec 2 | from apispec.ext.marshmallow import MarshmallowPlugin 3 | from flask_restx import reqparse 4 | from marshmallow import Schema, fields 5 | 6 | 7 | def get_pagination_args_parser(): 8 | pagination_arguments = reqparse.RequestParser() 9 | pagination_arguments.add_argument('page', type=int, required=False) 10 | pagination_arguments.add_argument('per_page', type=int, required=False, default=50) 11 | return pagination_arguments 12 | 13 | 14 | def add_fields_to_parser(parser, fields): 15 | for field in fields: 16 | parser.add_argument(field, type=str, required=False, store_missing=False) 17 | return parser 18 | 19 | 20 | def get_paginated_marshmallow_schema(marshmallow_schema): 21 | class Paginated(Schema): 22 | prev_num = fields.Integer(allow_none=True) 23 | next_num = fields.Integer(allow_none=True) 24 | has_next = fields.Boolean() 25 | has_prev = fields.Boolean() 26 | total = fields.Integer() 27 | page = fields.Integer() 28 | items = fields.Nested(marshmallow_schema, many=True) 29 | 30 | Paginated.__name__ = 'Paginated' + marshmallow_schema.__name__ 31 | return Paginated 32 | 33 | 34 | def apply_filter_map(query, filter_args, filter_map): 35 | filters = [] 36 | for key, value in filter_args.items(): 37 | if key in filter_map.keys(): 38 | filters.append(filter_map[key](value)) 39 | else: 40 | filters.append(filter_map['__default'](key, value)) 41 | return query.filter(*filters) 42 | 43 | 44 | def run_query(Model, args, filter_map, page, per_page): 45 | query = apply_filter_map(Model.query, args, filter_map) 46 | query = query.paginate(page=page, per_page=per_page, error_out=False) 47 | return query 48 | -------------------------------------------------------------------------------- /lspace/migrations/versions/b142d6d9e8da_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: b142d6d9e8da 4 | Revises: 5 | Create Date: 2019-05-11 14:42:35.971140 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'b142d6d9e8da' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('authors', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('name', sa.String(length=100), nullable=True), 24 | sa.PrimaryKeyConstraint('id') 25 | ) 26 | op.create_table('books', 27 | sa.Column('id', sa.Integer(), nullable=False), 28 | sa.Column('title', sa.String(length=100), nullable=True), 29 | sa.Column('isbn13', sa.String(length=13), nullable=True), 30 | sa.Column('publisher', sa.String(length=100), nullable=True), 31 | sa.Column('year', sa.Integer(), nullable=True), 32 | sa.Column('language', sa.String(length=20), nullable=True), 33 | sa.Column('md5sum', sa.String(length=32), nullable=True), 34 | sa.Column('path', sa.String(length=400), nullable=True), 35 | sa.PrimaryKeyConstraint('id') 36 | ) 37 | op.create_table('book_author_association', 38 | sa.Column('book_id', sa.Integer(), nullable=True), 39 | sa.Column('author_id', sa.Integer(), nullable=True), 40 | sa.ForeignKeyConstraint(['author_id'], ['authors.id'], ), 41 | sa.ForeignKeyConstraint(['book_id'], ['books.id'], ) 42 | ) 43 | # ### end Alembic commands ### 44 | 45 | 46 | def downgrade(): 47 | # ### commands auto generated by Alembic - please adjust! ### 48 | op.drop_table('book_author_association') 49 | op.drop_table('books') 50 | op.drop_table('authors') 51 | # ### end Alembic commands ### 52 | -------------------------------------------------------------------------------- /lspace/cli/import_command/add_to_shelf.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import click 4 | import yaml 5 | 6 | from lspace.cli.import_command.add_to_shelf_options._options import choose_shelf_other_choices 7 | from lspace.models import Shelf 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def _choose_shelf(): 13 | shelves = Shelf.query.all() 14 | shelf_names = [shelf.name for shelf in shelves] 15 | 16 | formatted_choices = {} 17 | for idx, shelf_name in enumerate(shelf_names): 18 | formatted_choices[str(idx + 1)] = click.style( 19 | '{index}: {shelf_name}\n'.format(index=idx + 1, shelf_name=shelf_name), 20 | bold=True) 21 | 22 | for key, val in choose_shelf_other_choices.items(): 23 | formatted_choices[key] = \ 24 | click.style(yaml.dump({ 25 | key: val['explanation']}, 26 | allow_unicode=True), bold=True) 27 | 28 | click.echo(click.style('choose a shelf for this book!', bold=True)) 29 | click.echo(''.join(formatted_choices.values())) 30 | choices = formatted_choices.keys() 31 | 32 | ret = click.prompt('', type=click.Choice(choices), default='d') 33 | 34 | if ret in list(choose_shelf_other_choices.keys()): 35 | choice = ret 36 | else: 37 | try: 38 | idx = int(ret) - 1 39 | choice = shelf_names[idx] 40 | except Exception as e: 41 | logger.exception('cant convert %s to int!' % ret, exc_info=True) 42 | return False 43 | 44 | return choice 45 | 46 | 47 | def add_to_shelf(book): 48 | shelf_or_other = _choose_shelf() 49 | if shelf_or_other in choose_shelf_other_choices.keys(): 50 | f = choose_shelf_other_choices.get(shelf_or_other)['function'] 51 | f(book=book) 52 | else: 53 | shelf = Shelf.query.filter_by(name=shelf_or_other).first() 54 | book.shelf = shelf 55 | -------------------------------------------------------------------------------- /lspace/api_v1_blueprint/resources/book.py: -------------------------------------------------------------------------------- 1 | from flask_restx import Resource 2 | from flask_restx._http import HTTPStatus 3 | 4 | from lspace.api_v1_blueprint import api 5 | from lspace.api_v1_blueprint.models import BookSchema, PaginatedBookSchema 6 | from lspace.api_v1_blueprint.resource_helpers import get_pagination_args_parser, \ 7 | add_fields_to_parser, run_query 8 | from lspace.models import Book, Shelf, Author 9 | 10 | filter_fields = ['title', 'publisher', 'shelf', 'author', 'md5sum', 'language', 'year'] 11 | 12 | args_parser = get_pagination_args_parser() 13 | add_fields_to_parser(args_parser, filter_fields) 14 | 15 | 16 | def filter_by_shelf(value): 17 | if value == 'default': 18 | return Book.shelf == None 19 | else: 20 | return Book.shelf.has(Shelf.name.ilike(value)) 21 | 22 | 23 | def filter_by_author(value): 24 | if value == 'None': 25 | return Book.authors == None 26 | else: 27 | return Book.authors.any(Author.name.ilike(value)) 28 | 29 | 30 | def default_filter(key, value): 31 | if value in ['None', ]: 32 | return getattr(Book, key) == None 33 | return getattr(Book, key).ilike(value) 34 | 35 | 36 | filter_map = { 37 | 'shelf': filter_by_shelf, 38 | 'author': filter_by_author, 39 | '__default': default_filter 40 | } 41 | 42 | 43 | class BookCollection(Resource): 44 | 45 | @api.expect(args_parser, validate=True) 46 | @api.response(200, "OK") 47 | def get(self): 48 | args = args_parser.parse_args() 49 | page = args.pop('page') 50 | per_page = args.pop('per_page') 51 | query = run_query(Book, args, filter_map, page, per_page) 52 | return PaginatedBookSchema().dump(query), HTTPStatus.OK 53 | 54 | 55 | class BookItem(Resource): 56 | @api.response(200, "OK") 57 | def get(self, id, **kwargs): 58 | book = Book.query.get(id) 59 | return BookSchema().dump(book), HTTPStatus.OK 60 | -------------------------------------------------------------------------------- /tests/cli/import_command/test_copy_to_library.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import MagicMock 3 | 4 | from lspace.cli.import_command import copy_to_library 5 | from lspace.models import Book 6 | from tests.cli._cli_test_base import BaseCliTest 7 | 8 | 9 | class TestCopyToLibrary_Move(BaseCliTest): 10 | 11 | def test_copy_to_library(self): 12 | with self.app.app_context(): 13 | book = Book.query.first() 14 | old_path = book.path 15 | new_path = 'new_path' 16 | absolute_library_path = os.path.join(self.app.config['USER_CONFIG']['library_path'], new_path) 17 | 18 | copy_to_library.move = MagicMock(return_value=None) 19 | copy_to_library.copyfile = MagicMock(return_value=None) 20 | 21 | copy_to_library.find_unused_path = MagicMock(return_value=new_path) 22 | 23 | copy_to_library.copy_to_library(book.path, book, move_file=True) 24 | copy_to_library.move.assert_called_with(old_path, absolute_library_path) 25 | copy_to_library.copyfile.assert_not_called() 26 | 27 | 28 | class TestCopyToLibrary_Copy(BaseCliTest): 29 | 30 | def test_copy_to_library_copy(self): 31 | with self.app.app_context(): 32 | book = Book.query.first() 33 | old_path = book.path 34 | new_path = 'new_path' 35 | 36 | absolute_library_path = os.path.join(self.app.config['USER_CONFIG']['library_path'], new_path) 37 | 38 | copy_to_library.move = MagicMock(return_value=None) 39 | copy_to_library.copyfile = MagicMock(return_value=None) 40 | 41 | copy_to_library.find_unused_path = MagicMock(return_value=new_path) 42 | 43 | copy_to_library.copy_to_library(book.path, book, move_file=False) 44 | copy_to_library.move.assert_not_called() 45 | 46 | copy_to_library.copyfile.assert_called_with(old_path, absolute_library_path) 47 | -------------------------------------------------------------------------------- /lspace/frontend_blueprint/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask import render_template 3 | from flask import request 4 | from flask_wtf import FlaskForm 5 | from typing import Dict 6 | from wtforms import StringField 7 | 8 | from lspace.api_v1_blueprint.resource_helpers import run_query 9 | from lspace.api_v1_blueprint.resources.book import filter_map as book_filter_map, filter_fields as book_filter_fields 10 | from lspace.models import Book 11 | 12 | frontend_blueprint = Blueprint('frontend', __name__, template_folder='templates', static_folder='static') 13 | 14 | 15 | class BookQueryForm(FlaskForm): 16 | class Meta(): 17 | csrf = False 18 | 19 | title = StringField('title', validators=[]) 20 | author = StringField('author', validators=[]) 21 | publisher = StringField('publisher', validators=[]) 22 | language = StringField('language', validators=[]) 23 | shelf = StringField('shelf', validators=[]) 24 | year = StringField('year', validators=[]) 25 | 26 | 27 | @frontend_blueprint.route('/', methods=['GET', 'POST']) 28 | @frontend_blueprint.route('/books', methods=['GET', 'POST']) 29 | def books(): 30 | page = int(request.args.get('page', 1)) 31 | per_page = 10 32 | 33 | book_args = {} 34 | form = BookQueryForm() 35 | 36 | for field in book_filter_fields: 37 | value = request.args.get(field, None) 38 | if value: 39 | book_args[field] = value 40 | getattr(form, field).data = value 41 | 42 | paginated_books = run_query(Book, book_args, book_filter_map, page, per_page) 43 | 44 | # set up vars for import string 45 | import_vars = dict() 46 | for k, v in dict(**request.args).items(): 47 | if v: 48 | import_vars[k] = v 49 | import_vars.pop('page', None) 50 | import_vars.pop('per_page', None) 51 | return render_template("books.html.jinja2", paginated_books=paginated_books, form=form, import_vars=import_vars) 52 | -------------------------------------------------------------------------------- /lspace/file_types/pdf.py: -------------------------------------------------------------------------------- 1 | 2 | import PyPDF2 as pypdf 3 | 4 | from ._base import FileTypeBase 5 | 6 | 7 | class PDF(FileTypeBase): 8 | extension = '.pdf' 9 | 10 | def __init__(self, path): 11 | super().__init__(path) 12 | 13 | self.pdf_reader = pypdf.PdfReader(self.path) 14 | self.metadata = self.pdf_reader.metadata 15 | self.xmp_metadata = self.pdf_reader.xmp_metadata 16 | 17 | def get_text(self): 18 | # type: () -> [str] 19 | pages = [] 20 | 21 | # printing number of pages in pdf file 22 | for page_idx in range(min(self.pdf_reader.numPages, 30)): 23 | page = self.pdf_reader.getPage(page_idx) 24 | try: 25 | extracted_text = page.extractText() 26 | pages.append(extracted_text) 27 | except KeyError: 28 | # KeyError: '/Contents' 29 | pass 30 | 31 | # if true, there was at least one page with text 32 | if True in [bool(page) for page in pages]: 33 | return pages 34 | return [] 35 | 36 | def get_isbn(self): 37 | # meta = self.pdf_reader.xmpMetadata 38 | 39 | # never found a pdf with xmp metadata - not sure what the results would look like 40 | # if meta: 41 | # if meta.dc_description: 42 | # print(meta.dc_description) 43 | return None 44 | 45 | def get_author(self): 46 | if self.metadata: 47 | return self.metadata.author 48 | return None 49 | 50 | def get_title(self): 51 | try: 52 | if (self.xmp_metadata and 53 | self.xmp_metadata.dc_title and 54 | self.xmp_metadata.dc_title.get('x-default', False)): 55 | return self.xmp_metadata.dc_title.get('x-default', False) 56 | except AttributeError: 57 | pass 58 | 59 | if self.metadata: 60 | return self.metadata.title 61 | return None 62 | -------------------------------------------------------------------------------- /tests/cli/import_command/test_calibre_meta_file.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import xml.etree.ElementTree as ET 3 | from lspace.cli.import_command.import_from_calibre import CalibreMetaFile 4 | from tests.cli._cli_test_base import BaseCliTest 5 | 6 | title = 'testtitle' 7 | year = 1960 8 | date = "{year}-02-14T23:00:00+00:00".format(year=year) 9 | publisher = "testpublisher" 10 | isbn = "some_isbn" 11 | language = 'testlang' 12 | author = 'some author' 13 | 14 | testfile = """ 15 | 16 | 17 | {TITLE} 18 | {AUTHOR} 19 | {DATE} 20 | {PUBLISHER} 21 | {ISBN} 22 | {LANGUAGE} 23 | 24 | 25 | 26 | 27 | 28 | """.format(TITLE=title, DATE=date, PUBLISHER=publisher, ISBN=isbn, LANGUAGE=language, AUTHOR=author) 29 | 30 | 31 | class BookTest(BaseCliTest): 32 | 33 | def test_meta_file(self): 34 | calibre_meta = CalibreMetaFile(xml=testfile) 35 | 36 | assert calibre_meta.title == title 37 | assert calibre_meta.year == year 38 | assert calibre_meta.publisher == publisher 39 | assert calibre_meta.isbn == isbn 40 | assert calibre_meta.language == language 41 | assert calibre_meta.authors == [author] 42 | 43 | def test_meta_book(self): 44 | calibre_meta = CalibreMetaFile(xml=testfile) 45 | with self.app.app_context(): 46 | book = calibre_meta.get_book() 47 | 48 | assert book.title == title 49 | assert book.year == year 50 | assert book.publisher == publisher 51 | assert book.isbn13 == isbn 52 | assert book.language == language 53 | assert book.authors_names == author 54 | -------------------------------------------------------------------------------- /dodo.py: -------------------------------------------------------------------------------- 1 | from doit.tools import PythonInteractiveAction 2 | 3 | 4 | def task_build(): 5 | return { 6 | 'targets': ['build', 'dist', 'lspace.egg-info', '.pytest_cache', 'htmlcov'], 7 | 'actions': ['rm -fr %(targets)s', 'python3 -m build'], 8 | 'clean': ['rm -fr %(targets)s'], 9 | 'verbosity': 2 10 | } 11 | 12 | 13 | def task_bump_dryrun(): 14 | return {'actions': ['bump2version --verbose --dry-run --allow-dirty %(part)s'], 15 | 'params': [{'name': 'part', 16 | 'long': 'part', 17 | 'type': str, 18 | 'choices': (('patch', ''), ('minor', ''), ('major', '')), 19 | 'default': False, 20 | 'help': 'Choose between patch, minor, major'}], 21 | 'verbosity': 2, } 22 | 23 | 24 | def task_bump(): 25 | return {'actions': ['bump2version --verbose %(part)s'], 26 | 'params': [{'name': 'part', 27 | 'long': 'part', 28 | 'type': str, 29 | 'choices': (('patch', ''), ('minor', ''), ('major', '')), 30 | 'default': False, 31 | 'help': 'Choose between patch, minor, major'}], 32 | 'verbosity': 2, } 33 | 34 | 35 | def task_release_pypi(): 36 | def confirm(): 37 | res = input('running release on NON-test pypy!\n' 38 | 'remember to install from a build to test if everytihng is included!\n' 39 | 'is the version bumped and everything commited and pushed?\n' 40 | 'everything tested?\n' 41 | '[yesanditsnotatest/OHMYGODNO] ') 42 | if res != 'yesanditsnotatest': 43 | raise Exception 44 | 45 | return { 46 | 'task_dep': ['build'], 47 | 'actions': [ 48 | (PythonInteractiveAction(confirm)), 49 | 'twine upload --verbose --disable-progress-bar --repository pypi dist/*'], 50 | 'verbosity': 2 51 | } 52 | 53 | 54 | def task_release_test_pypi(): 55 | def confirm(): 56 | res = input('running release on test pypy!\n' 57 | '[yes/NO] ') 58 | if res != 'yes': 59 | raise Exception 60 | 61 | return { 62 | 'task_dep': ['build'], 63 | 'actions': [ 64 | (PythonInteractiveAction(confirm)), 65 | 'twine upload --verbose --disable-progress-bar --repository testpypi dist/*'], 66 | 'verbosity': 2, 67 | } 68 | -------------------------------------------------------------------------------- /lspace/helpers/create_app.py: -------------------------------------------------------------------------------- 1 | import flask 2 | 3 | import logging 4 | import os 5 | 6 | import click 7 | from flask import Flask 8 | 9 | from flask_migrate import upgrade as db_upgrade 10 | 11 | from .init_logging import init_logging 12 | from . import read_config 13 | 14 | from lspace import db, whooshee, migrate 15 | 16 | 17 | APP_NAME = 'lspace' 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | __version__ = '0.4.5' 23 | 24 | os.environ['FLASK_APP'] = os.path.join(os.path.dirname(__file__), 'app.py') 25 | 26 | 27 | 28 | 29 | def create_app(): 30 | app = Flask(__name__, 31 | static_url_path='/_static', template_folder='/_templates') # we need /static for the frontend blueprint 32 | 33 | app_dir = click.get_app_dir(APP_NAME) 34 | app.config['APP_DIR'] = app_dir 35 | 36 | config_path = os.environ.get('LSPACE_CONFIG', False) 37 | if not config_path: 38 | config_path = os.path.join(app.config['APP_DIR'], 'config.yaml') 39 | 40 | app.config['CONFIG_PATH'] = config_path 41 | app.config['USER_CONFIG'] = read_config(config_path, app_dir) 42 | app.config['SQLALCHEMY_DATABASE_URI'] = app.config['USER_CONFIG']['database_path'] 43 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 44 | app.config['WHOOSHEE_DIR'] = os.path.join(app.config['APP_DIR'], 'whoosh_index') 45 | 46 | app.config['USER_CONFIG']['library_path'] = os.path.abspath( 47 | os.path.expanduser(app.config['USER_CONFIG']['library_path'])) 48 | 49 | init_logging(app.config['USER_CONFIG']['loglevel']) 50 | 51 | migration_dir = os.path.join(os.path.dirname(__file__), '../migrations') 52 | 53 | db.init_app(app) 54 | 55 | # fix migration for sqlite: https://github.com/miguelgrinberg/Flask-Migrate/issues/61#issuecomment-208131722 56 | with app.app_context(): 57 | if db.engine.url.drivername == 'sqlite': 58 | migrate.init_app(app, db, render_as_batch=True, directory=migration_dir) 59 | else: 60 | migrate.init_app(app, db, directory=migration_dir) 61 | db_upgrade() 62 | 63 | def url_for_self(**args): 64 | return flask.url_for(flask.request.endpoint, **{**flask.request.view_args, **flask.request.args, **args}) 65 | 66 | app.jinja_env.globals['url_for_self'] = url_for_self 67 | 68 | whooshee.init_app(app) 69 | 70 | @app.after_request 71 | def add_gnu_tp_header(response): 72 | # www.gnuterrypratchett.com 73 | response.headers.add("X-Clacks-Overhead", "GNU Terry Pratchett") 74 | return response 75 | 76 | from lspace.cli import cli_bp 77 | app.register_blueprint(cli_bp) 78 | 79 | return app 80 | 81 | -------------------------------------------------------------------------------- /lspace/api_v1_blueprint/models/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import url_for 2 | from marshmallow import Schema, fields, post_load, EXCLUDE 3 | 4 | from lspace.api_v1_blueprint.resource_helpers import get_paginated_marshmallow_schema 5 | from lspace.models import Author, Shelf, Book 6 | 7 | 8 | class ShelfSchema(Schema): 9 | id = fields.Int() 10 | name = fields.String(required=False) 11 | 12 | @post_load 13 | def make_shelf(self, data, **kwargs): 14 | shelf = Shelf.query.filter_by(name=data['name']).first() 15 | if not shelf: 16 | shelf = Shelf() 17 | shelf.name = data['name'] 18 | return shelf 19 | 20 | 21 | class AuthorSchema(Schema): 22 | id = fields.Int() 23 | name = fields.String() 24 | 25 | @post_load 26 | def make_author(self, data, **kwargs): 27 | author = Author.query.filter_by(name=data['name']).first() 28 | if not author: 29 | author = Author() 30 | author.name = data['name'] 31 | return author 32 | 33 | 34 | class BookSchema(Schema): 35 | class Meta: 36 | unknown = EXCLUDE 37 | 38 | id = fields.Int(dump_only=True, missing=None) 39 | title = fields.String() 40 | authors = fields.Nested(AuthorSchema, many=True) 41 | shelf = fields.Nested(ShelfSchema, allow_none=True) 42 | isbn13 = fields.String(default=None, allow_none=True) 43 | md5sum = fields.String() 44 | publisher = fields.String(allow_none=True) 45 | metadata_source = fields.String(allow_none=True) 46 | year = fields.Integer(allow_none=True) 47 | language = fields.String(allow_none=True) 48 | path = fields.String() 49 | url = fields.Method(serialize='get_url', 50 | deserialize='load_url') 51 | 52 | def get_url(self, book): 53 | return url_for('api.book_file', md5sum=book.md5sum) 54 | 55 | def load_url(self, value): 56 | return value 57 | 58 | @post_load 59 | def make_book(self, data, **kwargs): 60 | book = Book.query.filter_by(md5sum=data['md5sum']).first() 61 | if not book: 62 | book = Book() 63 | book.title = data['title'] 64 | book.authors = data['authors'] 65 | book.shelf = data['shelf'] 66 | book.isbn13 = data['isbn13'] 67 | book.md5sum = data['md5sum'] 68 | book.publisher = data['publisher'] 69 | book.metadata_source = data['metadata_source'] 70 | book.year = data['year'] 71 | book.language = data['language'] 72 | 73 | book.path = data['path'] 74 | book.url = data['url'] 75 | return book 76 | 77 | 78 | PaginatedBookSchema = get_paginated_marshmallow_schema(BookSchema) 79 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | requirements = [ 4 | "alembic==1.11.1", 5 | "aniso8601==9.0.1", 6 | "apispec==6.3.0", 7 | "attrs==23.1.0", 8 | "blinker==1.6.2", 9 | "certifi==2023.5.7", 10 | "chardet==5.1.0", 11 | "Click==8.1.4", 12 | "colorama==0.4.6", 13 | "EbookLib==0.18", 14 | "Flask==2.3.2", 15 | "Flask-Migrate==4.0.4", 16 | "flask-restx==1.1.0", 17 | "Flask-SQLAlchemy==3.0.5", 18 | "flask-whooshee==0.9.1", 19 | "Flask-WTF==1.1.1", 20 | "gunicorn==20.1.0", 21 | "html2text==2020.1.16", 22 | "idna==3.4", 23 | "isbnlib==3.10.14", 24 | "itsdangerous==2.1.2", 25 | "Jinja2==3.1.2", 26 | "jsonschema==4.18.1", 27 | "lxml==4.9.3", 28 | "Mako==1.2.4", 29 | "MarkupSafe==2.1.3", 30 | "marshmallow==3.19.0", 31 | "PyPDF2==3.0.1", 32 | "pyrsistent==0.19.3", 33 | "python-dateutil==2.8.2", 34 | "python-editor==1.0.4", 35 | "python-slugify==8.0.1", 36 | "pytz==2023.3", 37 | "PyYAML==6.0.1", 38 | "requests==2.31.0", 39 | "six==1.16.0", 40 | "SQLAlchemy==2.0.18", 41 | "text-unidecode==1.3", 42 | "typing==3.7.4.3", 43 | "urllib3==2.0.3", 44 | "Werkzeug==2.3.6", 45 | "Whoosh==2.7.4", 46 | "WTForms==3.0.1", 47 | ] 48 | 49 | test_requirements = [ 50 | "pytest==7.4.0", 51 | "pytest-cov==4.1.0", 52 | "pytest-cover==3.0.0", 53 | "codecov==2.1.13" 54 | ] 55 | 56 | dev_requirements = test_requirements + [ 57 | "ipython==8.14.0", 58 | "doit==0.36.0", 59 | "wheel==0.40.0", 60 | "twine==5.1.1", 61 | "build==0.10.0", 62 | "bump2version==1.0.1" 63 | ] 64 | 65 | setup( 66 | name='lspace', 67 | packages=find_packages(), 68 | include_package_data=True, 69 | version='0.4.8', 70 | entry_points={ 71 | 'console_scripts': [ 72 | 'lspace=lspace:cli' 73 | ], 74 | }, 75 | description='ebook manager built around isbnlib', 76 | url='https://github.com/puhoy/lspace', 77 | author='jan', 78 | author_email='stuff@kwoh.de', 79 | long_description=open('README.md').read(), 80 | long_description_content_type='text/markdown', 81 | extras_require={ 82 | 'dev': dev_requirements, 83 | 'test': test_requirements 84 | }, 85 | install_requires=requirements, 86 | classifiers=[ 87 | 'Development Status :: 4 - Beta', 88 | 'Environment :: Console', 89 | "Intended Audience :: End Users/Desktop", 90 | "Natural Language :: English", 91 | "Topic :: Utilities", 92 | "Programming Language :: Python :: 3.7", 93 | "Programming Language :: Python :: 3.8", 94 | "Programming Language :: Python :: 3.9", 95 | "Programming Language :: Python :: 3.10", 96 | ], 97 | ) 98 | -------------------------------------------------------------------------------- /lspace/cli/export_command.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import subprocess 5 | 6 | import click 7 | from slugify import slugify 8 | 9 | from flask import current_app 10 | from lspace.cli import cli_bp 11 | from lspace.cli.import_command.copy_to_library import find_unused_path 12 | from lspace.helpers.query import query_db 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | @cli_bp.cli.command(help='export books to a folder - this uses ebook-convert, which is part of calibre') 18 | @click.argument('query', nargs=-1) 19 | @click.argument('export_path') 20 | @click.option('--format') 21 | def export(query, export_path, format): 22 | if format: 23 | if not shutil.which('ebook-convert'): 24 | click.echo('could not find ebook-convert executable! is calibre installed?') 25 | return 26 | 27 | export_path = os.path.abspath(os.path.expanduser(export_path)) 28 | click.echo('exporting to %s' % export_path) 29 | 30 | if not os.path.isdir(export_path): 31 | click.echo('path has to be a folder!') 32 | return 33 | 34 | if format and not format.startswith('.'): 35 | # format and extension should both start with '.' 36 | format = '.' + format 37 | 38 | results = query_db(query) 39 | for result in results: 40 | if format and format != result.extension: 41 | target_extension = format 42 | else: 43 | target_extension = result.extension 44 | 45 | target_in_export_path = find_unused_path(export_path, 46 | current_app.config['USER_CONFIG']['file_format'], 47 | book=result) 48 | target_path = os.path.join(export_path, target_in_export_path) 49 | if not os.path.isdir(os.path.dirname(target_path)): 50 | os.makedirs(os.path.dirname(target_path)) 51 | 52 | if target_extension != result.extension: 53 | click.echo('converting %s to %s' % (result.extension, format)) 54 | try: 55 | subprocess.call( 56 | ["ebook-convert", 57 | result.full_path, 58 | target_path 59 | ]) 60 | except Exception as e: 61 | logger.exception('error converting %s' % result.full_path, exc_info=True) 62 | else: 63 | click.echo('exporting {authors_str} - {result.title} to {target_path}'.format(authors_str=result.authors, 64 | result=result, 65 | target_path=target_path)) 66 | shutil.copyfile(result.full_path, target_path) 67 | return 68 | -------------------------------------------------------------------------------- /lspace/frontend_blueprint/templates/base.html.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | L-Space 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 |
15 |
16 |

L-Space

17 |
18 |
19 | {{ macros.book_filter(form) }} 20 |
21 |
22 | {{ macros.import_block() }} 23 |
24 |
25 |
26 |
27 | {% block content %}{% endblock %} 28 |
29 |
30 |
31 | 32 | 75 | 76 | -------------------------------------------------------------------------------- /tests/file_types/test_base.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock 3 | 4 | from lspace.file_types import FileTypeBase 5 | from lspace.file_types import _base 6 | 7 | 8 | class FileTypeBaseTest(unittest.TestCase): 9 | 10 | def test_filename(self): 11 | test_path = '/tmp/test/' 12 | test_filename = 'testfile.pdf' 13 | f = FileTypeBase(test_path + test_filename) 14 | 15 | assert f.filename == test_filename 16 | 17 | def test_filter_symbols(self): 18 | test_str = 'a-.$/bc' 19 | expected_output = 'a bc' 20 | 21 | f = FileTypeBase('') 22 | 23 | assert f._filter_symbols(test_str) == expected_output 24 | 25 | def test_clean_filename(self): 26 | test_path = '/tmp/test/some_filename.ext' 27 | expected_output = 'some filename' 28 | 29 | f = FileTypeBase(test_path) 30 | 31 | assert f._clean_filename() == expected_output 32 | 33 | def test_get_isbn_from_text(self): 34 | f = FileTypeBase('') 35 | 36 | expected_isbn = '9783161484100' 37 | 38 | test_text = [ 39 | 'page 1', 40 | 'page 2 978-3-16-148410-0 still page 2', 41 | 'page 3' 42 | ] 43 | f.get_text = MagicMock(return_value=test_text) 44 | isbns = f.get_isbns_from_text() 45 | assert isbns == [expected_isbn] 46 | 47 | test_text = [ 48 | 'page 1', 49 | 'page 2 ISBN 978-3-16-148410-0 still page 2', 50 | 'page 3' 51 | ] 52 | f.get_text = MagicMock(return_value=test_text) 53 | isbns = f.get_isbns_from_text() 54 | assert isbns == [expected_isbn] 55 | 56 | test_text = [ 57 | 'page 1', 58 | 'page 2 9783161484100 still page 2', 59 | 'page 3' 60 | ] 61 | f.get_text = MagicMock(return_value=test_text) 62 | isbns = f.get_isbns_from_text() 63 | assert isbns == [expected_isbn] 64 | 65 | def test_find_isbn_in_text(self): 66 | f = FileTypeBase('') 67 | 68 | expected_metadata = { 69 | 'a': 1 70 | } 71 | 72 | test_text = [ 73 | 'page 1', 74 | 'page 2 978-3-16-148410-0 still page 2', 75 | 'page 3' 76 | ] 77 | f.get_text = MagicMock(return_value=test_text) 78 | _base.query_isbn_data = MagicMock(return_value=expected_metadata) 79 | 80 | isbns = f.find_isbn_in_text() 81 | assert isbns == [expected_metadata] 82 | 83 | 84 | def test_find_isbn_in_metadata(self): 85 | f = FileTypeBase('') 86 | 87 | expected_metadata = { 88 | 'a': 1 89 | } 90 | test_text = '978-3-16-148410-0' 91 | 92 | f.get_isbn = MagicMock(return_value=test_text) 93 | _base.query_isbn_data = MagicMock(return_value=expected_metadata) 94 | 95 | isbns = f.find_isbn_in_metadata() 96 | assert isbns == [expected_metadata] 97 | -------------------------------------------------------------------------------- /lspace/helpers/query.py: -------------------------------------------------------------------------------- 1 | import isbnlib 2 | from typing import List 3 | 4 | from lspace.helpers import logger 5 | from lspace.models.meta_cache import MetaCache 6 | from lspace.models import Book, Author 7 | 8 | 9 | def _fetch_isbn_meta(isbn, service): 10 | try: 11 | meta = isbnlib.meta(isbn, service=service) 12 | except (isbnlib.dev._exceptions.NoDataForSelectorError, 13 | isbnlib._exceptions.NotValidISBNError, 14 | isbnlib.dev._exceptions.DataNotFoundAtServiceError 15 | ): 16 | meta = {} 17 | except Exception as e: 18 | logger.exception('failed to get isbn data from {service}: {error}'.format(error=e, service=service)) 19 | meta = None 20 | return meta 21 | 22 | def _get_metadata_for_isbn(isbn, service='openl'): 23 | from lspace.helpers.create_app import db 24 | # type: (str, str) -> Book 25 | cached_meta = MetaCache.query.filter_by(isbn=isbn, service=service).first() 26 | if cached_meta: 27 | meta = cached_meta.results 28 | else: 29 | meta = _fetch_isbn_meta(isbn, service) 30 | if meta != None: 31 | new_meta = MetaCache(isbn=isbn, service=service, results=meta) 32 | 33 | db.session.add(new_meta) 34 | db.session.commit() 35 | 36 | if meta: 37 | return Book.from_search_result(meta, metadata_source=service) 38 | return None 39 | 40 | 41 | def query_isbn_data(isbn_str): 42 | # type: (str) -> Book 43 | 44 | if isbnlib.is_isbn10(isbn_str): 45 | isbn_str = isbnlib.to_isbn13(isbn_str) 46 | 47 | logger.info('query openlibrary for %s' % isbn_str) 48 | meta = _get_metadata_for_isbn(isbn_str, 'openl') 49 | 50 | if not meta: 51 | logger.info('query google books for %s' % isbn_str) 52 | meta = _get_metadata_for_isbn(isbn_str, 'goob') 53 | 54 | if meta: 55 | return meta 56 | return None 57 | 58 | 59 | def query_google_books(words): 60 | # type: (str) -> [SearchResult] 61 | logger.debug('query google books for %s' % words) 62 | try: 63 | raw_results = isbnlib.goom(words) 64 | 65 | except isbnlib.dev._exceptions.NoDataForSelectorError as e: 66 | raw_results = [] 67 | except Exception as e: 68 | logger.exception('failed to get from google books: {error}'.format(error=e)) 69 | raw_results = [] 70 | return [Book.from_search_result(result, metadata_source='google books api') for result in raw_results] 71 | 72 | 73 | def query_db(query, books=True, authors=True): 74 | # type: (str, bool, bool) -> List[Book] 75 | if not query or (len(query) == 1 and not query[0]): # in case of ('', ) 76 | return Book.query.all() 77 | else: 78 | results = [] 79 | joined_query = ' '.join(query) 80 | if books: 81 | results = Book.query.whooshee_search(joined_query, match_substrings=False).all() 82 | if authors: 83 | author_results = Author.query.whooshee_search(joined_query, match_substrings=False).all() 84 | for author in author_results: 85 | for book in author.books: 86 | results.append(book) 87 | return results 88 | -------------------------------------------------------------------------------- /lspace/migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | import logging 3 | 4 | from sqlalchemy import engine_from_config 5 | from sqlalchemy import pool 6 | 7 | from alembic import context 8 | 9 | # this is the Alembic Config object, which provides 10 | # access to the values within the .ini file in use. 11 | config = context.config 12 | 13 | # Interpret the config file for Python logging. 14 | # This line sets up loggers basically. 15 | logger = logging.getLogger('alembic.env') 16 | 17 | # add your model's MetaData object here 18 | # for 'autogenerate' support 19 | # from myapp import mymodel 20 | # target_metadata = mymodel.Base.metadata 21 | from flask import current_app 22 | config.set_main_option('sqlalchemy.url', 23 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 24 | target_metadata = current_app.extensions['migrate'].db.metadata 25 | 26 | # other values from the config, defined by the needs of env.py, 27 | # can be acquired: 28 | # my_important_option = config.get_main_option("my_important_option") 29 | # ... etc. 30 | 31 | 32 | def run_migrations_offline(): 33 | """Run migrations in 'offline' mode. 34 | 35 | This configures the context with just a URL 36 | and not an Engine, though an Engine is acceptable 37 | here as well. By skipping the Engine creation 38 | we don't even need a DBAPI to be available. 39 | 40 | Calls to context.execute() here emit the given string to the 41 | script output. 42 | 43 | """ 44 | url = config.get_main_option("sqlalchemy.url") 45 | context.configure( 46 | url=url, target_metadata=target_metadata, literal_binds=True 47 | ) 48 | 49 | with context.begin_transaction(): 50 | context.run_migrations() 51 | 52 | 53 | def run_migrations_online(): 54 | """Run migrations in 'online' mode. 55 | 56 | In this scenario we need to create an Engine 57 | and associate a connection with the context. 58 | 59 | """ 60 | 61 | # this callback is used to prevent an auto-migration from being generated 62 | # when there are no changes to the schema 63 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 64 | def process_revision_directives(context, revision, directives): 65 | if getattr(config.cmd_opts, 'autogenerate', False): 66 | script = directives[0] 67 | if script.upgrade_ops.is_empty(): 68 | directives[:] = [] 69 | logger.info('No changes in schema detected.') 70 | 71 | connectable = engine_from_config( 72 | config.get_section(config.config_ini_section), 73 | prefix='sqlalchemy.', 74 | poolclass=pool.NullPool, 75 | ) 76 | 77 | with connectable.connect() as connection: 78 | context.configure( 79 | connection=connection, 80 | target_metadata=target_metadata, 81 | process_revision_directives=process_revision_directives, 82 | **current_app.extensions['migrate'].configure_args 83 | ) 84 | 85 | with context.begin_transaction(): 86 | context.run_migrations() 87 | 88 | 89 | if context.is_offline_mode(): 90 | run_migrations_offline() 91 | else: 92 | run_migrations_online() 93 | -------------------------------------------------------------------------------- /lspace/cli/import_command/copy_to_library.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from shutil import copyfile, move 4 | 5 | from flask import current_app 6 | 7 | from lspace.models import Book 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def copy_to_library(source_path, book, move_file): 13 | # type: (str, Book, bool) -> str 14 | """ 15 | 16 | :param source_path: path to the file we want to import 17 | :param book: chosen result 18 | :param move_file: move instead of copy 19 | :return: 20 | """ 21 | # prepare the fields for path building 22 | 23 | library_path = current_app.config['USER_CONFIG']['library_path'] 24 | path_in_library = find_unused_path(library_path, 25 | current_app.config['USER_CONFIG']['file_format'], 26 | book) 27 | target_path = os.path.join(library_path, path_in_library) 28 | 29 | if not target_path: 30 | logger.error('could not find a path in the library for %s' % 31 | source_path) 32 | return False 33 | 34 | if not os.path.isdir(os.path.dirname(target_path)): 35 | os.makedirs(os.path.dirname(target_path)) 36 | 37 | logger.debug('importing to %s' % target_path) 38 | if not move_file: 39 | copyfile(source_path, target_path) 40 | else: 41 | if source_path != target_path: 42 | move(source_path, target_path) 43 | else: 44 | logger.info('source and target path are the same - skip moving the file') 45 | 46 | return path_in_library 47 | 48 | 49 | def find_unused_path(base_path, book_path_format, book): 50 | # type: (str, str, Book) -> str 51 | """ 52 | 53 | :param base_path: path to the library 54 | :param book_path_format: template for path in library from user config 55 | :param book: 56 | :return: new path relative from base_path 57 | """ 58 | # create the path for the book 59 | 60 | count = 0 61 | 62 | while count < 100: 63 | path_from_base_path = book_path_format.format( 64 | AUTHORS=book.author_names_slug, 65 | TITLE=book.title_slug, 66 | SHELVE=book.shelf_name_slug, # keep for old configs 67 | SHELF=book.shelf_name_slug, 68 | YEAR=book.year, 69 | LANGUAGE=book.language_slug, 70 | PUBLISHER=book.publisher_slug 71 | ) 72 | # if, for some reason, the path starts with /, we need to make it relative 73 | while path_from_base_path.startswith(os.sep): 74 | logger.debug('trimming path to %s' % path_from_base_path[1:]) 75 | path_from_base_path = path_from_base_path[1:] 76 | 77 | if count == 0: 78 | path_from_base_path += book.extension 79 | else: 80 | path_from_base_path = '{path_from_base_path}_{count}{extension}'.format( 81 | path_from_base_path=path_from_base_path, 82 | count=count, extension=book.extension) 83 | 84 | target_path = os.path.join(base_path, path_from_base_path) 85 | 86 | if not os.path.exists(target_path): 87 | return path_from_base_path 88 | 89 | count += 1 90 | return False -------------------------------------------------------------------------------- /tests/models/test_book.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import os 4 | import shutil 5 | import tempfile 6 | import unittest 7 | 8 | from flask_migrate import upgrade 9 | 10 | from lspace import create_app, db 11 | from lspace.models import Book, Author, Shelf 12 | 13 | 14 | def get_test_app(test_dir): 15 | return create_app(app_dir=test_dir) 16 | 17 | 18 | def get_temp_dir(): 19 | return tempfile.mkdtemp() 20 | 21 | 22 | class BookTest(unittest.TestCase): 23 | 24 | def setUp(self) -> None: 25 | self.test_dir = get_temp_dir() 26 | 27 | def getApp(self): 28 | 29 | app = get_test_app(self.test_dir) 30 | app.config['USER_CONFIG']['library_path'] = os.path.join(self.test_dir, 'library') 31 | 32 | with app.app_context(): 33 | upgrade() 34 | from lspace import db 35 | db.session.commit() 36 | return app 37 | 38 | def tearDown(self): 39 | shutil.rmtree(self.test_dir) 40 | 41 | 42 | def test_book_adding(self): 43 | """ 44 | adding a book should cascade to author, 45 | but author should not cascade to 2nd book 46 | 47 | :return: 48 | """ 49 | with self.getApp().app_context(): 50 | author = Author(name='author') 51 | book_1 = Book(title='book_1', authors=[author]) 52 | book_2 = Book(title='book_2', authors=[author]) 53 | 54 | db.session.add(book_1) 55 | 56 | db.session.commit() 57 | db.session.flush() 58 | 59 | with self.getApp().app_context(): 60 | books = Book.query.all() 61 | assert len(books) == 1 62 | assert books[0].authors[0].name == 'author' 63 | 64 | 65 | def test_author_adding_2(self): 66 | """ 67 | adding an author should not cascade to books 68 | 69 | :return: 70 | """ 71 | with self.getApp().app_context(): 72 | author = Author(name='author') 73 | book_1 = Book(title='book_1', authors=[author]) 74 | book_2 = Book(title='book_2', authors=[author]) 75 | 76 | db.session.add(author) 77 | 78 | db.session.commit() 79 | db.session.flush() 80 | 81 | with self.getApp().app_context(): 82 | books = Book.query.all() 83 | assert len(books) == 0 84 | author = Author.query.all() 85 | assert len(author) == 1 86 | 87 | def test_adding_book_with_shelf(self): 88 | """ 89 | adding a book should not cascade over shelf 90 | 91 | :return: 92 | """ 93 | with self.getApp().app_context(): 94 | author = Author(name='author') 95 | shelf = Shelf(name='genre') 96 | book_1 = Book(title='book_1', authors=[author], shelf=shelf) 97 | book_2 = Book(title='book_2', authors=[author], shelf=shelf) 98 | 99 | book_1.save() 100 | db.session.flush() 101 | 102 | with self.getApp().app_context(): 103 | books = Book.query.all() 104 | assert len(books) == 1 105 | author = Shelf.query.all() 106 | assert len(author) == 1 107 | -------------------------------------------------------------------------------- /lspace/cli/import_command/import_options/manual_import.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from typing import Union 3 | 4 | import click 5 | import yaml 6 | 7 | from lspace.cli.import_command.base_helper import BaseHelper 8 | from lspace.cli.import_command.import_options.peek import Peek 9 | from lspace.file_types import FileTypeBase 10 | from lspace.helpers import preprocess_isbns 11 | from lspace.models import Book 12 | 13 | 14 | class ManualImport(BaseHelper): 15 | explanation = 'import manually' 16 | 17 | @classmethod 18 | def function(cls, file_type_object, old_choices, *args, **kwargs): 19 | return manual_import(file_type_object, old_choices) 20 | 21 | 22 | def _prompt_choices(msg): 23 | choice = False 24 | 25 | while choice not in ['e', 'r']: 26 | choice = click.prompt( 27 | click.style( 28 | msg + ' \n' + 29 | 'e: edit\n' + 30 | 'r: restart with empty form\n' + 31 | 'c: cancel editing\n' + 32 | 'p: ' + Peek.explanation + '\n', 33 | bold=True), 34 | type=click.Choice(['e', 'r', 'p', 'c']), default='e') 35 | return choice 36 | 37 | 38 | def _get_edit_text(path): 39 | _edit_dict = dict( 40 | Title='', 41 | Authors=['', ], 42 | ISBN='', 43 | Publisher='', 44 | Year='', 45 | Language='', 46 | ) 47 | edit_dict = deepcopy(_edit_dict) 48 | 49 | text = '# import {path}\n'.format(path=path) 50 | text += '# only the title is needed, but you probably want to specify more :)\n\n' 51 | text += yaml.dump(edit_dict, sort_keys=False) 52 | return text 53 | 54 | 55 | def get_edit_result(file_type_object): 56 | # type: (FileTypeBase) -> Union[dict, bool] 57 | edit_text = _get_edit_text(file_type_object.path) 58 | choice = 'e' 59 | 60 | while True: 61 | if choice == 'e': 62 | result = click.edit(edit_text, require_save=True) 63 | 64 | if result: 65 | book_dict = yaml.load(result, Loader=yaml.FullLoader) 66 | if book_dict['Title']: 67 | return book_dict 68 | else: 69 | choice = _prompt_choices('title is needed!') 70 | edit_text = result 71 | 72 | else: 73 | choice = _prompt_choices('no data! did you save?') 74 | 75 | elif choice == 'r': 76 | edit_text = _get_edit_text(file_type_object.path) 77 | choice = 'e' 78 | 79 | elif choice == 'p': 80 | Peek.function(file_type_object, old_choices=[]) 81 | choice = _prompt_choices(click.style('', bold=True)) 82 | 83 | elif choice == 'c': 84 | return False 85 | 86 | else: 87 | # it shouldnt be possible to get here, but just in case.. 88 | choice = 'e' 89 | pass 90 | 91 | 92 | def manual_import(file_type_object, old_choices): 93 | # type: (FileTypeBase, [Book]) -> [dict] 94 | 95 | result = get_edit_result(file_type_object) 96 | if not result: 97 | return old_choices 98 | 99 | isbn = result.get('ISBN') 100 | if isbn: 101 | isbns = preprocess_isbns([isbn]) 102 | if len(isbns) == 1: 103 | isbn = isbns[0] 104 | result['ISBN-13'] = isbn 105 | 106 | return [Book.from_search_result(result, metadata_source='manually added')] 107 | -------------------------------------------------------------------------------- /lspace/cli/import_command/import_from_api/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | import tempfile 5 | from urllib.parse import urlparse, parse_qs, urlsplit, urlunsplit, urlencode 6 | 7 | import requests 8 | from flask import current_app 9 | from typing import List 10 | 11 | from lspace.api_v1_blueprint.models import BookSchema, PaginatedBookSchema 12 | from lspace.cli.import_command.copy_to_library import find_unused_path 13 | from lspace.models import Book 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def _get_next_page_url(url, response): 19 | scheme, netloc, path, query_string, fragment = urlsplit(url) 20 | query_params = parse_qs(query_string) 21 | 22 | next_num = response.get('next_num') 23 | 24 | if next_num: 25 | query_params['page'] = [next_num] 26 | else: 27 | return False 28 | 29 | query_string = urlencode(query_params, doseq=True) 30 | return urlunsplit((scheme, netloc, path, query_string, fragment)) 31 | 32 | 33 | def download_file(url, destination_path): 34 | with requests.get(url, stream=True) as r: 35 | r.raise_for_status() 36 | with open(destination_path, 'wb') as f: 37 | for chunk in r.iter_content(chunk_size=8192): 38 | if chunk: 39 | f.write(chunk) 40 | return True 41 | 42 | 43 | session = requests.session() 44 | 45 | 46 | class ApiImporter: 47 | def __init__(self, url): 48 | self.url = url 49 | parsed = urlparse(url) 50 | self.scheme = parsed.scheme 51 | self.netloc = parsed.netloc 52 | self.path = parsed.path 53 | self.fragment = parsed.fragment 54 | 55 | path = parsed.path 56 | 57 | clean_api_base_path = '/api/v1/' 58 | splits = path.rpartition(clean_api_base_path) 59 | 60 | self.detail_path = splits[2] 61 | 62 | self.detail_path_import_function_map = { 63 | '^(?:books\/)$': self.fetch_from_books_route, 64 | '^(?:books\/)([0-9]+)$': self.fetch_from_book_route 65 | } 66 | 67 | def fetch_from_books_route(self): 68 | next_url = self.url 69 | while next_url: 70 | paginated_response = session.get(next_url).json() 71 | paginated_books = PaginatedBookSchema().load(paginated_response) 72 | 73 | books = paginated_books['items'] 74 | yield from self.get_books(books) 75 | next_url = _get_next_page_url(next_url, paginated_response) 76 | 77 | def fetch_from_book_route(self): 78 | book_response = session.get(self.url).json() 79 | book = BookSchema().load(book_response) 80 | return self.get_books([book]) 81 | 82 | def get_book(self, tmpdirname, book): 83 | temp_path = find_unused_path(tmpdirname, current_app.config['USER_CONFIG']['file_format'], book) 84 | abs_temp_path = os.path.join(tmpdirname, temp_path) 85 | if not os.path.isdir(os.path.dirname(abs_temp_path)): 86 | os.makedirs(os.path.dirname(abs_temp_path)) 87 | book_url = urlunsplit((self.scheme, self.netloc, book.url, '', '')) 88 | download_file(book_url, abs_temp_path) 89 | return abs_temp_path, book 90 | 91 | def get_books(self, books): 92 | # type: (List[Book]) -> (str, Book) 93 | with tempfile.TemporaryDirectory() as tmpdirname: 94 | for book in books: 95 | yield self.get_book(tmpdirname, book) 96 | 97 | def start_import(self, skip_library_check): 98 | for route_regex, function in self.detail_path_import_function_map.items(): 99 | if re.match(route_regex, self.detail_path): 100 | yield from function() 101 | return 102 | -------------------------------------------------------------------------------- /lspace/file_types/_base.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | import os 4 | import string 5 | 6 | import isbnlib 7 | 8 | from lspace.helpers import preprocess_isbns 9 | from lspace.helpers.query import query_isbn_data, query_google_books 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class FileTypeBase: 15 | extension = None 16 | 17 | def __init__(self, path): 18 | self.path = path 19 | 20 | def get_text(self): 21 | raise NotImplementedError 22 | 23 | def get_title(self): 24 | return None 25 | 26 | def get_author(self): 27 | return None 28 | 29 | def get_year(self): 30 | return None 31 | 32 | def get_isbn(self): 33 | return None 34 | 35 | @property 36 | def filename(self): 37 | filename = os.path.split(self.path)[-1] 38 | return filename 39 | 40 | def get_md5(self): 41 | with open(self.path, 'rb') as file_to_check: 42 | data = file_to_check.read() 43 | md5sum = hashlib.md5(data).hexdigest() 44 | logger.debug('md5 is %s' % md5sum) 45 | return md5sum 46 | 47 | def find_isbn_in_metadata(self): 48 | _isbn = self.get_isbn() 49 | if _isbn: 50 | logger.info('found isbn %s in metadata!' % _isbn) 51 | d = query_isbn_data(_isbn) 52 | if d: 53 | return [d] 54 | return [] 55 | 56 | def find_isbn_in_text(self): 57 | logger.info('looking for isbn in text...') 58 | isbns = self.get_isbns_from_text() 59 | if isbns: 60 | isbns_with_metadata = [] 61 | for isbn in isbns: 62 | d = query_isbn_data(isbn) 63 | if d: 64 | isbns_with_metadata.append(d) 65 | 66 | if isbns_with_metadata: 67 | logger.info('found isbns in text!') 68 | return isbns_with_metadata 69 | return [] 70 | 71 | def find_isbn_from_author_title(self): 72 | if self.get_title(): 73 | search_str = self._filter_symbols(self.get_title()) 74 | if self.get_author(): 75 | search_str = '%s %s' % (self._filter_symbols(self.get_author()), search_str) 76 | results = query_google_books(search_str) 77 | if results: 78 | logger.info('found isbns from author + title...') 79 | return results 80 | return [] 81 | 82 | def find_isbn_from_filename(self): 83 | guessed_meta = self.guess_from_filename() 84 | if guessed_meta: 85 | return guessed_meta 86 | return [] 87 | 88 | def fetch_results(self): 89 | find_functions = [self.find_isbn_in_metadata, 90 | self.find_isbn_in_text, 91 | self.find_isbn_from_author_title, 92 | self.find_isbn_from_filename] 93 | results = {} 94 | for f in find_functions: 95 | for result in f(): 96 | results[result.isbn13] = result 97 | self.results = list(results.values()) 98 | return self.results 99 | 100 | 101 | def _filter_symbols(self, s): 102 | # type: (str) -> str 103 | whitelist = string.ascii_letters + string.digits + ' ' 104 | clean_string = ''.join( 105 | c if c in whitelist else ' ' for c in s) 106 | return clean_string 107 | 108 | def _clean_filename(self): 109 | filename, extension = os.path.splitext(self.filename) 110 | clean_filename = self._filter_symbols(filename) 111 | 112 | return clean_filename 113 | 114 | def guess_from_filename(self): 115 | clean_filename = self._clean_filename() 116 | logger.info('looking for %s' % clean_filename) 117 | results = query_google_books(clean_filename) 118 | logger.debug('results: %s' % results) 119 | return results 120 | 121 | def get_isbns_from_text(self): 122 | pages = self.get_text() 123 | pages_as_str = '\n'.join(pages) 124 | 125 | isbns = isbnlib.get_isbnlike(pages_as_str, level='normal') 126 | 127 | # print('unprocessed isbns: %s' % isbns) 128 | canonical_isbns = preprocess_isbns(isbns) 129 | 130 | # print('canonical isbns: %s' % canonical_isbns) 131 | return canonical_isbns 132 | -------------------------------------------------------------------------------- /lspace/cli/import_command/import_from_calibre.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import xml.etree.ElementTree as ET 3 | from collections import namedtuple 4 | from pathlib import Path 5 | 6 | from typing import Generator 7 | from typing import List 8 | from dateutil.parser import parse 9 | from lspace.models import Book, Author 10 | 11 | CalibreBook = namedtuple('Book', ['path']) 12 | 13 | 14 | class CalibreWrapper: 15 | def __init__(self, db_path): 16 | self.conn = sqlite3.connect(db_path) 17 | self.library_path = Path(db_path).parent 18 | 19 | def get_library_id(self): 20 | query = """ 21 | SELECT id, uuid 22 | FROM library_id; 23 | """ 24 | cursor = self.conn.cursor() 25 | cursor.execute(query) 26 | rows = cursor.fetchall() 27 | return rows 28 | 29 | def import_books(self): 30 | """ 31 | iterate over the books paths from calibre sqlite db, 32 | prepare book object from metadata.opf, yield this book object along with the path to the book 33 | (yields path/book per book file; calibre stores all formats of the book in this folder) 34 | 35 | :return: 36 | """ 37 | for book_path in self._get_book_paths(): 38 | abs_book_path = Path(self.library_path, book_path) 39 | meta_file = str(Path(abs_book_path, 'metadata.opf')) 40 | 41 | calibre_meta = CalibreMetaFile(meta_file) 42 | book = calibre_meta.get_book() 43 | 44 | files_in_book_path = [str(p) for p in abs_book_path.glob('*') if 45 | not (str(p).endswith('metadata.opf') or str(p).endswith('cover.jpg'))] 46 | 47 | for file in files_in_book_path: 48 | yield file, book 49 | 50 | def _get_book_paths(self): 51 | # type: () -> Generator[List[str]] 52 | query = """ 53 | SELECT books.path 54 | FROM books 55 | """ 56 | cursor = self.conn.cursor() 57 | cursor.execute(query) 58 | rows = cursor.fetchall() 59 | for row in rows: 60 | yield row[0] 61 | 62 | 63 | class CalibreMetaFile: 64 | 65 | ns = {'dc': 'http://purl.org/dc/elements/1.1/', 66 | 'opf': 'http://www.idpf.org/2007/opf' 67 | } 68 | 69 | def __init__(self, path=None, xml=None): 70 | if path: 71 | tree = ET.parse(path) 72 | self.root = tree.getroot() 73 | elif xml: 74 | self.root = ET.fromstring(xml) 75 | else: 76 | raise Exception('no path or xml provided!') 77 | 78 | 79 | @property 80 | def title(self): 81 | node = self.root[0].find('dc:title', CalibreMetaFile.ns) 82 | if node is None: 83 | return None 84 | return node.text 85 | 86 | @property 87 | def authors(self): 88 | nodes = self.root[0].findall('dc:creator', CalibreMetaFile.ns) 89 | return [node.text for node in nodes] 90 | 91 | @property 92 | def isbn(self): 93 | node = self.root[0].find('dc:identifier[@opf:scheme="ISBN"]', CalibreMetaFile.ns) 94 | if node is None: 95 | return None 96 | return node.text 97 | 98 | @property 99 | def publisher(self): 100 | node = self.root[0].find('dc:publisher', CalibreMetaFile.ns) 101 | if node is None: 102 | return None 103 | return node.text 104 | 105 | @property 106 | def year(self): 107 | node = self.root[0].find('dc:date', CalibreMetaFile.ns) 108 | if node is None: 109 | return None 110 | d = parse(node.text) 111 | return d.year 112 | 113 | @property 114 | def language(self): 115 | node = self.root[0].find('dc:language', CalibreMetaFile.ns) 116 | if node is None: 117 | return None 118 | return node.text 119 | 120 | def get_book(self): 121 | authors = [] 122 | for author_name in self.authors: 123 | author = Author.query.filter_by(name=author_name).first() 124 | if not author: 125 | author = Author() 126 | author.name = author_name 127 | authors.append(author) 128 | 129 | book = Book() 130 | book.from_dict({ 131 | 'Title': self.title, 132 | 'ISBN-13': self.isbn, 133 | 'Year': self.year, 134 | 'Publisher': self.publisher, 135 | 'Language': self.language 136 | }) 137 | book.authors = authors 138 | book.metadata_source = 'calibre' 139 | return book -------------------------------------------------------------------------------- /lspace/frontend_blueprint/templates/macros.html.jinja2: -------------------------------------------------------------------------------- 1 | {% macro book(book) %} 2 | 29 | {% endmacro %} 30 | 31 | {% macro shelf(shelf) %} 32 | shelf macro!! 33 | {% endmacro %} 34 | 35 | {% macro author(author) %} 36 | Name: {{ author.name }}
37 | Books: 38 |
    39 | {% for author_book in author.books %} 40 |
  • {{ book(author_book) }}
  • 41 | {% endfor %} 42 |
43 | author macro!! 44 | {% endmacro %} class=pagination-list 45 | 46 | {% macro pagination(paginated_thing) %} 47 | 59 | 60 | {% endmacro %} 61 | 62 | 63 | {% macro book_filter(form) %} 64 |
65 |
66 |
search
67 |
68 | {{ form.title(size=20, class_="input", placeholder='title') }} 69 |
70 |
71 |
72 |
73 | {{ form.publisher(size=20, class_="input", placeholder='publisher') }} 74 |
75 |
76 |
77 |
78 | {{ form.author(size=20, class_="input", placeholder='author') }} 79 |
80 |
81 |
82 |
83 | {{ form.language(size=20, class_="input", placeholder='language') }} 84 |
85 |
86 |
87 |
88 | {{ form.shelf(size=20, class_="input", placeholder='shelf') }} 89 |
90 |
91 |
92 |
93 | {{ form.year(size=20, class_="input", placeholder='year') }} 94 |
95 |

case insensitive. use % as wildcard!

96 |
97 | 98 |
99 | {% endmacro %} 100 | 101 | 102 | {% macro import_block() %} 103 | 104 |
105 |
106 | 107 | import search results: 108 |
109 |
111 | 112 |
113 |
114 | 115 | {% endmacro %} 116 | 117 | -------------------------------------------------------------------------------- /lspace/models/book.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from flask import current_app 5 | from slugify import slugify 6 | from sqlalchemy import Column, Integer, String, ForeignKey, Boolean 7 | from sqlalchemy.orm import relationship 8 | 9 | from lspace import db, whooshee 10 | from lspace.models import Author 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | @whooshee.register_model('title', 'language', 'isbn13') 16 | class Book(db.Model): 17 | __tablename__ = 'books' 18 | 19 | id = Column(Integer, primary_key=True) 20 | title = Column(String(100)) 21 | authors = relationship("Author", 22 | secondary="book_author_association", 23 | back_populates="books", 24 | ) 25 | 26 | isbn13 = Column(String(13)) 27 | publisher = Column(String(100)) 28 | year = Column(Integer()) 29 | language = Column(String(20)) 30 | 31 | md5sum = Column(String(32)) 32 | path = Column(String(400)) 33 | 34 | is_external_path = Column(Boolean()) 35 | 36 | metadata_source = Column(String(20), default='') 37 | 38 | shelve_id = Column(Integer, ForeignKey('shelves.id')) 39 | shelf = relationship("Shelf", back_populates="books", cascade="") 40 | 41 | url = None 42 | 43 | @property 44 | def shelf_name(self): 45 | if self.shelf: 46 | return self.shelf.name 47 | else: 48 | return current_app.config['USER_CONFIG']['default_shelf'] 49 | 50 | @property 51 | def shelf_name_slug(self): 52 | return slugify(self.shelf_name) 53 | 54 | @property 55 | def language_slug(self): 56 | language = self.language or current_app.config['USER_CONFIG']['default_language'] 57 | return slugify(language) 58 | 59 | @property 60 | def publisher_slug(self): 61 | publisher = self.publisher or current_app.config['USER_CONFIG']['default_publisher'] 62 | return slugify(publisher) 63 | 64 | @property 65 | def full_path(self): 66 | # type: () -> str 67 | if self.is_external_path: 68 | return self.path 69 | 70 | library_path = current_app.config['USER_CONFIG']['library_path'] 71 | return os.path.expanduser(os.path.join(library_path, self.path)) 72 | 73 | @property 74 | def authors_names(self): 75 | # type: () -> str 76 | """ 77 | :return: concatenated author names 78 | """ 79 | return ', '.join(author.name for author in self.authors) 80 | 81 | @property 82 | def author_names_slug(self): 83 | # type: () -> str 84 | """ 85 | :return: slugified author names 86 | """ 87 | if self.authors: 88 | author_slugs = [slugify(author.name) for author in self.authors] 89 | authors = '_'.join(author_slugs) 90 | else: 91 | authors = slugify(current_app.config['USER_CONFIG']['default_author']) 92 | return authors 93 | 94 | @property 95 | def title_slug(self): 96 | return slugify(self.title) 97 | 98 | @property 99 | def extension(self): 100 | # type: () -> str 101 | filename, file_extension = os.path.splitext(self.path) 102 | return file_extension 103 | 104 | @staticmethod 105 | def from_search_result(d, metadata_source): 106 | book = Book() 107 | book.metadata_source = metadata_source 108 | book.from_dict(d) 109 | return book 110 | 111 | def from_dict(self, d): 112 | self.isbn13 = d.get('ISBN-13', None) 113 | self.title = d.get('Title', 'no title') 114 | self.publisher = d.get('Publisher', None) 115 | self.language = d.get('Language', None) 116 | 117 | year = d.get('Year', None) 118 | if not year: 119 | self.year = 0 120 | else: 121 | self.year = int(year) 122 | 123 | if not d.get('Authors', None): 124 | authors = ['no author'] 125 | else: 126 | authors = d.get('Authors') 127 | 128 | for author_name in authors: 129 | author = Author.query.filter_by(name=author_name).first() 130 | if not author: 131 | logger.info('creating %s' % author_name) 132 | author = Author(name=author_name) 133 | self.authors.append(author) 134 | 135 | def __repr__(self): 136 | return ''.format(isbn=self.isbn13, authors=self.authors_names, 137 | title=self.title) 138 | 139 | def to_dict(self): 140 | return dict( 141 | title=self.title, 142 | authors=self.authors, 143 | 144 | isbn13=self.isbn13, 145 | publisher=self.publisher, 146 | year=self.year, 147 | language=self.language, 148 | 149 | md5sum=self.md5sum, 150 | path=self.path, 151 | 152 | metadata_source=self.metadata_source 153 | ) 154 | 155 | def formatted_output_head(self): 156 | return '{authors} - {title} ({year})'.format( 157 | authors=self.authors_names, title=self.title, year=self.year) 158 | 159 | def formatted_output_details(self): 160 | return 'isbn: {isbn}\npublisher: {publisher}\nlanguage: {language}\nmetadata source: {source}\n'.format( 161 | isbn=self.isbn13, 162 | language=self.language, 163 | publisher=self.publisher, 164 | source=self.metadata_source) 165 | 166 | def save(self): 167 | for author in self.authors: 168 | db.session.add(author) 169 | 170 | if self.shelf: 171 | db.session.add(self.shelf) 172 | 173 | db.session.add(self) 174 | 175 | return db.session.commit() 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # l-space 2 | 3 | a cli ebook manager built around [isbnlib](https://github.com/xlcnd/isbnlib) 4 | 5 | on import, lspace tries to find isbns in the files metadata and in the text. 6 | with the isbn it tries to fetch metadata about the book from google books and openlibrary. 7 | if no isbn is found, it queries metadata based on the filename. 8 | 9 | after this, your properly renamed files will be stored in your library folder. 10 | 11 | currently supports epub and pdf. 12 | 13 | 14 | [![Build Status](https://travis-ci.org/puhoy/lspace.svg?branch=master)](https://travis-ci.org/puhoy/lspace) 15 | 16 | [![codecov](https://codecov.io/gh/puhoy/lspace/branch/master/graph/badge.svg)](https://codecov.io/gh/puhoy/lspace) 17 | 18 | ## requirements 19 | 20 | python >=3.5 and pip 21 | 22 | 23 | ## installation 24 | 25 | #### from pypi (latest release) 26 | 27 | `pip install lspace` 28 | 29 | #### from github (probably-not-so-stable-dev-stuff) 30 | 31 | `pip install git+https://github.com/puhoy/lspace.git` 32 | 33 | 34 | ## setup 35 | 36 | after installation, you should run 37 | 38 | `lspace init` 39 | 40 | this will setup a new configuration file, which you can edit to specify the structure of your library, for example. 41 | 42 | a default config file would look like this: 43 | ``` 44 | database_path: sqlite:////home/USER/.config/lspace/lspace.db 45 | file_format: '{SHELF}/{AUTHORS}_{TITLE}' 46 | library_path: ~/library 47 | loglevel: error 48 | default_shelf: misc 49 | default_author: no author 50 | default_language: no language 51 | default_publisher: no publisher 52 | ``` 53 | 54 | #### database path 55 | 56 | path to your database. 57 | the project uses sqlalchemy, so all databases supported by sqlalchemy should be fine. 58 | 59 | #### file_format 60 | 61 | template string for storing the plain files in the library. 62 | 63 | `{SHELF}/{AUTHORS}_{TITLE}` would produce files like `scifi/cixin-liu_three-body-problem.epub` 64 | 65 | author and title will be automatically slugified for this. 66 | 67 | possible variables to use are: AUTHORS, TITLE, SHELF, YEAR, LANGUAGE, PUBLISHER 68 | 69 | #### library path 70 | 71 | where the imported files are stored 72 | 73 | #### loglevel 74 | 75 | the default python loglevels (debug, info, error, exception) 76 | 77 | #### default_{shelf, author, language, publisher} 78 | 79 | the default field names, in case nothing is specified in import 80 | 81 | 82 | ## usage 83 | 84 | ### importing 85 | 86 | `lspace import path/to/ebook.epub` 87 | 88 | `lspace import path/to/folder/*` 89 | 90 | if you already have a folder you are happy with and, for example, just want to use it to serve your books or search through your files, you can add the `--inplace` switch on import, which will not copy them over to your library folder, but instead keep the book as an "external" reference. 91 | 92 | `lspace import --inplace path/to/ebook.epub` 93 | 94 | #### import from calibre library 95 | 96 | `lspace import path/to/calibre_library/metadata.db` 97 | 98 | #### import from lspace api 99 | 100 | `lspace import http:///api/v1/` 101 | 102 | the web interface (`lspace web` - scroll down a bit!) generates import strings based on your search! 103 | 104 | 105 | ### searching your library 106 | 107 | `lspace list QUERY [--path]` 108 | 109 | for example, 110 | 111 | `lspace list programming --path` 112 | 113 | would return something like 114 | 115 | /home/USER/library/donald-e-knuth/art-of-computer-programming-volume-2.pdf 116 | /home/USER/library/donald-e-knuth/the-art-of-computer-programming-volume-1-fascicle-1.pdf 117 | 118 | and 119 | 120 | `lspace list dwarf` 121 | 122 | would return return 123 | 124 | Peter Tyson - Getting Started With Dwarf Fortress 125 | 126 | ### removing stuff 127 | 128 | `lspace remove QUERY` 129 | 130 | this command will ask you before it actually deletes stuff :) 131 | 132 | Peter Tyson - Getting Started With Dwarf Fortress 133 | /home/USER/library/peter-tyson/getting-started-with-dwarf-fortress.epub 134 | delete this book from library? [y/N]: 135 | 136 | ### exporting books 137 | 138 | 139 | `lspace export QUERY ~/some/folder/ --format mobi` 140 | 141 | would convert all books matching on QUERY to 'mobi' and export them to ~/some/folder 142 | 143 | to actually export to another format, you need "ebook-convert", which is part of [calibre](https://calibre-ebook.com/)! 144 | 145 | ### browse & share your books via webserver 146 | 147 | `lspace web --host 0.0.0.0 --port 5000` 148 | 149 | ![L-Space web interface](https://raw.githubusercontent.com/puhoy/lspace/master/lspace_screenshot.png "screenshot of the L-Space web interface") 150 | 151 | this also gives you the import command for your current search results! 152 | 153 | (or you can just download them manually..) 154 | 155 | ## setting up a dev env 156 | 157 | #### 1. clone this repo 158 | 159 | #### 2. make a virtualenv and activate it 160 | 161 | ```bash 162 | python -m venv env 163 | 164 | source env/bin/activate # for bash 165 | 166 | # or 167 | #. env/bin/activate.fish # for fish 168 | ``` 169 | 170 | #### 3. install requirements 171 | 172 | ```bash 173 | pip install -e .[dev] 174 | ``` 175 | 176 | #### 4. set up a separate config to not mess up your regular installation 177 | 178 | ```bash 179 | # initialize a new config file at a separate path 180 | LSPACE_CONFIG=~/.config/lspace_dev/config.yml lspace init 181 | 182 | # change the database and library path! (otherwise it would still use the regular db) 183 | sed -i 's/lspace\/lspace.db/lspace_dev\/lspace.db/g' ~/.config/lspace_dev/config.yml 184 | sed -i 's/~\/library/~\/library_dev/g' ~/.config/lspace_dev/config.yml 185 | 186 | # also, if you want, set the loglevel to something else 187 | 188 | ``` 189 | 190 | after this, just set LSPACE_CONFIG to your new config file before you start to try new stuff 191 | 192 | ```bash 193 | export LSPACE_CONFIG=~/.config/lspace_dev/config.yml # bash 194 | set -gx LSPACE_CONFIG ~/.config/lspace_dev/config.yml # fish 195 | ``` 196 | 197 | #### migrations 198 | 199 | the db command is available when running in dev mode. 200 | 201 | create a new migration with `LSPACE_DEV=1 lspace db migrate` 202 | 203 | afterwards, running any lspace command will automatically update the database 204 | 205 | 206 | #### making a release 207 | 208 | commands to build, test-release and release are wrapped in a `doit` script `dodo.py`. 209 | 210 | bumping is requires bump2version, twine is used for uploading to pypi. 211 | 212 | 213 | ## why "L-space"? 214 | 215 | its named after discworlds [library-space](https://en.wikipedia.org/wiki/List_of_dimensions_of_the_Discworld#L-space) dimension :) 216 | 217 | -------------------------------------------------------------------------------- /lspace/cli/import_command/_import.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import PurePath, Path 3 | from urllib.parse import urlparse 4 | 5 | import click 6 | import yaml 7 | from typing import List 8 | from typing import Union 9 | 10 | from lspace.cli.import_command.add_book_to_db import add_book_to_db 11 | from lspace.cli.import_command.add_to_shelf import add_to_shelf 12 | from lspace.cli.import_command.check_if_in_library import check_if_in_library 13 | from lspace.cli.import_command.copy_to_library import copy_to_library 14 | from lspace.cli.import_command.import_from_api import ApiImporter 15 | from lspace.cli.import_command.import_from_calibre import CalibreWrapper 16 | from lspace.cli.import_command.import_options._options import other_choices 17 | from lspace.file_types import FileTypeBase 18 | from lspace.file_types import get_file_type_object 19 | from lspace.models import Book 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | def bold(s): 25 | return click.style(s, bold=True) 26 | 27 | 28 | def is_calibre_library(path): 29 | if PurePath(path).suffix == '.db': 30 | c = CalibreWrapper(path) 31 | library_id = None 32 | try: 33 | library_id = c.get_library_id() 34 | except Exception as e: 35 | logger.exception('', exc_info=True) 36 | pass 37 | 38 | if library_id: 39 | return True 40 | 41 | return False 42 | 43 | 44 | def is_api(url): 45 | parsed = urlparse(url) 46 | scheme = parsed.scheme 47 | path = parsed.path 48 | 49 | clean_api_path = '/api/v1/' 50 | if (scheme in ['http', 'https']) and (clean_api_path in path): 51 | # todo: fetch /version ? 52 | return True 53 | 54 | return False 55 | 56 | 57 | def import_wizard(path, skip_library_check, move, inplace): 58 | # type: (str, bool, bool, bool) -> Union[Book, None] 59 | click.echo(bold('importing ') + path) 60 | 61 | if is_calibre_library(path): 62 | if click.confirm('this looks like a calibre library - import?', default=True): 63 | calibre_importer = CalibreWrapper(path) 64 | for book_path, book in calibre_importer.import_books(): 65 | import_file_wizard(book_path, skip_library_check, move, inplace, metadata=[book]) 66 | return 67 | 68 | if is_api(path): 69 | if click.confirm('this looks like the lspace api - import?', default=True): 70 | api_importer = ApiImporter(path) 71 | for book_path, book in api_importer.start_import(skip_library_check=skip_library_check): 72 | import_file_wizard(book_path, skip_library_check, move=False, inplace=False, metadata=[book]) 73 | return 74 | 75 | return import_file_wizard(path, skip_library_check, move, inplace) 76 | 77 | 78 | def import_file_wizard(path, skip_library_check, move, inplace, metadata=None): 79 | # type: (str, bool, bool, bool, List[Book]) -> Union[Book, None] 80 | 81 | abspath = Path(path).resolve() 82 | if not abspath.exists(): 83 | logger.error(f"cannot find file at {abspath}, skipping") 84 | return 85 | 86 | try: 87 | file_type_object = get_file_type_object(abspath) 88 | except Exception as e: 89 | logger.exception('error reading {path}'.format(path=abspath), exc_info=True) 90 | click.secho('error reading {path}'.format(path=abspath), fg='red') 91 | return 92 | 93 | if not file_type_object: 94 | # skip, because we have no class to read this file 95 | click.echo('skipping %s' % path) 96 | return 97 | 98 | if not skip_library_check and Book.query.filter_by(md5sum=file_type_object.get_md5()).first(): 99 | click.echo(bold('already imported') + ', skipping ' + path) 100 | return 101 | 102 | if not metadata: 103 | isbns_with_metadata = file_type_object.fetch_results() 104 | else: 105 | isbns_with_metadata = metadata 106 | 107 | if len(isbns_with_metadata) == 0: 108 | click.echo('could not find any isbn or metadata for %s' % file_type_object.filename) 109 | choice = choose_result(file_type_object, []) 110 | 111 | else: 112 | choice = choose_result(file_type_object, isbns_with_metadata) 113 | 114 | logger.debug('choice was %s' % choice) 115 | 116 | while choice in list(other_choices.keys()): 117 | # if choice is one of "other choices", its not one of the results, 118 | # but one of the strings mapped to functions 119 | 120 | function_that_gets_new_choices = other_choices.get(choice)['function'] 121 | isbns_with_metadata = function_that_gets_new_choices( 122 | file_type_object=file_type_object, 123 | old_choices=isbns_with_metadata, 124 | ) 125 | 126 | if isbns_with_metadata is not False: 127 | choice = choose_result(file_type_object, isbns_with_metadata) 128 | else: 129 | # "skip" is the only function that returns false here 130 | choice = False 131 | 132 | if choice: 133 | book = _import(file_type_object, choice, move, inplace) 134 | return book 135 | else: 136 | click.echo('skipping %s' % file_type_object.filename, color='yellow') 137 | 138 | 139 | def choose_result(file_type_object, isbns_with_metadata): 140 | # type: (FileTypeBase, [Book]) -> Book 141 | if not isbns_with_metadata: 142 | click.secho('no results found :(', fg='yellow') 143 | 144 | formatted_choices = format_metadata_choices( 145 | isbns_with_metadata) 146 | 147 | click.echo(''.join(formatted_choices.values())) 148 | 149 | choices = list(formatted_choices.keys()) 150 | 151 | if len(isbns_with_metadata) >= 1: 152 | default = '1' 153 | else: 154 | default = 's' 155 | 156 | ret = click.prompt('choose result for ' + 157 | click.style('{filename}'.format(filename=file_type_object.filename), bold=True), 158 | type=click.Choice(choices), 159 | default=default) 160 | if ret in list(other_choices.keys()): 161 | choice = ret 162 | else: 163 | try: 164 | idx = int(ret) - 1 165 | choice = isbns_with_metadata[idx] 166 | except Exception as e: 167 | logger.exception('cant convert %s to int!' % ret, exc_info=True) 168 | return False 169 | 170 | return choice 171 | 172 | 173 | def format_metadata_choices(isbns_with_metadata): 174 | # type: ([Book]) -> dict 175 | 176 | formatted_metadata = { 177 | # 'choice': 'str shown to user' 178 | } 179 | for idx, meta in reversed(list(enumerate(isbns_with_metadata))): 180 | logger.info('adding %s' % meta) 181 | 182 | formatted_metadata[str(idx + 1)] = click.style( 183 | '{index}: {head}\n'.format(index=idx + 1, head=meta.formatted_output_head()), 184 | bold=True) 185 | formatted_metadata[str(idx + 1)] += meta.formatted_output_details() + '\n' 186 | 187 | for key, val in other_choices.items(): 188 | formatted_metadata[key] = \ 189 | click.style(yaml.dump({ 190 | key: val['explanation']}, 191 | allow_unicode=True), bold=True) 192 | 193 | logger.debug('formatted data is %s' % formatted_metadata) 194 | return formatted_metadata 195 | 196 | 197 | def similar_books_decide_import(book_choice): 198 | similar_books = check_if_in_library(book_choice) 199 | if similar_books: 200 | click.echo(bold('found similar books in library:')) 201 | for book in similar_books: 202 | click.echo(bold('{book.authors_names} - {book.title}'.format(book=book))) 203 | click.echo('isbn: {book.isbn13}'.format(book=book)) 204 | click.echo('{book.path}\n'.format(book=book)) 205 | 206 | if not click.confirm('import anyway?'): 207 | return False 208 | 209 | return True 210 | 211 | 212 | def _import(file_type_object, book_choice, move_file, inplace): 213 | # type: (FileTypeBase, Book, bool, bool) -> Union[Book, None] 214 | logger.debug('importing %s, %s' % (file_type_object, book_choice)) 215 | logger.debug(file_type_object) 216 | 217 | book_choice.path = file_type_object.path 218 | 219 | do_import = similar_books_decide_import(book_choice) 220 | if not do_import: 221 | click.echo(bold('skipping {path}'.format(path=file_type_object.path))) 222 | return 223 | 224 | add_to_shelf(book_choice) 225 | 226 | if inplace: 227 | book = add_book_to_db(file_type_object, book_choice, file_type_object.path, is_external_path=True) 228 | 229 | else: 230 | path_in_library = copy_to_library(file_type_object.path, book_choice, move_file) 231 | if path_in_library: 232 | book = add_book_to_db(file_type_object, book_choice, path_in_library, is_external_path=False) 233 | else: 234 | click.secho('could not import %s' % file_type_object.path, fg='red') 235 | return None 236 | 237 | click.secho('imported %s - %s' % (book.authors_names, book.title), fg='green') 238 | return book 239 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | --------------------------------------------------------------------------------