diff --git a/actions/calibre b/actions/calibre
new file mode 100755
index 000000000..bc8d51cd7
--- /dev/null
+++ b/actions/calibre
@@ -0,0 +1,76 @@
+#!/usr/bin/python3
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Configuration helper for calibre.
+"""
+
+import argparse
+import json
+import pathlib
+import shutil
+import subprocess
+
+from plinth.modules import calibre
+
+LIBRARIES_PATH = pathlib.Path('/var/lib/calibre-server-freedombox/libraries')
+
+
+def parse_arguments():
+ """Return parsed command line arguments as dictionary."""
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
+
+ subparsers.add_parser('list-libraries',
+ help='Return the list of libraries setup')
+ subparser = subparsers.add_parser('create-library',
+ help='Create an empty library')
+ subparser.add_argument('name', help='Name of the new library')
+ subparser = subparsers.add_parser('delete-library',
+ help='Delete a library and its contents')
+ subparser.add_argument('name', help='Name of the library to delete')
+
+ subparsers.required = True
+ return parser.parse_args()
+
+
+def subcommand_list_libraries(_):
+ """Return the list of libraries setup."""
+ libraries = []
+ for library in LIBRARIES_PATH.glob('*/metadata.db'):
+ libraries.append(str(library.parent.name))
+
+ print(json.dumps({'libraries': libraries}))
+
+
+def subcommand_create_library(arguments):
+ """Create an empty library."""
+ calibre.validate_library_name(arguments.name)
+ library = LIBRARIES_PATH / arguments.name
+ library.mkdir(mode=0o755) # Raise exception if already exists
+ subprocess.call(
+ ['calibredb', '--with-library', library, 'list_categories'],
+ stdout=subprocess.DEVNULL)
+
+ # Force systemd StateDirectory= logic to assign proper ownership to the
+ # DynamicUser=
+ shutil.chown(LIBRARIES_PATH.parent, 'root', 'root')
+
+
+def subcommand_delete_library(arguments):
+ """Delete a library and its contents."""
+ calibre.validate_library_name(arguments.name)
+ library = LIBRARIES_PATH / arguments.name
+ shutil.rmtree(library)
+
+
+def main():
+ """Parse arguments and perform all duties."""
+ arguments = parse_arguments()
+
+ subcommand = arguments.subcommand.replace('-', '_')
+ subcommand_method = globals()['subcommand_' + subcommand]
+ subcommand_method(arguments)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/plinth/modules/calibre/__init__.py b/plinth/modules/calibre/__init__.py
new file mode 100644
index 000000000..e3786812c
--- /dev/null
+++ b/plinth/modules/calibre/__init__.py
@@ -0,0 +1,124 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+FreedomBox app for calibre e-book library.
+"""
+
+import json
+import re
+
+from django.utils.translation import ugettext_lazy as _
+
+from plinth import actions
+from plinth import app as app_module
+from plinth import cfg, frontpage, menu
+from plinth.daemon import Daemon
+from plinth.modules.apache.components import Webserver
+from plinth.modules.firewall.components import Firewall
+from plinth.modules.users.components import UsersAndGroups
+from plinth.utils import format_lazy
+
+from .manifest import backup, clients # noqa, pylint: disable=unused-import
+
+version = 1
+
+managed_services = ['calibre-server-freedombox']
+
+managed_packages = ['calibre']
+
+_description = [
+ format_lazy(
+ _('calibre server provides online access to your e-book collection. '
+ 'You can store your e-books on your {box_name}, read them online or '
+ 'from any of your devices.'), box_name=_(cfg.box_name)),
+ _('You can organize your e-books, extract and edit their metadata, and '
+ 'perform advanced search. It can import, export, or convert across a '
+ 'wide range of formats to make e-books ready for reading on any '
+ 'device. You can read books on your browser with the builtin web '
+ 'reader. It remembers your last read location, bookmarks, and '
+ 'highlighted text.'),
+ _('Only users belonging to calibre group will be able to access '
+ 'the app. Content distribution with OPDS is currently not supported.')
+]
+
+app = None
+
+LIBRARY_NAME_PATTERN = r'[a-zA-Z0-9 _-]+'
+
+
+class CalibreApp(app_module.App):
+ """FreedomBox app for calibre."""
+
+ app_id = 'calibre'
+
+ def __init__(self):
+ """Create components for the app."""
+ super().__init__()
+
+ groups = {'calibre': _('Administer calibre application')}
+
+ info = app_module.Info(app_id=self.app_id, version=version,
+ name=_('calibre'), icon_filename='calibre',
+ short_description=_('E-book Library'),
+ description=_description, manual_page='calibre',
+ clients=clients)
+ self.add(info)
+
+ menu_item = menu.Menu('menu-calibre', info.name,
+ info.short_description, info.icon_filename,
+ 'calibre:index', parent_url_name='apps')
+ self.add(menu_item)
+
+ shortcut = frontpage.Shortcut('shortcut-calibre', info.name,
+ short_description=info.short_description,
+ icon=info.icon_filename, url='/calibre',
+ clients=info.clients,
+ login_required=True,
+ allowed_groups=list(groups))
+ self.add(shortcut)
+
+ firewall = Firewall('firewall-calibre', info.name,
+ ports=['http', 'https'], is_external=True)
+ self.add(firewall)
+
+ webserver = Webserver('webserver-calibre', 'calibre-freedombox',
+ urls=['https://{host}/calibre'])
+ self.add(webserver)
+
+ daemon = Daemon('daemon-calibre', managed_services[0],
+ listen_ports=[(8844, 'tcp4')])
+ self.add(daemon)
+
+ users_and_groups = UsersAndGroups('users-and-groups-calibre',
+ reserved_usernames=['calibre'],
+ groups=groups)
+ self.add(users_and_groups)
+
+
+def setup(helper, old_version=None):
+ """Install and configure the module."""
+ helper.install(managed_packages)
+ helper.call('post', app.enable)
+
+
+def validate_library_name(library_name):
+ """Raise exception if library name does not fit the accepted pattern."""
+ if not re.fullmatch(r'[A-Za-z0-9_.-]+', library_name):
+ raise Exception('Invalid library name')
+
+
+def list_libraries():
+ """Return a list of libraries."""
+ output = actions.superuser_run('calibre', ['list-libraries'])
+ return json.loads(output)['libraries']
+
+
+def create_library(name):
+ """Create an empty library."""
+ actions.superuser_run('calibre', ['create-library', name])
+ actions.superuser_run('service', ['try-restart', managed_services[0]])
+
+
+def delete_library(name):
+ """Delete a library and its contents."""
+ actions.superuser_run('calibre', ['delete-library', name])
+ actions.superuser_run('service', ['try-restart', managed_services[0]])
diff --git a/plinth/modules/calibre/data/etc/apache2/conf-available/calibre-freedombox.conf b/plinth/modules/calibre/data/etc/apache2/conf-available/calibre-freedombox.conf
new file mode 100644
index 000000000..e423f2134
--- /dev/null
+++ b/plinth/modules/calibre/data/etc/apache2/conf-available/calibre-freedombox.conf
@@ -0,0 +1,10 @@
+##
+## On all sites, provide calibre web interface on a path: /calibre
+##
+
+ ProxyPass http://localhost:8844/calibre
+ Include includes/freedombox-single-sign-on.conf
+
+ TKTAuthToken "admin" "calibre"
+
+
diff --git a/plinth/modules/calibre/data/etc/plinth/modules-enabled/calibre b/plinth/modules/calibre/data/etc/plinth/modules-enabled/calibre
new file mode 100644
index 000000000..bef75a6fd
--- /dev/null
+++ b/plinth/modules/calibre/data/etc/plinth/modules-enabled/calibre
@@ -0,0 +1 @@
+plinth.modules.calibre
diff --git a/plinth/modules/calibre/data/lib/systemd/system/calibre-server-freedombox.service b/plinth/modules/calibre/data/lib/systemd/system/calibre-server-freedombox.service
new file mode 100644
index 000000000..29941bc85
--- /dev/null
+++ b/plinth/modules/calibre/data/lib/systemd/system/calibre-server-freedombox.service
@@ -0,0 +1,47 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+[Unit]
+Description=calibre Content Server
+Documentation=man:calibre-server(1)
+After=network.target
+
+[Service]
+CapabilityBoundingSet=~CAP_SYS_ADMIN CAP_SYS_PTRACE CAP_SETUID CAP_SETGID CAP_SETPCAP CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_IPC_OWNER CAP_NET_ADMIN CAP_AUDIT_CONTROL CAP_AUDIT_READ CAP_AUDIT_WRITE CAP_KILL CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_LINUX_IMMUTABLE CAP_IPC_LOCK CAP_SYS_CHROOT CAP_BLOCK_SUSPEND CAP_LEASE CAP_SYS_PACCT CAP_SYS_TTY_CONFIG CAP_SYS_BOOT CAP_MAC_ADMIN CAP_MAC_OVERRIDE CAP_SYS_NICE CAP_SYS_RESOURCE
+DevicePolicy=closed
+Environment=HOME="/var/lib/calibre-server-freedombox"
+Environment=DEFAULT_LIBRARY="/var/lib/calibre-server-freedombox/libraries/Library"
+Environment=ARGS="--listen-on 127.0.0.1 --url-prefix /calibre --port 8844 --enable-local-write --disable-auth"
+ExecStartPre=sh -e -c "files=$$(ls ${HOME}/libraries/*/metadata.db 2>/dev/null || true); [ \"x$${files}\" = \"x\" ] && (mkdir -p \"${DEFAULT_LIBRARY}\" && calibredb --with-library=\"${DEFAULT_LIBRARY}\" list_categories > /dev/null) || true"
+ExecStart=sh -e -c "files=${HOME}/libraries/*/metadata.db; libraries=$$(dirname $${files}) ; exec /usr/bin/calibre-server $ARGS $${libraries}"
+Restart=on-failure
+ExecReload=/bin/kill -HUP $MAINPID
+DynamicUser=yes
+LockPersonality=yes
+NoNewPrivileges=yes
+PrivateDevices=yes
+PrivateMounts=yes
+PrivateTmp=yes
+PrivateUsers=yes
+ProtectControlGroups=yes
+ProtectClock=yes
+ProtectHome=yes
+ProtectHostname=yes
+ProtectKernelLogs=yes
+ProtectKernelModules=yes
+ProtectKernelTunables=yes
+ProtectSystem=strict
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
+RestrictNamespaces=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+StateDirectory=calibre-server-freedombox
+SystemCallArchitectures=native
+SystemCallFilter=@system-service
+SystemCallFilter=~@resources
+SystemCallFilter=~@privileged
+SystemCallErrorNumber=EPERM
+Type=simple
+
+
+[Install]
+WantedBy=multi-user.target
diff --git a/plinth/modules/calibre/forms.py b/plinth/modules/calibre/forms.py
new file mode 100644
index 000000000..61fe67d61
--- /dev/null
+++ b/plinth/modules/calibre/forms.py
@@ -0,0 +1,29 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Django form for configuring calibre.
+"""
+
+from django import forms
+from django.core import validators
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext_lazy as _
+
+from plinth.modules import calibre
+
+
+class CreateLibraryForm(forms.Form):
+ """Form to create an empty library."""
+
+ name = forms.CharField(
+ label=_('Name of the new library'), strip=True,
+ validators=[validators.RegexValidator(r'^[A-Za-z0-9_.-]+$')])
+
+ def clean_name(self):
+ """Check if the library name is valid."""
+ name = self.cleaned_data['name']
+
+ if name in calibre.list_libraries():
+ raise ValidationError(
+ _('A library with this name already exists.'))
+
+ return name
diff --git a/plinth/modules/calibre/manifest.py b/plinth/modules/calibre/manifest.py
new file mode 100644
index 000000000..2b6ce7317
--- /dev/null
+++ b/plinth/modules/calibre/manifest.py
@@ -0,0 +1,21 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+from django.utils.translation import ugettext_lazy as _
+
+from plinth.clients import validate
+from plinth.modules.backups.api import validate as validate_backup
+
+clients = validate([{
+ 'name': _('calibre'),
+ 'platforms': [{
+ 'type': 'web',
+ 'url': '/calibre/'
+ }]
+}])
+
+backup = validate_backup({
+ 'data': {
+ 'directories': ['/var/lib/private/calibre-server-freedombox/']
+ },
+ 'services': ['calibre-server-freedombox']
+})
diff --git a/plinth/modules/calibre/templates/calibre-delete-library.html b/plinth/modules/calibre/templates/calibre-delete-library.html
new file mode 100644
index 000000000..93946e251
--- /dev/null
+++ b/plinth/modules/calibre/templates/calibre-delete-library.html
@@ -0,0 +1,30 @@
+{% extends "base.html" %}
+{% comment %}
+# SPDX-License-Identifier: AGPL-3.0-or-later
+{% endcomment %}
+
+{% load i18n %}
+
+{% block content %}
+
+
+ {% blocktrans trimmed %}
+ Delete calibre Library {{ name }}
+ {% endblocktrans %}
+
+
+
+ {% blocktrans trimmed %}
+ Delete this library permanently? All stored e-books and saved data will be
+ lost.
+ {% endblocktrans %}
+
+
+
+
+{% endblock %}
diff --git a/plinth/modules/calibre/templates/calibre.html b/plinth/modules/calibre/templates/calibre.html
new file mode 100644
index 000000000..9e4e854a8
--- /dev/null
+++ b/plinth/modules/calibre/templates/calibre.html
@@ -0,0 +1,59 @@
+{% extends "app.html" %}
+{% comment %}
+# SPDX-License-Identifier: AGPL-3.0-or-later
+{% endcomment %}
+
+{% load i18n %}
+
+{% block page_head %}
+
+{% endblock %}
+
+{% block configuration %}
+ {{ block.super }}
+
+ {% trans "Manage Libraries" %}
+
+
+
+
+
+ {% if not libraries %}
+
{% trans 'No libraries available.' %}
+ {% else %}
+
+ {% for library in libraries %}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/plinth/modules/calibre/tests/__init__.py b/plinth/modules/calibre/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/plinth/modules/calibre/tests/data/sample.txt b/plinth/modules/calibre/tests/data/sample.txt
new file mode 100644
index 000000000..73709ba68
--- /dev/null
+++ b/plinth/modules/calibre/tests/data/sample.txt
@@ -0,0 +1 @@
+Testing
diff --git a/plinth/modules/calibre/tests/test_actions.py b/plinth/modules/calibre/tests/test_actions.py
new file mode 100644
index 000000000..ff61ece9c
--- /dev/null
+++ b/plinth/modules/calibre/tests/test_actions.py
@@ -0,0 +1,84 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Test module for calibre actions.
+"""
+
+import imp
+import json
+import pathlib
+from unittest.mock import call, patch
+
+import pytest
+
+
+def _action_file():
+ """Return the path to the 'calibre' actions file."""
+ current_directory = pathlib.Path(__file__).parent
+ return str(current_directory / '..' / '..' / '..' / '..' / 'actions' /
+ 'calibre')
+
+
+actions = imp.load_source('calibre', _action_file())
+
+
+@pytest.fixture(name='call_action')
+def fixture_call_action(tmpdir, capsys):
+ """Run actions with custom root path."""
+
+ def _call_action(args, **kwargs):
+ actions.LIBRARIES_PATH = pathlib.Path(str(tmpdir))
+ with patch('argparse._sys.argv', ['calibre'] + args):
+ actions.main()
+ captured = capsys.readouterr()
+ return captured.out
+
+ return _call_action
+
+
+@pytest.fixture(autouse=True)
+def fixture_patch():
+ """Patch some underlying methods."""
+
+ def side_effect(*args, **_kwargs):
+ if args[0][0] != 'calibredb':
+ return
+
+ path = pathlib.Path(args[0][2]) / 'metadata.db'
+ path.touch()
+
+ with patch('subprocess.call') as subprocess_call, \
+ patch('shutil.chown'):
+ subprocess_call.side_effect = side_effect
+ yield
+
+
+def test_list_libraries(call_action):
+ """Test listing libraries."""
+ assert json.loads(call_action(['list-libraries'])) == {'libraries': []}
+ call_action(['create-library', 'TestLibrary'])
+ expected_output = {'libraries': ['TestLibrary']}
+ assert json.loads(call_action(['list-libraries'])) == expected_output
+
+
+@patch('shutil.chown')
+def test_create_library(chown, call_action):
+ """Test creating a library."""
+ call_action(['create-library', 'TestLibrary'])
+ library = actions.LIBRARIES_PATH / 'TestLibrary'
+ assert (library / 'metadata.db').exists()
+ assert library.stat().st_mode == 0o40755
+ expected_output = {'libraries': ['TestLibrary']}
+ assert json.loads(call_action(['list-libraries'])) == expected_output
+
+ chown.assert_has_calls([call(library.parent.parent, 'root', 'root')])
+
+
+def test_delete_library(call_action):
+ """Test deleting a library."""
+ call_action(['create-library', 'TestLibrary'])
+
+ call_action(['delete-library', 'TestLibrary'])
+ assert json.loads(call_action(['list-libraries'])) == {'libraries': []}
+
+ with pytest.raises(FileNotFoundError):
+ call_action(['delete-library', 'TestLibrary'])
diff --git a/plinth/modules/calibre/tests/test_functional.py b/plinth/modules/calibre/tests/test_functional.py
new file mode 100644
index 000000000..9f7ca97e6
--- /dev/null
+++ b/plinth/modules/calibre/tests/test_functional.py
@@ -0,0 +1,180 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Functional, browser based tests for calibre app.
+"""
+
+import pathlib
+import time
+
+import pytest
+
+from plinth.tests import functional
+
+pytestmark = [pytest.mark.apps, pytest.mark.sso, pytest.mark.calibre]
+
+
+@pytest.fixture(scope='module', autouse=True)
+def fixture_background(session_browser):
+ """Login and install the app."""
+ functional.login(session_browser)
+ functional.install(session_browser, 'calibre')
+ yield
+ functional.app_disable(session_browser, 'calibre')
+
+
+def test_enable_disable(session_browser):
+ """Test enabling the app."""
+ functional.app_disable(session_browser, 'calibre')
+
+ functional.app_enable(session_browser, 'calibre')
+ assert functional.service_is_running(session_browser, 'calibre')
+ assert functional.is_available(session_browser, 'calibre')
+
+ functional.app_disable(session_browser, 'calibre')
+ assert not functional.service_is_running(session_browser, 'calibre')
+ assert not functional.is_available(session_browser, 'calibre')
+
+
+def test_add_delete_library(session_browser):
+ """Test adding/deleting a new library."""
+ functional.app_enable(session_browser, 'calibre')
+ _delete_library(session_browser, 'FunctionalTest', True)
+
+ _add_library(session_browser, 'FunctionalTest')
+ assert _is_library_available(session_browser, 'FunctionalTest')
+
+ _delete_library(session_browser, 'FunctionalTest')
+ assert not _is_library_available(session_browser, 'FunctionalTest')
+
+
+def test_add_delete_book(session_browser):
+ """Test adding/delete book in the library."""
+ functional.app_enable(session_browser, 'calibre')
+ _add_library(session_browser, 'FunctionalTest')
+ _delete_book(session_browser, 'FunctionalTest', 'sample.txt', True)
+
+ _add_book(session_browser, 'FunctionalTest', 'sample.txt')
+ assert _is_book_available(session_browser, 'FunctionalTest', 'sample.txt')
+
+ _delete_book(session_browser, 'FunctionalTest', 'sample.txt')
+ assert not _is_book_available(session_browser, 'FunctionalTest',
+ 'sample.txt')
+
+
+@pytest.mark.backups
+def test_backup(session_browser):
+ """Test backing up and restoring."""
+ functional.app_enable(session_browser, 'calibre')
+ _add_library(session_browser, 'FunctionalTest')
+ functional.backup_create(session_browser, 'calibre', 'test_calibre')
+ _delete_library(session_browser, 'FunctionalTest')
+ functional.backup_restore(session_browser, 'calibre', 'test_calibre')
+ assert _is_library_available(session_browser, 'FunctionalTest')
+
+
+def _add_library(browser, name):
+ """Add a new library."""
+ if _is_library_available(browser, name):
+ return
+
+ browser.find_link_by_href(
+ '/plinth/apps/calibre/library/create/').first.click()
+ browser.find_by_id('id_calibre-name').fill(name)
+ functional.submit(browser)
+
+
+def _delete_library(browser, name, ignore_missing=False):
+ """Delete a library."""
+ functional.nav_to_module(browser, 'calibre')
+ link = browser.find_link_by_href(
+ f'/plinth/apps/calibre/library/{name}/delete/')
+ if not link:
+ if ignore_missing:
+ return
+
+ raise ValueError('Library not found')
+
+ link.first.click()
+ functional.submit(browser)
+
+
+def _is_library_available(browser, name):
+ """Return whether a library is present in the list of libraries."""
+ functional.nav_to_module(browser, 'calibre')
+ link = browser.find_link_by_href(
+ f'/plinth/apps/calibre/library/{name}/delete/')
+ return bool(link)
+
+
+def _visit_library(browser, name):
+ """Open the page for the library."""
+ functional.visit(browser, '/calibre/')
+
+ # Calibre interface will be available a short time after restarting the
+ # service.
+ def _service_available():
+ unavailable_xpath = '//h1[contains(text(), "Service Unavailable")]'
+ available = not browser.find_by_xpath(unavailable_xpath)
+ if not available:
+ time.sleep(0.5)
+ functional.visit(browser, '/calibre/')
+
+ return available
+
+ functional.eventually(_service_available)
+
+ functional.eventually(browser.find_by_css,
+ args=[f'.calibre-push-button[data-lid="{name}"]'])
+ link = browser.find_by_css(f'.calibre-push-button[data-lid="{name}"]')
+ if not link:
+ raise ValueError('Library not found')
+
+ link.first.click()
+ functional.eventually(browser.find_by_css, [f'.book-list-cover-grid'])
+
+
+def _add_book(browser, library_name, book_name):
+ """Add a book to the library through Calibre interface."""
+ _visit_library(browser, library_name)
+ add_button = browser.find_by_css(f'a[data-button-icon="plus"]')
+ add_button.first.click()
+
+ functional.eventually(browser.find_by_xpath,
+ ['//span[contains(text(), "Add books")]'])
+ browser.execute_script(
+ '''document.querySelector('input[type="file"]').setAttribute(
+ 'name', 'test-book-upload');''')
+
+ file_path = pathlib.Path(__file__).parent / f'data/{book_name}'
+ browser.attach_file('test-book-upload', [str(file_path)])
+ functional.eventually(browser.find_by_xpath,
+ ['//span[contains(text(), "Added successfully")]'])
+
+
+def _delete_book(browser, library_name, book_name, ignore_missing=False):
+ """Delete a book from the library through Calibre interface."""
+ _visit_library(browser, library_name)
+ book_name = book_name.partition('.')[0]
+ book = browser.find_by_xpath(f'//a[contains(@title, "{book_name}")]')
+ if not book:
+ if ignore_missing:
+ return
+
+ raise Exception('Book not found')
+
+ book.first.click()
+ delete_button = browser.find_by_css(f'a[data-button-icon="trash"]')
+ delete_button.first.click()
+
+ dialog = browser.find_by_id('modal-container').first
+ functional.eventually(lambda: dialog.visible)
+ ok_button = browser.find_by_xpath('//span[contains(text(), "OK")]')
+ ok_button.first.click()
+
+
+def _is_book_available(browser, library_name, book_name):
+ """Return whether a book is present in Calibre interface."""
+ _visit_library(browser, library_name)
+ book_name = book_name.partition('.')[0]
+ book = browser.find_by_xpath(f'//a[contains(@title, "{book_name}")]')
+ return bool(book)
diff --git a/plinth/modules/calibre/tests/test_views.py b/plinth/modules/calibre/tests/test_views.py
new file mode 100644
index 000000000..24ede010d
--- /dev/null
+++ b/plinth/modules/calibre/tests/test_views.py
@@ -0,0 +1,146 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Tests for calibre views.
+"""
+
+from unittest.mock import call, patch
+
+import pytest
+from django import urls
+from django.contrib.messages.storage.fallback import FallbackStorage
+from django.http.response import Http404
+
+from plinth import actions, module_loader
+from plinth.modules.calibre import views
+
+# For all tests, use plinth.urls instead of urls configured for testing
+pytestmark = pytest.mark.urls('plinth.urls')
+
+
+@pytest.fixture(autouse=True, scope='module')
+def fixture_calibre_urls():
+ """Make sure calibre app's URLs are part of plinth.urls."""
+ with patch('plinth.module_loader._modules_to_load', new=[]) as modules, \
+ patch('plinth.urls.urlpatterns', new=[]):
+ modules.append('plinth.modules.calibre')
+ module_loader.include_urls()
+ yield
+
+
+@pytest.fixture(autouse=True)
+def calibre_patch():
+ """Patch calibre methods."""
+ with patch('plinth.modules.calibre.list_libraries') as list_libraries:
+ list_libraries.return_value = ['TestExistingLibrary']
+
+ yield
+
+
+def make_request(request, view, **kwargs):
+ """Make request with a message storage."""
+ setattr(request, 'session', 'session')
+ messages = FallbackStorage(request)
+ setattr(request, '_messages', messages)
+ response = view(request, **kwargs)
+
+ return response, messages
+
+
+@patch('plinth.modules.calibre.create_library')
+def test_create_library(create_library, rf):
+ """Test that create library view works."""
+ form_data = {'calibre-name': 'TestLibrary'}
+ request = rf.post(urls.reverse('calibre:create-library'), data=form_data)
+ view = views.CreateLibraryView.as_view()
+ response, messages = make_request(request, view)
+
+ assert response.status_code == 302
+ assert response.url == urls.reverse('calibre:index')
+ assert list(messages)[0].message == 'Library created.'
+ create_library.assert_has_calls([call('TestLibrary')])
+
+
+@patch('plinth.modules.calibre.create_library')
+def test_create_library_failed(create_library, rf):
+ """Test that create library fails as expected."""
+ create_library.side_effect = actions.ActionError('calibre', 'TestOutput',
+ 'TestError')
+ form_data = {'calibre-name': 'TestLibrary'}
+ request = rf.post(urls.reverse('calibre:create-library'), data=form_data)
+ view = views.CreateLibraryView.as_view()
+ response, messages = make_request(request, view)
+
+ assert response.status_code == 302
+ assert response.url == urls.reverse('calibre:index')
+ assert list(messages)[0].message == \
+ 'An error occurred while creating the library. TestError'
+
+
+def test_create_library_existing_library(rf):
+ """Test that create library errors out for an existing library name."""
+ form_data = {'calibre-name': 'TestExistingLibrary'}
+ request = rf.post(urls.reverse('calibre:create-library'), data=form_data)
+ view = views.CreateLibraryView.as_view()
+ response, _ = make_request(request, view)
+
+ assert response.context_data['form'].errors['name'][
+ 0] == 'A library with this name already exists.'
+ assert response.status_code == 200
+
+
+def test_create_library_invalid_name(rf):
+ """Test that create library errors out for invalid name."""
+ form_data = {'calibre-name': 'Invalid Library'}
+ request = rf.post(urls.reverse('calibre:create-library'), data=form_data)
+ view = views.CreateLibraryView.as_view()
+ response, _ = make_request(request, view)
+
+ assert response.context_data['form'].errors['name'][
+ 0] == 'Enter a valid value.'
+ assert response.status_code == 200
+
+
+@patch('plinth.modules.calibre.app')
+def test_delete_library_confirmation_view(_app, rf):
+ """Test that deleting library confirmation shows correct name."""
+ response, _ = make_request(rf.get(''), views.delete_library,
+ name='TestExistingLibrary')
+ assert response.status_code == 200
+ assert response.context_data['name'] == 'TestExistingLibrary'
+
+
+@patch('plinth.modules.calibre.delete_library')
+@patch('plinth.modules.calibre.app')
+def test_delete_library(_app, delete_library, rf):
+ """Test that deleting a library works."""
+ response, messages = make_request(rf.post(''), views.delete_library,
+ name='TestExistingLibrary')
+ assert response.status_code == 302
+ assert response.url == urls.reverse('calibre:index')
+ assert list(messages)[0].message == 'TestExistingLibrary deleted.'
+ delete_library.assert_has_calls([call('TestExistingLibrary')])
+
+
+@patch('plinth.modules.calibre.delete_library')
+def test_delete_library_error(delete_library, rf):
+ """Test that deleting a library shows error when operation fails."""
+ delete_library.side_effect = actions.ActionError('calibre', 'TestInput',
+ 'TestError')
+ response, messages = make_request(rf.post(''), views.delete_library,
+ name='TestExistingLibrary')
+ assert response.status_code == 302
+ assert response.url == urls.reverse('calibre:index')
+ assert list(messages)[0].message == \
+ 'Could not delete TestExistingLibrary: '\
+ "('calibre', 'TestInput', 'TestError')"
+
+
+def test_delete_library_non_existing(rf):
+ """Test that deleting a library shows error when operation fails."""
+ with pytest.raises(Http404):
+ make_request(rf.post(''), views.delete_library,
+ name='TestNonExistingLibrary')
+
+ with pytest.raises(Http404):
+ make_request(rf.get(''), views.delete_library,
+ name='TestNonExistingLibrary')
diff --git a/plinth/modules/calibre/urls.py b/plinth/modules/calibre/urls.py
new file mode 100644
index 000000000..a4cb694e3
--- /dev/null
+++ b/plinth/modules/calibre/urls.py
@@ -0,0 +1,16 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+URLs for the calibre module.
+"""
+
+from django.conf.urls import url
+
+from . import views
+
+urlpatterns = [
+ url(r'^apps/calibre/$', views.CalibreAppView.as_view(), name='index'),
+ url(r'^apps/calibre/library/create/$', views.CreateLibraryView.as_view(),
+ name='create-library'),
+ url(r'^apps/calibre/library/(?P[a-zA-Z0-9_.-]+)/delete/$',
+ views.delete_library, name='delete-library'),
+]
diff --git a/plinth/modules/calibre/views.py b/plinth/modules/calibre/views.py
new file mode 100644
index 000000000..f969de9d5
--- /dev/null
+++ b/plinth/modules/calibre/views.py
@@ -0,0 +1,75 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""
+Views for the calibre module.
+"""
+
+from django.contrib import messages
+from django.contrib.messages.views import SuccessMessageMixin
+from django.http import Http404
+from django.shortcuts import redirect
+from django.template.response import TemplateResponse
+from django.urls import reverse_lazy
+from django.utils.translation import ugettext as _
+from django.views.generic.edit import FormView
+
+from plinth import actions, views
+from plinth.modules import calibre
+
+from . import forms
+
+
+class CalibreAppView(views.AppView):
+ """Serve configuration form."""
+ app_id = 'calibre'
+ template_name = 'calibre.html'
+
+ def get_context_data(self, **kwargs):
+ """Return additional context for rendering the template."""
+ context = super().get_context_data(**kwargs)
+ context['libraries'] = calibre.list_libraries()
+ return context
+
+
+class CreateLibraryView(SuccessMessageMixin, FormView):
+ """View to create an empty library."""
+ form_class = forms.CreateLibraryForm
+ prefix = 'calibre'
+ template_name = 'form.html'
+ success_url = reverse_lazy('calibre:index')
+ success_message = _('Library created.')
+
+ def form_valid(self, form):
+ """Create the library on valid form submission."""
+ try:
+ calibre.create_library(form.cleaned_data['name'])
+ except actions.ActionError as error:
+ self.success_message = ''
+ error_text = error.args[2].split('\n')[0]
+ messages.error(
+ self.request, "{0} {1}".format(
+ _('An error occurred while creating the library.'),
+ error_text))
+
+ return super().form_valid(form)
+
+
+def delete_library(request, name):
+ """View to delete a library."""
+ if name not in calibre.list_libraries():
+ raise Http404
+
+ if request.method == 'POST':
+ try:
+ calibre.delete_library(name)
+ messages.success(request, _('{name} deleted.').format(name=name))
+ except actions.ActionError as error:
+ messages.error(
+ request,
+ _('Could not delete {name}: {error}').format(
+ name=name, error=error))
+ return redirect(reverse_lazy('calibre:index'))
+
+ return TemplateResponse(request, 'calibre-delete-library.html', {
+ 'title': calibre.app.info.name,
+ 'name': name
+ })
diff --git a/plinth/templates/form.html b/plinth/templates/form.html
new file mode 100644
index 000000000..ae21f928a
--- /dev/null
+++ b/plinth/templates/form.html
@@ -0,0 +1,22 @@
+{% extends "base.html" %}
+{% comment %}
+# SPDX-License-Identifier: AGPL-3.0-or-later
+{% endcomment %}
+
+{% load bootstrap %}
+{% load i18n %}
+
+{% block content %}
+
+ {{ title }}
+
+
+
+{% endblock %}
diff --git a/pytest.ini b/pytest.ini
index e95adc2ab..cad6c2306 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -4,6 +4,7 @@ markers = functional
apps
backups
bind
+ calibre
configuration
date_and_time
deluge
diff --git a/static/themes/default/icons/calibre.png b/static/themes/default/icons/calibre.png
new file mode 100644
index 000000000..12e7f1067
Binary files /dev/null and b/static/themes/default/icons/calibre.png differ
diff --git a/static/themes/default/icons/calibre.svg b/static/themes/default/icons/calibre.svg
new file mode 100644
index 000000000..a433020da
--- /dev/null
+++ b/static/themes/default/icons/calibre.svg
@@ -0,0 +1,1139 @@
+
+
+
+