Jacek Kowalski
4 hours ago 6b9278a5d57091fce636dbb640b380cfb990db6c
Books version 2.0
85 files deleted
129 files added
17544 ■■■■■ changed files
.env 45 ●●●●● patch | view | raw | blame | history
.env.dev 4 ●●●●● patch | view | raw | blame | history
.env.test 3 ●●●●● patch | view | raw | blame | history
.gitignore 21 ●●●●● patch | view | raw | blame | history
BUILD 19 ●●●●● patch | view | raw | blame | history
CHANGELOG 90 ●●●●● patch | view | raw | blame | history
HEADER 11 ●●●●● patch | view | raw | blame | history
INSTALL 8 ●●●●● patch | view | raw | blame | history
LICENSE 28 ●●●●● patch | view | raw | blame | history
README.md 41 ●●●●● patch | view | raw | blame | history
UPGRADE 26 ●●●●● patch | view | raw | blame | history
add.php 84 ●●●●● patch | view | raw | blame | history
add_book.php 22 ●●●●● patch | view | raw | blame | history
add_file.php 25 ●●●●● patch | view | raw | blame | history
add_isbn.php 31 ●●●●● patch | view | raw | blame | history
add_search.php 24 ●●●●● patch | view | raw | blame | history
add_similar.php 22 ●●●●● patch | view | raw | blame | history
biblioteka.sql 40 ●●●●● patch | view | raw | blame | history
bin/console 22 ●●●●● patch | view | raw | blame | history
bin/phpunit 5 ●●●●● patch | view | raw | blame | history
borrow_back.php 21 ●●●●● patch | view | raw | blame | history
borrow_book.php 21 ●●●●● patch | view | raw | blame | history
borrow_history.php 13 ●●●●● patch | view | raw | blame | history
composer.json 78 ●●●●● patch | view | raw | blame | history
config/bundles.php 10 ●●●●● patch | view | raw | blame | history
config/packages/cache.yaml 19 ●●●●● patch | view | raw | blame | history
config/packages/csrf.yaml 7 ●●●●● patch | view | raw | blame | history
config/packages/doctrine.yaml 47 ●●●●● patch | view | raw | blame | history
config/packages/doctrine_migrations.yaml 6 ●●●●● patch | view | raw | blame | history
config/packages/framework.yaml 24 ●●●●● patch | view | raw | blame | history
config/packages/knp_paginator.yaml 7 ●●●●● patch | view | raw | blame | history
config/packages/property_info.yaml 3 ●●●●● patch | view | raw | blame | history
config/packages/routing.yaml 10 ●●●●● patch | view | raw | blame | history
config/packages/translation.yaml 8 ●●●●● patch | view | raw | blame | history
config/packages/twig.yaml 7 ●●●●● patch | view | raw | blame | history
config/packages/validator.yaml 11 ●●●●● patch | view | raw | blame | history
config/preload.php 6 ●●●●● patch | view | raw | blame | history
config/routes.yaml 11 ●●●●● patch | view | raw | blame | history
config/routes/framework.yaml 4 ●●●●● patch | view | raw | blame | history
config/services.yaml 36 ●●●●● patch | view | raw | blame | history
cover.php 33 ●●●●● patch | view | raw | blame | history
covers/own/.emptydir patch | view | raw | blame | history
covers_big/own/.emptydir patch | view | raw | blame | history
del.php 30 ●●●●● patch | view | raw | blame | history
del_book.php 14 ●●●●● patch | view | raw | blame | history
design/bottom.php 7 ●●●●● patch | view | raw | blame | history
design/script.js 85 ●●●●● patch | view | raw | blame | history
design/style.css 105 ●●●●● patch | view | raw | blame | history
design/top.php 35 ●●●●● patch | view | raw | blame | history
edit.php 47 ●●●●● patch | view | raw | blame | history
edit_book.php 18 ●●●●● patch | view | raw | blame | history
generate.php 100 ●●●●● patch | view | raw | blame | history
generate39_list.php 53 ●●●●● patch | view | raw | blame | history
generate_list.php 71 ●●●●● patch | view | raw | blame | history
generate_page.php 50 ●●●●● patch | view | raw | blame | history
help/db2_php.png patch | view | raw | blame | history
help/db2_php.xcf patch | view | raw | blame | history
help/db2_php2.png patch | view | raw | blame | history
help/db2_php3.png patch | view | raw | blame | history
help/pma1.png patch | view | raw | blame | history
help/pma1.xcf patch | view | raw | blame | history
help/pma2.png patch | view | raw | blame | history
help/pma2.xcf patch | view | raw | blame | history
help/podrecznik.odt patch | view | raw | blame | history
help/podrecznik_orig.pdf patch | view | raw | blame | history
includes/MARC21.php 186 ●●●●● patch | view | raw | blame | history
includes/MARC21_opisy.php 30 ●●●●● patch | view | raw | blame | history
includes/PDOO.php 24 ●●●●● patch | view | raw | blame | history
includes/YAZ.php 83 ●●●●● patch | view | raw | blame | history
includes/YAZ_ibd.php 69 ●●●●● patch | view | raw | blame | history
includes/checksum.php 52 ●●●●● patch | view | raw | blame | history
includes/config.php 19 ●●●●● patch | view | raw | blame | history
includes/convert.php 41 ●●●●● patch | view | raw | blame | history
includes/db2.php 411 ●●●●● patch | view | raw | blame | history
includes/generate_codabar.php 68 ●●●●● patch | view | raw | blame | history
includes/generate_code39.php 100 ●●●●● patch | view | raw | blame | history
includes/generate_html.php 67 ●●●●● patch | view | raw | blame | history
includes/gotowe.php 340 ●●●●● patch | view | raw | blame | history
includes/ibd.php 42 ●●●●● patch | view | raw | blame | history
includes/ibd_BN.php 103 ●●●●● patch | view | raw | blame | history
includes/ksiazki.php 260 ●●●●● patch | view | raw | blame | history
includes/okladki.php 181 ●●●●● patch | view | raw | blame | history
includes/pozycz.php 32 ●●●●● patch | view | raw | blame | history
includes/std.php 25 ●●●●● patch | view | raw | blame | history
includes/validate.php 127 ●●●●● patch | view | raw | blame | history
index.php 48 ●●●●● patch | view | raw | blame | history
info.php 10 ●●●●● patch | view | raw | blame | history
inwentaryzacja/begin.php 28 ●●●●● patch | view | raw | blame | history
inwentaryzacja/design/bottom.php 7 ●●●●● patch | view | raw | blame | history
inwentaryzacja/design/script.js 85 ●●●●● patch | view | raw | blame | history
inwentaryzacja/design/style.css 105 ●●●●● patch | view | raw | blame | history
inwentaryzacja/design/top.php 35 ●●●●● patch | view | raw | blame | history
inwentaryzacja/end.php 76 ●●●●● patch | view | raw | blame | history
inwentaryzacja/error.wav patch | view | raw | blame | history
inwentaryzacja/finish.php 52 ●●●●● patch | view | raw | blame | history
inwentaryzacja/index.php 9 ●●●●● patch | view | raw | blame | history
inwentaryzacja/locate.css 65 ●●●●● patch | view | raw | blame | history
inwentaryzacja/locate.htm 28 ●●●●● patch | view | raw | blame | history
inwentaryzacja/locate.js 359 ●●●●● patch | view | raw | blame | history
inwentaryzacja/make.php 87 ●●●●● patch | view | raw | blame | history
inwentaryzacja/save.php 35 ●●●●● patch | view | raw | blame | history
list_all.php 10 ●●●●● patch | view | raw | blame | history
list_borrowed.php 11 ●●●●● patch | view | raw | blame | history
list_repulsed.php 11 ●●●●● patch | view | raw | blame | history
migrations/Version20261106120000.php 110 ●●●●● patch | view | raw | blame | history
migrations/Version20261113120000.php 31 ●●●●● patch | view | raw | blame | history
phpunit.dist.xml 48 ●●●●● patch | view | raw | blame | history
place.php 50 ●●●●● patch | view | raw | blame | history
place_change.php 29 ●●●●● patch | view | raw | blame | history
place_set.php 44 ●●●●● patch | view | raw | blame | history
public/css/custom.css 82 ●●●●● patch | view | raw | blame | history
public/css/inventory.css 39 ●●●●● patch | view | raw | blame | history
public/css/spectre 1 ●●●●● patch | view | raw | blame | history
public/css/spectre-0.5.9/spectre-exp.css 1231 ●●●●● patch | view | raw | blame | history
public/css/spectre-0.5.9/spectre-exp.min.css 1 ●●●●● patch | view | raw | blame | history
public/css/spectre-0.5.9/spectre-icons.css 597 ●●●●● patch | view | raw | blame | history
public/css/spectre-0.5.9/spectre-icons.min.css 1 ●●●●● patch | view | raw | blame | history
public/css/spectre-0.5.9/spectre.css 3760 ●●●●● patch | view | raw | blame | history
public/css/spectre-0.5.9/spectre.min.css 1 ●●●●● patch | view | raw | blame | history
public/index.php 9 ●●●●● patch | view | raw | blame | history
public/js/inventory.js 596 ●●●●● patch | view | raw | blame | history
search.php 14 ●●●●● patch | view | raw | blame | history
src/Command/ImportLegacyCommand.php 109 ●●●●● patch | view | raw | blame | history
src/Controller/BookController.php 110 ●●●●● patch | view | raw | blame | history
src/Controller/BorrowController.php 54 ●●●●● patch | view | raw | blame | history
src/Controller/CoverController.php 64 ●●●●● patch | view | raw | blame | history
src/Controller/InventoryController.php 100 ●●●●● patch | view | raw | blame | history
src/Controller/LabelController.php 49 ●●●●● patch | view | raw | blame | history
src/Controller/LookupController.php 42 ●●●●● patch | view | raw | blame | history
src/Controller/OustedBookController.php 120 ●●●●● patch | view | raw | blame | history
src/Controller/SearchController.php 34 ●●●●● patch | view | raw | blame | history
src/Entity/AbstractBook.php 306 ●●●●● patch | view | raw | blame | history
src/Entity/AbstractBorrowHistory.php 49 ●●●●● patch | view | raw | blame | history
src/Entity/AbstractLabel.php 50 ●●●●● patch | view | raw | blame | history
src/Entity/Book.php 85 ●●●●● patch | view | raw | blame | history
src/Entity/Borrow.php 39 ●●●●● patch | view | raw | blame | history
src/Entity/BorrowHistory.php 24 ●●●●● patch | view | raw | blame | history
src/Entity/Label.php 26 ●●●●● patch | view | raw | blame | history
src/Entity/OustedBook.php 71 ●●●●● patch | view | raw | blame | history
src/Entity/OustedBorrowHistory.php 24 ●●●●● patch | view | raw | blame | history
src/Entity/OustedLabel.php 25 ●●●●● patch | view | raw | blame | history
src/Enum/LabelType.php 35 ●●●●● patch | view | raw | blame | history
src/Form/AddLabelType.php 52 ●●●●● patch | view | raw | blame | history
src/Form/BookSearchType.php 35 ●●●●● patch | view | raw | blame | history
src/Form/BookType.php 41 ●●●●● patch | view | raw | blame | history
src/Form/BorrowType.php 25 ●●●●● patch | view | raw | blame | history
src/Form/BulkOustType.php 48 ●●●●● patch | view | raw | blame | history
src/Form/CoverType.php 38 ●●●●● patch | view | raw | blame | history
src/Form/OustType.php 25 ●●●●● patch | view | raw | blame | history
src/Kernel.php 11 ●●●●● patch | view | raw | blame | history
src/LegacyImport/CoverImportReport.php 31 ●●●●● patch | view | raw | blame | history
src/LegacyImport/CoverImporter.php 221 ●●●●● patch | view | raw | blame | history
src/LegacyImport/ImportReport.php 21 ●●●●● patch | view | raw | blame | history
src/LegacyImport/LegacyImporter.php 278 ●●●●● patch | view | raw | blame | history
src/Lookup/BookCandidate.php 18 ●●●●● patch | view | raw | blame | history
src/Lookup/BookLookupInterface.php 18 ●●●●● patch | view | raw | blame | history
src/Lookup/LookupFailedException.php 7 ●●●●● patch | view | raw | blame | history
src/Lookup/NationalLibraryLookup.php 86 ●●●●● patch | view | raw | blame | history
src/Model/BookSearch.php 15 ●●●●● patch | view | raw | blame | history
src/Model/Isbn.php 48 ●●●●● patch | view | raw | blame | history
src/Repository/BookRepository.php 170 ●●●●● patch | view | raw | blame | history
src/Repository/BorrowHistoryRepository.php 17 ●●●●● patch | view | raw | blame | history
src/Repository/BorrowRepository.php 25 ●●●●● patch | view | raw | blame | history
src/Repository/LabelRepository.php 33 ●●●●● patch | view | raw | blame | history
src/Repository/OustedBookRepository.php 22 ●●●●● patch | view | raw | blame | history
src/Repository/OustedBorrowHistoryRepository.php 17 ●●●●● patch | view | raw | blame | history
src/Repository/OustedLabelRepository.php 17 ●●●●● patch | view | raw | blame | history
src/Service/CoverProcessingException.php 7 ●●●●● patch | view | raw | blame | history
src/Service/CoverStorage.php 76 ●●●●● patch | view | raw | blame | history
src/Service/CoverThumbnailer.php 82 ●●●●● patch | view | raw | blame | history
src/Service/Library.php 132 ●●●●● patch | view | raw | blame | history
src/Service/LibraryThingCovers.php 70 ●●●●● patch | view | raw | blame | history
src/Validator/AvailableBookId.php 20 ●●●●● patch | view | raw | blame | history
src/Validator/AvailableBookIdValidator.php 32 ●●●●● patch | view | raw | blame | history
sync.php 17 ●●●●● patch | view | raw | blame | history
templates/base.html.twig 57 ●●●●● patch | view | raw | blame | history
templates/book/_cover.html.twig 3 ●●●●● patch | view | raw | blame | history
templates/book/_details.html.twig 42 ●●●●● patch | view | raw | blame | history
templates/book/_history.html.twig 29 ●●●●● patch | view | raw | blame | history
templates/book/_results.html.twig 7 ●●●●● patch | view | raw | blame | history
templates/book/_tile.html.twig 35 ●●●●● patch | view | raw | blame | history
templates/book/add.html.twig 100 ●●●●● patch | view | raw | blame | history
templates/book/edit.html.twig 13 ●●●●● patch | view | raw | blame | history
templates/book/list.html.twig 27 ●●●●● patch | view | raw | blame | history
templates/book/lookup.html.twig 49 ●●●●● patch | view | raw | blame | history
templates/book/new.html.twig 13 ●●●●● patch | view | raw | blame | history
templates/book/show.html.twig 106 ●●●●● patch | view | raw | blame | history
templates/borrow/list.html.twig 27 ●●●●● patch | view | raw | blame | history
templates/inventory/index.html.twig 96 ●●●●● patch | view | raw | blame | history
templates/inventory/sw.js.twig 46 ●●●●● patch | view | raw | blame | history
templates/ousted/bulk.html.twig 57 ●●●●● patch | view | raw | blame | history
templates/ousted/list.html.twig 31 ●●●●● patch | view | raw | blame | history
templates/ousted/show.html.twig 55 ●●●●● patch | view | raw | blame | history
templates/search/index.html.twig 39 ●●●●● patch | view | raw | blame | history
templates/spectre/form.html.twig 73 ●●●●● patch | view | raw | blame | history
templates/spectre/knp_paginator_pagination.html.twig 31 ●●●●● patch | view | raw | blame | history
test.php 146 ●●●●● patch | view | raw | blame | history
tests/DatabaseTestCase.php 42 ●●●●● patch | view | raw | blame | history
tests/Fixtures/rotated-orientation-6.jpg patch | view | raw | blame | history
tests/Functional/BookTest.php 144 ●●●●● patch | view | raw | blame | history
tests/Functional/BulkOustTest.php 95 ●●●●● patch | view | raw | blame | history
tests/Functional/CoverTest.php 174 ●●●●● patch | view | raw | blame | history
tests/Functional/ImportLegacyCommandTest.php 209 ●●●●● patch | view | raw | blame | history
tests/Functional/ImportLegacyCoversTest.php 169 ●●●●● patch | view | raw | blame | history
tests/Functional/InventoryTest.php 139 ●●●●● patch | view | raw | blame | history
tests/Functional/LibraryThingCoverTest.php 116 ●●●●● patch | view | raw | blame | history
tests/Functional/LoanTest.php 116 ●●●●● patch | view | raw | blame | history
tests/Functional/LookupTest.php 60 ●●●●● patch | view | raw | blame | history
tests/Functional/SearchTest.php 65 ●●●●● patch | view | raw | blame | history
tests/LegacyDatabase.php 24 ●●●●● patch | view | raw | blame | history
tests/Unit/IsbnTest.php 39 ●●●●● patch | view | raw | blame | history
tests/bootstrap.php 14 ●●●●● patch | view | raw | blame | history
translations/messages.en.yaml 176 ●●●●● patch | view | raw | blame | history
translations/messages.pl.yaml 176 ●●●●● patch | view | raw | blame | history
.env
New file
@@ -0,0 +1,45 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
#  * .env                contains default values for the environment variables needed by the app
#  * .env.local          uncommitted file with local overrides
#  * .env.$APP_ENV       committed environment-specific defaults
#  * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# SQLite is the development default. MySQL, MariaDB and PostgreSQL are supported too;
# for those, state the server version in the URL:
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=mariadb-11.8.6&charset=utf8mb4"
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
###< doctrine/doctrine-bundle ###
# Database holding the legacy tables (ksiazki, pozycz, pozycz_historia), read only by
# "bin/console app:import-legacy".
LEGACY_DATABASE_URL="mysql://books:books123@127.0.0.1:3306/books?serverVersion=mariadb-11.8.6&charset=utf8mb4"
# Key for the LibraryThing covers API (https://www.librarything.com/services/keys.php). When set,
# a book added with an ISBN gets its cover from LibraryThing. Put the key in .env.local.
LIBRARYTHING_API_KEY=
.env.dev
New file
@@ -0,0 +1,4 @@
###> symfony/framework-bundle ###
APP_SECRET=dfe52648fee5c8f57d208278d991605a
###< symfony/framework-bundle ###
.env.test
New file
@@ -0,0 +1,3 @@
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
LEGACY_DATABASE_URL="sqlite:///%kernel.project_dir%/var/legacy_test.db"
.gitignore
New file
@@ -0,0 +1,21 @@
composer.phar
.composer
.idea
nbproject
/config/reference.php
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/public/covers/
/var/
/vendor/
###< symfony/framework-bundle ###
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###
BUILD
File was deleted
CHANGELOG
File was deleted
HEADER
File was deleted
INSTALL
File was deleted
LICENSE
New file
@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, Jacek Kowalski
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
   list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
   this list of conditions and the following disclaimer in the documentation
   and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
   contributors may be used to endorse or promote products derived from
   this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
README.md
New file
@@ -0,0 +1,41 @@
# Book Shelf
Home book library tracker.
Rebuilt using Symfony 8.1, it runs on SQLite, MySQL and PostgreSQL.
## Setup
Requires PHP 8.4 or newer with the GD, Exif and PDO extensions.
```bash
composer install
php bin/console doctrine:migrations:migrate
php -S 127.0.0.1:8080 -t public
```
Defaults to using SQLite in var directory.
To use another database, set `DATABASE_URL` in `.env.local`.
See `.env` for database URL fomats.
## LibraryThing covers
With a [LibraryThing developer key](https://www.librarything.com/services/keys.php) in `.env.local`,
a book added with an ISBN gets its cover from LibraryThing:
```bash
LIBRARYTHING_API_KEY=your-key
```
## Upgrading the old system
`app:import-legacy` copies the data from legacy tables into the current schema.
Additionally covers can be imported from the `covers_big` directory.
Data is read from MySQL at `LEGACY_DATABASE_URL` set in `.env.local`.
```bash
php bin/console doctrine:migrations:migrate
php bin/console app:import-legacy --dry-run
php bin/console app:import-legacy --covers=/path/to/covers_big --dry-run
php bin/console app:import-legacy
php bin/console app:import-legacy --covers=/path/to/covers_big
```
UPGRADE
File was deleted
add.php
File was deleted
add_book.php
File was deleted
add_file.php
File was deleted
add_isbn.php
File was deleted
add_search.php
File was deleted
add_similar.php
File was deleted
biblioteka.sql
File was deleted
bin/console
New file
@@ -0,0 +1,22 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__) . '/vendor')) {
    throw new LogicException('Dependencies are missing. Try running "composer install".');
}
if (!is_file(dirname(__DIR__) . '/vendor/autoload_runtime.php')) {
    throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';
return function (array $context) {
    $kernel = new Kernel($context['APP_ENV'], (bool)$context['APP_DEBUG']);
    return new Application($kernel);
};
bin/phpunit
New file
@@ -0,0 +1,5 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
borrow_back.php
File was deleted
borrow_book.php
File was deleted
borrow_history.php
File was deleted
composer.json
New file
@@ -0,0 +1,78 @@
{
    "type": "project",
    "license": "proprietary",
    "minimum-stability": "stable",
    "prefer-stable": true,
    "require": {
        "php": ">=8.4",
        "ext-ctype": "*",
        "ext-exif": "*",
        "ext-gd": "*",
        "ext-iconv": "*",
        "doctrine/doctrine-bundle": "^3.4.1",
        "doctrine/doctrine-migrations-bundle": "^4.0.1",
        "doctrine/orm": "^3.7.6",
        "knplabs/knp-paginator-bundle": "^6.11",
        "symfony/asset": "8.1.*",
        "symfony/console": "8.1.*",
        "symfony/dotenv": "8.1.*",
        "symfony/flex": "^2.12",
        "symfony/form": "8.1.*",
        "symfony/framework-bundle": "8.1.*",
        "symfony/http-client": "8.1.*",
        "symfony/mime": "8.1.*",
        "symfony/runtime": "8.1.*",
        "symfony/security-csrf": "8.1.*",
        "symfony/translation": "8.1.*",
        "symfony/twig-bundle": "8.1.*",
        "symfony/validator": "8.1.*",
        "symfony/yaml": "8.1.*"
    },
    "config": {
        "allow-plugins": {
            "php-http/discovery": true,
            "symfony/flex": true,
            "symfony/runtime": true
        },
        "bump-after-update": true,
        "sort-packages": true
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "App\\Tests\\": "tests/"
        }
    },
    "scripts": {
        "auto-scripts": {
            "cache:clear": "symfony-cmd",
            "assets:install %PUBLIC_DIR%": "symfony-cmd"
        },
        "post-install-cmd": [
            "@auto-scripts"
        ],
        "post-update-cmd": [
            "@auto-scripts"
        ]
    },
    "conflict": {
        "symfony/symfony": "*"
    },
    "extra": {
        "symfony": {
            "allow-contrib": false,
            "require": "8.1.*"
        }
    },
    "require-dev": {
        "phpunit/phpunit": "^13.4.7",
        "symfony/browser-kit": "8.1.*",
        "symfony/css-selector": "8.1.*",
        "symfony/maker-bundle": "^1.68",
        "symfony/phpunit-bridge": "^8.1.10"
    }
}
config/bundles.php
New file
@@ -0,0 +1,10 @@
<?php
return [
    Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
    Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
    Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
    Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
    Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
    Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
];
config/packages/cache.yaml
New file
@@ -0,0 +1,19 @@
framework:
    cache:
        # Unique name of your app: used to compute stable namespaces for cache keys.
        #prefix_seed: your_vendor_name/app_name
        # The "app" cache stores to the filesystem by default.
        # The data in this cache should persist between deploys.
        # Other options include:
        # Redis
        #app: cache.adapter.redis
        #default_redis_provider: redis://localhost
        # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
        #app: cache.adapter.apcu
        # Namespaced pools use the above "app" backend by default
        #pools:
            #my.dedicated.cache: null
config/packages/csrf.yaml
New file
@@ -0,0 +1,7 @@
# Session-backed tokens: the app has no JavaScript for the stateless double-submit flow.
framework:
    form:
        csrf_protection:
            token_id: submit
    csrf_protection: true
config/packages/doctrine.yaml
New file
@@ -0,0 +1,47 @@
doctrine:
    dbal:
        default_connection: default
        connections:
            default:
                url: '%env(resolve:DATABASE_URL)%'
                # The legacy tables may share this database; keep them out of schema tooling.
                schema_filter: ~^(?!(ksiazki|pozycz|pozycz_historia)$)~
                profiling_collect_backtrace: '%kernel.debug%'
            legacy:
                url: '%env(resolve:LEGACY_DATABASE_URL)%'
    orm:
        default_entity_manager: default
        entity_managers:
            default:
                connection: default
                validate_xml_mapping: true
                naming_strategy: doctrine.orm.naming_strategy.underscore
                identity_generation_preferences:
                    Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
                auto_mapping: true
                mappings:
                    App:
                        type: attribute
                        is_bundle: false
                        dir: '%kernel.project_dir%/src/Entity'
                        prefix: 'App\Entity'
when@prod:
    doctrine:
        orm:
            entity_managers:
                default:
                    query_cache_driver:
                        type: pool
                        pool: doctrine.system_cache_pool
                    result_cache_driver:
                        type: pool
                        pool: doctrine.result_cache_pool
    framework:
        cache:
            pools:
                doctrine.result_cache_pool:
                    adapter: cache.app
                doctrine.system_cache_pool:
                    adapter: cache.system
config/packages/doctrine_migrations.yaml
New file
@@ -0,0 +1,6 @@
doctrine_migrations:
    migrations_paths:
        # namespace is arbitrary but should be different from App\Migrations
        # as migrations classes should NOT be autoloaded
        'DoctrineMigrations': '%kernel.project_dir%/migrations'
    enable_profiler: false
config/packages/framework.yaml
New file
@@ -0,0 +1,24 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
    secret: '%env(APP_SECRET)%'
    # Note that the session will be started ONLY if you read or write from it.
    session: true
    #esi: true
    #fragments: true
    http_client:
        scoped_clients:
            bn.client:
                base_uri: 'https://data.bn.org.pl'
                timeout: 10
            librarything.client:
                base_uri: 'https://covers.librarything.com'
                timeout: 5
when@test:
    framework:
        test: true
        session:
            storage_factory_id: session.storage.factory.mock_file
config/packages/knp_paginator.yaml
New file
@@ -0,0 +1,7 @@
knp_paginator:
    page_range: 10
    default_options:
        page_name: page
        distinct: false
    template:
        pagination: 'spectre/knp_paginator_pagination.html.twig'
config/packages/property_info.yaml
New file
@@ -0,0 +1,3 @@
framework:
    property_info:
        with_constructor_extractor: true
config/packages/routing.yaml
New file
@@ -0,0 +1,10 @@
framework:
    router:
        # Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
        # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
        default_uri: '%env(DEFAULT_URI)%'
when@prod:
    framework:
        router:
            strict_requirements: null
config/packages/translation.yaml
New file
@@ -0,0 +1,8 @@
framework:
    default_locale: en
    enabled_locales: [en, pl]
    set_locale_from_accept_language: true
    translator:
        default_path: '%kernel.project_dir%/translations'
        fallbacks:
            - en
config/packages/twig.yaml
New file
@@ -0,0 +1,7 @@
twig:
    file_name_pattern: '*.twig'
    form_themes: ['spectre/form.html.twig']
when@test:
    twig:
        strict_variables: true
config/packages/validator.yaml
New file
@@ -0,0 +1,11 @@
framework:
    validation:
        # Enables validator auto-mapping support.
        # For instance, basic validation constraints will be inferred from Doctrine's metadata.
        #auto_mapping:
        #    App\Entity\: []
when@test:
    framework:
        validation:
            not_compromised_password: false
config/preload.php
New file
@@ -0,0 +1,6 @@
<?php
declare(strict_types=1);
foreach (glob(dirname(__DIR__) . '/var/cache/prod/*.preload.php') ?: [] as $file) {
    require $file;
}
config/routes.yaml
New file
@@ -0,0 +1,11 @@
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
# This file is the entry point to configure the routes of your app.
# Methods with the #[Route] attribute are automatically imported.
# See also https://symfony.com/doc/current/routing.html
# To list all registered routes, run the following command:
#   bin/console debug:router
controllers:
    resource: routing.controllers
config/routes/framework.yaml
New file
@@ -0,0 +1,4 @@
when@dev:
    _errors:
        resource: '@FrameworkBundle/Resources/config/routing/errors.php'
        prefix: /_error
config/services.yaml
New file
@@ -0,0 +1,36 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# See also https://symfony.com/doc/current/service_container/import.html
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
    app.cover_originals_dir: '%kernel.project_dir%/var/covers'
    app.cover_thumbnails_dir: '%kernel.project_dir%/public/covers'
    app.librarything_api_key: '%env(default::LIBRARYTHING_API_KEY)%'
when@test:
    parameters:
        app.cover_originals_dir: '%kernel.project_dir%/var/test/covers/originals'
        app.cover_thumbnails_dir: '%kernel.project_dir%/var/test/covers/thumbnails'
services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
    # makes classes in src/ available to be used as services
    # this creates a service per class whose id is the fully-qualified class name
    App\:
        resource: '../src/'
        exclude:
            - '../src/Entity/'
            - '../src/Enum/'
            - '../src/Kernel.php'
            - '../src/Model/'
    # add more service definitions when explicit configuration is needed
    # please note that last definitions always *replace* previous ones
cover.php
File was deleted
covers/own/.emptydir
covers_big/own/.emptydir
del.php
File was deleted
del_book.php
File was deleted
design/bottom.php
File was deleted
design/script.js
File was deleted
design/style.css
File was deleted
design/top.php
File was deleted
edit.php
File was deleted
edit_book.php
File was deleted
generate.php
File was deleted
generate39_list.php
File was deleted
generate_list.php
File was deleted
generate_page.php
File was deleted
help/db2_php.png
Binary files differ
help/db2_php.xcf
Binary files differ
help/db2_php2.png
Binary files differ
help/db2_php3.png
Binary files differ
help/pma1.png
Binary files differ
help/pma1.xcf
Binary files differ
help/pma2.png
Binary files differ
help/pma2.xcf
Binary files differ
help/podrecznik.odt
Binary files differ
help/podrecznik_orig.pdf
Binary files differ
includes/MARC21.php
File was deleted
includes/MARC21_opisy.php
File was deleted
includes/PDOO.php
File was deleted
includes/YAZ.php
File was deleted
includes/YAZ_ibd.php
File was deleted
includes/checksum.php
File was deleted
includes/config.php
File was deleted
includes/convert.php
File was deleted
includes/db2.php
File was deleted
includes/generate_codabar.php
File was deleted
includes/generate_code39.php
File was deleted
includes/generate_html.php
File was deleted
includes/gotowe.php
File was deleted
includes/ibd.php
File was deleted
includes/ibd_BN.php
File was deleted
includes/ksiazki.php
File was deleted
includes/okladki.php
File was deleted
includes/pozycz.php
File was deleted
includes/std.php
File was deleted
includes/validate.php
File was deleted
index.php
File was deleted
info.php
File was deleted
inwentaryzacja/begin.php
File was deleted
inwentaryzacja/design/bottom.php
File was deleted
inwentaryzacja/design/script.js
File was deleted
inwentaryzacja/design/style.css
File was deleted
inwentaryzacja/design/top.php
File was deleted
inwentaryzacja/end.php
File was deleted
inwentaryzacja/error.wav
Binary files differ
inwentaryzacja/finish.php
File was deleted
inwentaryzacja/index.php
File was deleted
inwentaryzacja/locate.css
File was deleted
inwentaryzacja/locate.htm
File was deleted
inwentaryzacja/locate.js
File was deleted
inwentaryzacja/make.php
File was deleted
inwentaryzacja/save.php
File was deleted
list_all.php
File was deleted
list_borrowed.php
File was deleted
list_repulsed.php
File was deleted
migrations/Version20261106120000.php
New file
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\Table;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
/**
 * Baseline schema. It uses the Schema API rather than SQL so that the same migration runs on
 * SQLite, MySQL, MariaDB and PostgreSQL.
 */
final class Version20261106120000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Create the book, loan and label tables';
    }
    public function up(Schema $schema): void
    {
        $book = $schema->createTable('book');
        $this->addBookColumns($book);
        $book->setPrimaryKey(['id']);
        $oustedBook = $schema->createTable('ousted_book');
        $this->addBookColumns($oustedBook);
        $oustedBook->addColumn('reason', Types::STRING, ['length' => 255]);
        $oustedBook->setPrimaryKey(['id']);
        $borrow = $schema->createTable('borrow');
        $borrow->addColumn('book_id', Types::INTEGER);
        $borrow->addColumn('who', Types::STRING, ['length' => 255]);
        $borrow->addColumn('at', Types::DATETIME_IMMUTABLE);
        $borrow->setPrimaryKey(['book_id']);
        $borrow->addForeignKeyConstraint('book', ['book_id'], ['id'], ['deferrable' => false], 'FK_55DBA8B016A2B381');
        $history = $schema->createTable('borrow_history');
        $this->addHistoryColumns($history);
        $history->addUniqueIndex(['book_id', 'at'], 'borrow_history_book_at');
        $history->addIndex(['book_id'], 'IDX_794A76CF16A2B381');
        $history->addForeignKeyConstraint('book', ['book_id'], ['id'], ['deferrable' => false], 'FK_794A76CF16A2B381');
        $oustedHistory = $schema->createTable('ousted_borrow_history');
        $this->addHistoryColumns($oustedHistory);
        $oustedHistory->addUniqueIndex(['book_id', 'at'], 'ousted_borrow_history_book_at');
        $oustedHistory->addIndex(['book_id'], 'IDX_8BF39F5F16A2B381');
        $oustedHistory->addForeignKeyConstraint('ousted_book', ['book_id'], ['id'], ['deferrable' => false], 'FK_8BF39F5F16A2B381');
        $label = $schema->createTable('label');
        $this->addLabelColumns($label);
        $label->addUniqueIndex(['book_id', 'type', 'label'], 'label_book_type_label');
        $label->addIndex(['label'], 'label_label');
        $label->addIndex(['book_id'], 'IDX_EA750E816A2B381');
        $label->addForeignKeyConstraint('book', ['book_id'], ['id'], ['deferrable' => false], 'FK_EA750E816A2B381');
        $oustedLabel = $schema->createTable('ousted_label');
        $this->addLabelColumns($oustedLabel);
        $oustedLabel->addUniqueIndex(['book_id', 'type', 'label'], 'ousted_label_book_type_label');
        $oustedLabel->addIndex(['book_id'], 'IDX_CCF8BC1B16A2B381');
        $oustedLabel->addForeignKeyConstraint('ousted_book', ['book_id'], ['id'], ['deferrable' => false], 'FK_CCF8BC1B16A2B381');
    }
    public function down(Schema $schema): void
    {
        $schema->dropTable('ousted_label');
        $schema->dropTable('label');
        $schema->dropTable('ousted_borrow_history');
        $schema->dropTable('borrow_history');
        $schema->dropTable('borrow');
        $schema->dropTable('ousted_book');
        $schema->dropTable('book');
    }
    private function addBookColumns(Table $table): void
    {
        $table->addColumn('id', Types::INTEGER);
        $table->addColumn('title', Types::STRING, ['length' => 255]);
        $table->addColumn('author', Types::STRING, ['length' => 255]);
        $table->addColumn('language', Types::STRING, ['length' => 255, 'notnull' => false]);
        $table->addColumn('edition', Types::STRING, ['length' => 255, 'notnull' => false]);
        $table->addColumn('publisher', Types::STRING, ['length' => 255, 'notnull' => false]);
        $table->addColumn('publication_loc', Types::STRING, ['length' => 255, 'notnull' => false]);
        $table->addColumn('publication_year', Types::INTEGER, ['notnull' => false]);
        $table->addColumn('placement', Types::STRING, ['length' => 255, 'notnull' => false]);
        $table->addColumn('cover', Types::STRING, ['length' => 64, 'notnull' => false]);
    }
    private function addLabelColumns(Table $table): void
    {
        $table->addColumn('id', Types::INTEGER, ['autoincrement' => true]);
        $table->addColumn('book_id', Types::INTEGER);
        $table->addColumn('type', Types::STRING, ['length' => 16]);
        $table->addColumn('label', Types::STRING, ['length' => 255]);
        $table->setPrimaryKey(['id']);
    }
    private function addHistoryColumns(Table $table): void
    {
        $table->addColumn('id', Types::INTEGER, ['autoincrement' => true]);
        $table->addColumn('book_id', Types::INTEGER);
        $table->addColumn('who', Types::STRING, ['length' => 255]);
        $table->addColumn('at', Types::DATETIME_IMMUTABLE);
        $table->addColumn('till', Types::DATETIME_IMMUTABLE);
        $table->setPrimaryKey(['id']);
    }
}
migrations/Version20261113120000.php
New file
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20261113120000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Add the date a book was last seen during an inventory';
    }
    public function up(Schema $schema): void
    {
        foreach (['book', 'ousted_book'] as $table) {
            $schema->getTable($table)->addColumn('last_seen', Types::DATETIME_IMMUTABLE, ['notnull' => false]);
        }
    }
    public function down(Schema $schema): void
    {
        foreach (['book', 'ousted_book'] as $table) {
            $schema->getTable($table)->dropColumn('last_seen');
        }
    }
}
phpunit.dist.xml
New file
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         colors="true"
         failOnDeprecation="true"
         failOnNotice="true"
         failOnWarning="true"
         bootstrap="tests/bootstrap.php"
         cacheDirectory=".phpunit.cache"
>
    <php>
        <ini name="display_errors" value="1" />
        <ini name="error_reporting" value="-1" />
        <server name="APP_ENV" value="test" force="true" />
        <server name="SHELL_VERBOSITY" value="-1" />
    </php>
    <testsuites>
        <testsuite name="Project Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    <source ignoreSuppressionOfDeprecations="true"
            ignoreIndirectDeprecations="true"
            restrictNotices="true"
            restrictWarnings="true"
    >
        <include>
            <directory>src</directory>
        </include>
        <deprecationTrigger>
            <method>Doctrine\Deprecations\Deprecation::trigger</method>
            <method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
            <function>trigger_deprecation</function>
        </deprecationTrigger>
    </source>
    <extensions>
        <bootstrap class="Symfony\Bridge\PhpUnit\SymfonyExtension">
            <parameter name="clock-mock-namespaces" value="App" />
            <parameter name="dns-mock-namespaces" value="App" />
        </bootstrap>
    </extensions>
</phpunit>
place.php
File was deleted
place_change.php
File was deleted
place_set.php
File was deleted
public/css/custom.css
New file
@@ -0,0 +1,82 @@
body {
    padding: 5px;
}
header.navbar {
    padding-left: 5px;
}
.tile-icon {
    width: 75px;
    min-height: 75px;
}
a.tile-icon {
    display: block;
    color: inherit;
}
.tile-icon .icon {
    font-size: 70px;
}
.results > .tile:nth-child(even) {
    background-color: #eee;
}
.results .tile {
    align-items: center;
}
.results .tile-title .label {
    margin-left: .5rem;
}
.page_content {
    padding: .6rem;
}
.page_content + .results {
    margin-top: -.6rem;
}
.flash {
    margin: .6rem 0;
}
.book-labels {
    list-style: none;
    margin-left: 0;
}
form.inline {
    display: inline;
}
.tile-cover {
    display: block;
    max-width: 75px;
    max-height: 113px;
}
.book-cover {
    display: block;
    max-width: 100%;
    width: 300px;
    height: auto;
}
.book-panel {
    margin-bottom: .8rem;
}
.book-panel .panel-body:last-child {
    padding-bottom: .8rem;
}
.book-panel .panel-body .table:last-child,
.book-panel .panel-body p:last-child,
.book-panel .panel-body .book-labels {
    margin-bottom: 0;
}
.book-panel .panel-footer form:last-child,
.book-panel .panel-footer .form-group:last-child {
    margin-bottom: 0;
}
.results .tile-borrowed {
    background-color: #ffd;
}
.results > .tile-borrowed:nth-child(even) {
    background-color: #feb;
}
.tile-borrowed .tile-title a {
    color: #a60;
}
.tile-borrowed .tile-subtitle .label-primary,
.tile-borrowed .tile-subtitle .label-secondary {
    background: #fb0;
    color: #fff;
}
public/css/inventory.css
New file
@@ -0,0 +1,39 @@
[hidden] {
    display: none !important;
}
body {
    padding: 5px;
}
#inventory {
    max-width: 960px;
    margin: 0 auto;
    padding: .6rem;
}
.scan-form .form-input {
    font-size: 1.2rem;
}
.current-location {
    font-size: 1.4rem;
    margin: .4rem 0;
}
.current-location strong {
    margin-left: .4rem;
}
.message {
    margin: .6rem 0;
}
.status {
    margin: .6rem 0 1rem;
}
.status .column {
    padding: .2rem .4rem;
}
.log td {
    vertical-align: middle;
}
.log .log-date {
    white-space: nowrap;
}
.log .label + .btn {
    margin-left: .2rem;
}
public/css/spectre
New file
@@ -0,0 +1 @@
spectre-0.5.9
public/css/spectre-0.5.9/spectre-exp.css
New file
@@ -0,0 +1,1231 @@
/*! Spectre.css Experimentals v0.5.9 | MIT License | github.com/picturepan2/spectre */
.form-autocomplete {
  position: relative;
}
.form-autocomplete .form-autocomplete-input {
  align-content: flex-start;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-line-pack: start;
  -ms-flex-wrap: wrap;
  flex-wrap: wrap;
  height: auto;
  min-height: 1.6rem;
  padding: .1rem;
}
.form-autocomplete .form-autocomplete-input.is-focused {
  border-color: #5755d9;
  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
}
.form-autocomplete .form-autocomplete-input .form-input {
  border-color: transparent;
  box-shadow: none;
  display: inline-block;
  -ms-flex: 1 0 auto;
  flex: 1 0 auto;
  height: 1.2rem;
  line-height: .8rem;
  margin: .1rem;
  width: auto;
}
.form-autocomplete .menu {
  left: 0;
  position: absolute;
  top: 100%;
  width: 100%;
}
.form-autocomplete.autocomplete-oneline .form-autocomplete-input {
  -ms-flex-wrap: nowrap;
  flex-wrap: nowrap;
  overflow-x: auto;
}
.form-autocomplete.autocomplete-oneline .chip {
  -ms-flex: 1 0 auto;
  flex: 1 0 auto;
}
.calendar {
  border: .05rem solid #dadee4;
  border-radius: .1rem;
  display: block;
  min-width: 280px;
}
.calendar .calendar-nav {
  align-items: center;
  background: #f7f8f9;
  border-top-left-radius: .1rem;
  border-top-right-radius: .1rem;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-align: center;
  font-size: .9rem;
  padding: .4rem;
}
.calendar .calendar-header,
.calendar .calendar-body {
  display: -ms-flexbox;
  display: flex;
  -ms-flex-pack: center;
  -ms-flex-wrap: wrap;
  flex-wrap: wrap;
  justify-content: center;
  padding: .4rem 0;
}
.calendar .calendar-header .calendar-date,
.calendar .calendar-body .calendar-date {
  -ms-flex: 0 0 14.28%;
  flex: 0 0 14.28%;
  max-width: 14.28%;
}
.calendar .calendar-header {
  background: #f7f8f9;
  border-bottom: .05rem solid #dadee4;
  color: #bcc3ce;
  font-size: .7rem;
  text-align: center;
}
.calendar .calendar-body {
  color: #66758c;
}
.calendar .calendar-date {
  border: 0;
  padding: .2rem;
}
.calendar .calendar-date .date-item {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background: transparent;
  border: .05rem solid transparent;
  border-radius: 50%;
  color: #66758c;
  cursor: pointer;
  font-size: .7rem;
  height: 1.4rem;
  line-height: 1rem;
  outline: none;
  padding: .1rem;
  position: relative;
  text-align: center;
  text-decoration: none;
  transition: background .2s, border .2s, box-shadow .2s, color .2s;
  vertical-align: middle;
  white-space: nowrap;
  width: 1.4rem;
}
.calendar .calendar-date .date-item.date-today {
  border-color: #e5e5f9;
  color: #5755d9;
}
.calendar .calendar-date .date-item:focus {
  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
}
.calendar .calendar-date .date-item:focus,
.calendar .calendar-date .date-item:hover {
  background: #fefeff;
  border-color: #e5e5f9;
  color: #5755d9;
  text-decoration: none;
}
.calendar .calendar-date .date-item:active,
.calendar .calendar-date .date-item.active {
  background: #4b48d6;
  border-color: #3634d2;
  color: #fff;
}
.calendar .calendar-date .date-item.badge::after {
  position: absolute;
  right: 3px;
  top: 3px;
  transform: translate(50%, -50%);
}
.calendar .calendar-date .date-item:disabled,
.calendar .calendar-date .date-item.disabled,
.calendar .calendar-date .calendar-event:disabled,
.calendar .calendar-date .calendar-event.disabled {
  cursor: default;
  opacity: .25;
  pointer-events: none;
}
.calendar .calendar-date.prev-month .date-item,
.calendar .calendar-date.prev-month .calendar-event,
.calendar .calendar-date.next-month .date-item,
.calendar .calendar-date.next-month .calendar-event {
  opacity: .25;
}
.calendar .calendar-range {
  position: relative;
}
.calendar .calendar-range::before {
  background: #f1f1fc;
  content: "";
  height: 1.4rem;
  left: 0;
  position: absolute;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
}
.calendar .calendar-range.range-start::before {
  left: 50%;
}
.calendar .calendar-range.range-end::before {
  right: 50%;
}
.calendar .calendar-range.range-start .date-item,
.calendar .calendar-range.range-end .date-item {
  background: #4b48d6;
  border-color: #3634d2;
  color: #fff;
}
.calendar .calendar-range .date-item {
  color: #5755d9;
}
.calendar.calendar-lg .calendar-body {
  padding: 0;
}
.calendar.calendar-lg .calendar-body .calendar-date {
  border-bottom: .05rem solid #dadee4;
  border-right: .05rem solid #dadee4;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-direction: column;
  flex-direction: column;
  height: 5.5rem;
  padding: 0;
}
.calendar.calendar-lg .calendar-body .calendar-date:nth-child(7n) {
  border-right: 0;
}
.calendar.calendar-lg .calendar-body .calendar-date:nth-last-child(-n+7) {
  border-bottom: 0;
}
.calendar.calendar-lg .date-item {
  align-self: flex-end;
  -ms-flex-item-align: end;
  height: 1.4rem;
  margin-right: .2rem;
  margin-top: .2rem;
}
.calendar.calendar-lg .calendar-range::before {
  top: 19px;
}
.calendar.calendar-lg .calendar-range.range-start::before {
  left: auto;
  width: 19px;
}
.calendar.calendar-lg .calendar-range.range-end::before {
  right: 19px;
}
.calendar.calendar-lg .calendar-events {
  flex-grow: 1;
  -ms-flex-positive: 1;
  line-height: 1;
  overflow-y: auto;
  padding: .2rem;
}
.calendar.calendar-lg .calendar-event {
  border-radius: .1rem;
  display: block;
  font-size: .7rem;
  margin: .1rem auto;
  overflow: hidden;
  padding: 3px 4px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.carousel .carousel-locator:nth-of-type(1):checked ~ .carousel-container .carousel-item:nth-of-type(1),
.carousel .carousel-locator:nth-of-type(2):checked ~ .carousel-container .carousel-item:nth-of-type(2),
.carousel .carousel-locator:nth-of-type(3):checked ~ .carousel-container .carousel-item:nth-of-type(3),
.carousel .carousel-locator:nth-of-type(4):checked ~ .carousel-container .carousel-item:nth-of-type(4),
.carousel .carousel-locator:nth-of-type(5):checked ~ .carousel-container .carousel-item:nth-of-type(5),
.carousel .carousel-locator:nth-of-type(6):checked ~ .carousel-container .carousel-item:nth-of-type(6),
.carousel .carousel-locator:nth-of-type(7):checked ~ .carousel-container .carousel-item:nth-of-type(7),
.carousel .carousel-locator:nth-of-type(8):checked ~ .carousel-container .carousel-item:nth-of-type(8) {
  animation: carousel-slidein .75s ease-in-out 1;
  opacity: 1;
  z-index: 100;
}
.carousel .carousel-locator:nth-of-type(1):checked ~ .carousel-nav .nav-item:nth-of-type(1),
.carousel .carousel-locator:nth-of-type(2):checked ~ .carousel-nav .nav-item:nth-of-type(2),
.carousel .carousel-locator:nth-of-type(3):checked ~ .carousel-nav .nav-item:nth-of-type(3),
.carousel .carousel-locator:nth-of-type(4):checked ~ .carousel-nav .nav-item:nth-of-type(4),
.carousel .carousel-locator:nth-of-type(5):checked ~ .carousel-nav .nav-item:nth-of-type(5),
.carousel .carousel-locator:nth-of-type(6):checked ~ .carousel-nav .nav-item:nth-of-type(6),
.carousel .carousel-locator:nth-of-type(7):checked ~ .carousel-nav .nav-item:nth-of-type(7),
.carousel .carousel-locator:nth-of-type(8):checked ~ .carousel-nav .nav-item:nth-of-type(8) {
  color: #f7f8f9;
}
.carousel {
  background: #f7f8f9;
  display: block;
  overflow: hidden;
  -webkit-overflow-scrolling: touch;
  position: relative;
  width: 100%;
  z-index: 1;
}
.carousel .carousel-container {
  height: 100%;
  left: 0;
  position: relative;
}
.carousel .carousel-container::before {
  content: "";
  display: block;
  padding-bottom: 56.25%;
}
.carousel .carousel-container .carousel-item {
  animation: carousel-slideout 1s ease-in-out 1;
  height: 100%;
  left: 0;
  margin: 0;
  opacity: 0;
  position: absolute;
  top: 0;
  width: 100%;
}
.carousel .carousel-container .carousel-item:hover .item-prev,
.carousel .carousel-container .carousel-item:hover .item-next {
  opacity: 1;
}
.carousel .carousel-container .item-prev,
.carousel .carousel-container .item-next {
  background: rgba(247, 248, 249, .25);
  border-color: rgba(247, 248, 249, .5);
  color: #f7f8f9;
  opacity: 0;
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  transition: all .4s;
  z-index: 100;
}
.carousel .carousel-container .item-prev {
  left: 1rem;
}
.carousel .carousel-container .item-next {
  right: 1rem;
}
.carousel .carousel-nav {
  bottom: .4rem;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-pack: center;
  justify-content: center;
  left: 50%;
  position: absolute;
  transform: translateX(-50%);
  width: 10rem;
  z-index: 100;
}
.carousel .carousel-nav .nav-item {
  color: rgba(247, 248, 249, .5);
  display: block;
  -ms-flex: 1 0 auto;
  flex: 1 0 auto;
  height: 1.6rem;
  margin: .2rem;
  max-width: 2.5rem;
  position: relative;
}
.carousel .carousel-nav .nav-item::before {
  background: currentColor;
  content: "";
  display: block;
  height: .1rem;
  position: absolute;
  top: .5rem;
  width: 100%;
}
@keyframes carousel-slidein {
  0% {
    transform: translateX(100%);
  }
  100% {
    transform: translateX(0);
  }
}
@keyframes carousel-slideout {
  0% {
    opacity: 1;
    transform: translateX(0);
  }
  100% {
    opacity: 1;
    transform: translateX(-50%);
  }
}
.comparison-slider {
  height: 50vh;
  overflow: hidden;
  -webkit-overflow-scrolling: touch;
  position: relative;
  width: 100%;
}
.comparison-slider .comparison-before,
.comparison-slider .comparison-after {
  height: 100%;
  left: 0;
  margin: 0;
  overflow: hidden;
  position: absolute;
  top: 0;
}
.comparison-slider .comparison-before img,
.comparison-slider .comparison-after img {
  height: 100%;
  object-fit: cover;
  object-position: left center;
  position: absolute;
  width: 100%;
}
.comparison-slider .comparison-before {
  width: 100%;
  z-index: 1;
}
.comparison-slider .comparison-before .comparison-label {
  right: .8rem;
}
.comparison-slider .comparison-after {
  max-width: 100%;
  min-width: 0;
  z-index: 2;
}
.comparison-slider .comparison-after::before {
  background: transparent;
  content: "";
  cursor: default;
  height: 100%;
  left: 0;
  position: absolute;
  right: .8rem;
  top: 0;
  z-index: 1;
}
.comparison-slider .comparison-after::after {
  background: currentColor;
  border-radius: 50%;
  box-shadow: 0 -5px, 0 5px;
  color: #fff;
  content: "";
  height: 3px;
  pointer-events: none;
  position: absolute;
  right: .4rem;
  top: 50%;
  transform: translate(50%, -50%);
  width: 3px;
}
.comparison-slider .comparison-after .comparison-label {
  left: .8rem;
}
.comparison-slider .comparison-resizer {
  animation: first-run 1.5s 1 ease-in-out;
  cursor: ew-resize;
  height: .8rem;
  left: 0;
  max-width: 100%;
  min-width: .8rem;
  opacity: 0;
  outline: none;
  position: relative;
  resize: horizontal;
  top: 50%;
  transform: translateY(-50%) scaleY(30);
  width: 0;
}
.comparison-slider .comparison-label {
  background: rgba(48, 55, 66, .5);
  bottom: .8rem;
  color: #fff;
  padding: .2rem .4rem;
  position: absolute;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
}
@keyframes first-run {
  0% {
    width: 0;
  }
  25% {
    width: 2.4rem;
  }
  50% {
    width: .8rem;
  }
  75% {
    width: 1.2rem;
  }
  100% {
    width: 0;
  }
}
.filter .filter-tag#tag-0:checked ~ .filter-nav .chip[for="tag-0"],
.filter .filter-tag#tag-1:checked ~ .filter-nav .chip[for="tag-1"],
.filter .filter-tag#tag-2:checked ~ .filter-nav .chip[for="tag-2"],
.filter .filter-tag#tag-3:checked ~ .filter-nav .chip[for="tag-3"],
.filter .filter-tag#tag-4:checked ~ .filter-nav .chip[for="tag-4"],
.filter .filter-tag#tag-5:checked ~ .filter-nav .chip[for="tag-5"],
.filter .filter-tag#tag-6:checked ~ .filter-nav .chip[for="tag-6"],
.filter .filter-tag#tag-7:checked ~ .filter-nav .chip[for="tag-7"],
.filter .filter-tag#tag-8:checked ~ .filter-nav .chip[for="tag-8"] {
  background: #5755d9;
  color: #fff;
}
.filter .filter-tag#tag-1:checked ~ .filter-body .filter-item:not([data-tag~="tag-1"]),
.filter .filter-tag#tag-2:checked ~ .filter-body .filter-item:not([data-tag~="tag-2"]),
.filter .filter-tag#tag-3:checked ~ .filter-body .filter-item:not([data-tag~="tag-3"]),
.filter .filter-tag#tag-4:checked ~ .filter-body .filter-item:not([data-tag~="tag-4"]),
.filter .filter-tag#tag-5:checked ~ .filter-body .filter-item:not([data-tag~="tag-5"]),
.filter .filter-tag#tag-6:checked ~ .filter-body .filter-item:not([data-tag~="tag-6"]),
.filter .filter-tag#tag-7:checked ~ .filter-body .filter-item:not([data-tag~="tag-7"]),
.filter .filter-tag#tag-8:checked ~ .filter-body .filter-item:not([data-tag~="tag-8"]) {
  display: none;
}
.filter .filter-nav {
  margin: .4rem 0;
}
.filter .filter-body {
  display: -ms-flexbox;
  display: flex;
  -ms-flex-wrap: wrap;
  flex-wrap: wrap;
}
.meter {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background: #f7f8f9;
  border: 0;
  border-radius: .1rem;
  display: block;
  height: .8rem;
  width: 100%;
}
.meter::-webkit-meter-inner-element {
  display: block;
}
.meter::-webkit-meter-bar,
.meter::-webkit-meter-optimum-value,
.meter::-webkit-meter-suboptimum-value,
.meter::-webkit-meter-even-less-good-value {
  border-radius: .1rem;
}
.meter::-webkit-meter-bar {
  background: #f7f8f9;
}
.meter::-webkit-meter-optimum-value {
  background: #32b643;
}
.meter::-webkit-meter-suboptimum-value {
  background: #ffb700;
}
.meter::-webkit-meter-even-less-good-value {
  background: #e85600;
}
.meter::-moz-meter-bar,
.meter:-moz-meter-optimum,
.meter:-moz-meter-sub-optimum,
.meter:-moz-meter-sub-sub-optimum {
  border-radius: .1rem;
}
.meter:-moz-meter-optimum::-moz-meter-bar {
  background: #32b643;
}
.meter:-moz-meter-sub-optimum::-moz-meter-bar {
  background: #ffb700;
}
.meter:-moz-meter-sub-sub-optimum::-moz-meter-bar {
  background: #e85600;
}
.off-canvas {
  display: -ms-flexbox;
  display: flex;
  -ms-flex-flow: nowrap;
  flex-flow: nowrap;
  height: 100%;
  position: relative;
  width: 100%;
}
.off-canvas .off-canvas-toggle {
  display: block;
  left: .4rem;
  position: absolute;
  top: .4rem;
  transition: none;
  z-index: 1;
}
.off-canvas .off-canvas-sidebar {
  background: #f7f8f9;
  bottom: 0;
  left: 0;
  min-width: 10rem;
  overflow-y: auto;
  position: fixed;
  top: 0;
  transform: translateX(-100%);
  transition: transform .25s;
  z-index: 200;
}
.off-canvas .off-canvas-content {
  -ms-flex: 1 1 auto;
  flex: 1 1 auto;
  height: 100%;
  padding: .4rem .4rem .4rem 4rem;
}
.off-canvas .off-canvas-overlay {
  background: rgba(48, 55, 66, .1);
  border-color: transparent;
  border-radius: 0;
  bottom: 0;
  display: none;
  height: 100%;
  left: 0;
  position: fixed;
  right: 0;
  top: 0;
  width: 100%;
}
.off-canvas .off-canvas-sidebar:target,
.off-canvas .off-canvas-sidebar.active {
  transform: translateX(0);
}
.off-canvas .off-canvas-sidebar:target ~ .off-canvas-overlay,
.off-canvas .off-canvas-sidebar.active ~ .off-canvas-overlay {
  display: block;
  z-index: 100;
}
@media (min-width: 960px) {
  .off-canvas.off-canvas-sidebar-show .off-canvas-toggle {
    display: none;
  }
  .off-canvas.off-canvas-sidebar-show .off-canvas-sidebar {
    -ms-flex: 0 0 auto;
    flex: 0 0 auto;
    position: relative;
    transform: none;
  }
  .off-canvas.off-canvas-sidebar-show .off-canvas-overlay {
    display: none !important;
  }
}
.parallax {
  display: block;
  height: auto;
  position: relative;
  width: auto;
}
.parallax .parallax-content {
  box-shadow: 0 1rem 2.1rem rgba(48, 55, 66, .3);
  height: auto;
  transform: perspective(1000px);
  transform-style: preserve-3d;
  transition: all .4s ease;
  width: 100%;
}
.parallax .parallax-content::before {
  content: "";
  display: block;
  height: 100%;
  left: 0;
  position: absolute;
  top: 0;
  width: 100%;
}
.parallax .parallax-front {
  align-items: center;
  color: #fff;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-align: center;
  -ms-flex-pack: center;
  height: 100%;
  justify-content: center;
  left: 0;
  position: absolute;
  text-align: center;
  text-shadow: 0 0 20px rgba(48, 55, 66, .75);
  top: 0;
  transform: translateZ(50px) scale(.95);
  transition: transform .4s;
  width: 100%;
  z-index: 1;
}
.parallax .parallax-top-left {
  height: 50%;
  left: 0;
  outline: none;
  position: absolute;
  top: 0;
  width: 50%;
  z-index: 100;
}
.parallax .parallax-top-left:focus ~ .parallax-content,
.parallax .parallax-top-left:hover ~ .parallax-content {
  transform: perspective(1000px) rotateX(3deg) rotateY(-3deg);
}
.parallax .parallax-top-left:focus ~ .parallax-content::before,
.parallax .parallax-top-left:hover ~ .parallax-content::before {
  background: linear-gradient(135deg, rgba(255, 255, 255, .35) 0%, transparent 50%);
}
.parallax .parallax-top-left:focus ~ .parallax-content .parallax-front,
.parallax .parallax-top-left:hover ~ .parallax-content .parallax-front {
  transform: translate3d(4.5px, 4.5px, 50px) scale(.95);
}
.parallax .parallax-top-right {
  height: 50%;
  outline: none;
  position: absolute;
  right: 0;
  top: 0;
  width: 50%;
  z-index: 100;
}
.parallax .parallax-top-right:focus ~ .parallax-content,
.parallax .parallax-top-right:hover ~ .parallax-content {
  transform: perspective(1000px) rotateX(3deg) rotateY(3deg);
}
.parallax .parallax-top-right:focus ~ .parallax-content::before,
.parallax .parallax-top-right:hover ~ .parallax-content::before {
  background: linear-gradient(-135deg, rgba(255, 255, 255, .35) 0%, transparent 50%);
}
.parallax .parallax-top-right:focus ~ .parallax-content .parallax-front,
.parallax .parallax-top-right:hover ~ .parallax-content .parallax-front {
  transform: translate3d(-4.5px, 4.5px, 50px) scale(.95);
}
.parallax .parallax-bottom-left {
  bottom: 0;
  height: 50%;
  left: 0;
  outline: none;
  position: absolute;
  width: 50%;
  z-index: 100;
}
.parallax .parallax-bottom-left:focus ~ .parallax-content,
.parallax .parallax-bottom-left:hover ~ .parallax-content {
  transform: perspective(1000px) rotateX(-3deg) rotateY(-3deg);
}
.parallax .parallax-bottom-left:focus ~ .parallax-content::before,
.parallax .parallax-bottom-left:hover ~ .parallax-content::before {
  background: linear-gradient(45deg, rgba(255, 255, 255, .35) 0%, transparent 50%);
}
.parallax .parallax-bottom-left:focus ~ .parallax-content .parallax-front,
.parallax .parallax-bottom-left:hover ~ .parallax-content .parallax-front {
  transform: translate3d(4.5px, -4.5px, 50px) scale(.95);
}
.parallax .parallax-bottom-right {
  bottom: 0;
  height: 50%;
  outline: none;
  position: absolute;
  right: 0;
  width: 50%;
  z-index: 100;
}
.parallax .parallax-bottom-right:focus ~ .parallax-content,
.parallax .parallax-bottom-right:hover ~ .parallax-content {
  transform: perspective(1000px) rotateX(-3deg) rotateY(3deg);
}
.parallax .parallax-bottom-right:focus ~ .parallax-content::before,
.parallax .parallax-bottom-right:hover ~ .parallax-content::before {
  background: linear-gradient(-45deg, rgba(255, 255, 255, .35) 0%, transparent 50%);
}
.parallax .parallax-bottom-right:focus ~ .parallax-content .parallax-front,
.parallax .parallax-bottom-right:hover ~ .parallax-content .parallax-front {
  transform: translate3d(-4.5px, -4.5px, 50px) scale(.95);
}
.progress {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background: #eef0f3;
  border: 0;
  border-radius: .1rem;
  color: #5755d9;
  height: .2rem;
  position: relative;
  width: 100%;
}
.progress::-webkit-progress-bar {
  background: transparent;
  border-radius: .1rem;
}
.progress::-webkit-progress-value {
  background: #5755d9;
  border-radius: .1rem;
}
.progress::-moz-progress-bar {
  background: #5755d9;
  border-radius: .1rem;
}
.progress:indeterminate {
  animation: progress-indeterminate 1.5s linear infinite;
  background: #eef0f3 linear-gradient(to right, #5755d9 30%, #eef0f3 30%) top left/150% 150% no-repeat;
}
.progress:indeterminate::-moz-progress-bar {
  background: transparent;
}
@keyframes progress-indeterminate {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}
.slider {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background: transparent;
  display: block;
  height: 1.2rem;
  width: 100%;
}
.slider:focus {
  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
  outline: none;
}
.slider.tooltip:not([data-tooltip])::after {
  content: attr(value);
}
.slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  background: #5755d9;
  border: 0;
  border-radius: 50%;
  height: .6rem;
  margin-top: -.25rem;
  -webkit-transition: transform .2s;
  transition: transform .2s;
  width: .6rem;
}
.slider::-moz-range-thumb {
  background: #5755d9;
  border: 0;
  border-radius: 50%;
  height: .6rem;
  -moz-transition: transform .2s;
  transition: transform .2s;
  width: .6rem;
}
.slider::-ms-thumb {
  background: #5755d9;
  border: 0;
  border-radius: 50%;
  height: .6rem;
  -ms-transition: transform .2s;
  transition: transform .2s;
  width: .6rem;
}
.slider:active::-webkit-slider-thumb {
  transform: scale(1.25);
}
.slider:active::-moz-range-thumb {
  transform: scale(1.25);
}
.slider:active::-ms-thumb {
  transform: scale(1.25);
}
.slider:disabled::-webkit-slider-thumb,
.slider.disabled::-webkit-slider-thumb {
  background: #f7f8f9;
  transform: scale(1);
}
.slider:disabled::-moz-range-thumb,
.slider.disabled::-moz-range-thumb {
  background: #f7f8f9;
  transform: scale(1);
}
.slider:disabled::-ms-thumb,
.slider.disabled::-ms-thumb {
  background: #f7f8f9;
  transform: scale(1);
}
.slider::-webkit-slider-runnable-track {
  background: #eef0f3;
  border-radius: .1rem;
  height: .1rem;
  width: 100%;
}
.slider::-moz-range-track {
  background: #eef0f3;
  border-radius: .1rem;
  height: .1rem;
  width: 100%;
}
.slider::-ms-track {
  background: #eef0f3;
  border-radius: .1rem;
  height: .1rem;
  width: 100%;
}
.slider::-ms-fill-lower {
  background: #5755d9;
}
.timeline .timeline-item {
  display: -ms-flexbox;
  display: flex;
  margin-bottom: 1.2rem;
  position: relative;
}
.timeline .timeline-item::before {
  background: #dadee4;
  content: "";
  height: 100%;
  left: 11px;
  position: absolute;
  top: 1.2rem;
  width: 2px;
}
.timeline .timeline-item .timeline-left {
  -ms-flex: 0 0 auto;
  flex: 0 0 auto;
}
.timeline .timeline-item .timeline-content {
  -ms-flex: 1 1 auto;
  flex: 1 1 auto;
  padding: 2px 0 2px .8rem;
}
.timeline .timeline-item .timeline-icon {
  align-items: center;
  border-radius: 50%;
  color: #fff;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-align: center;
  -ms-flex-pack: center;
  height: 1.2rem;
  justify-content: center;
  text-align: center;
  width: 1.2rem;
}
.timeline .timeline-item .timeline-icon::before {
  border: .1rem solid #5755d9;
  border-radius: 50%;
  content: "";
  display: block;
  height: .4rem;
  left: .4rem;
  position: absolute;
  top: .4rem;
  width: .4rem;
}
.timeline .timeline-item .timeline-icon.icon-lg {
  background: #5755d9;
  line-height: 1.2rem;
}
.timeline .timeline-item .timeline-icon.icon-lg::before {
  content: none;
}
.viewer-360 {
  align-items: center;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-align: center;
  -ms-flex-direction: column;
  flex-direction: column;
}
.viewer-360 .viewer-slider[max="36"][value="1"] + .viewer-image {
  background-position-y: 0;
}
.viewer-360 .viewer-slider[max="36"][value="2"] + .viewer-image {
  background-position-y: 2.8571428571%;
}
.viewer-360 .viewer-slider[max="36"][value="3"] + .viewer-image {
  background-position-y: 5.7142857143%;
}
.viewer-360 .viewer-slider[max="36"][value="4"] + .viewer-image {
  background-position-y: 8.5714285714%;
}
.viewer-360 .viewer-slider[max="36"][value="5"] + .viewer-image {
  background-position-y: 11.4285714286%;
}
.viewer-360 .viewer-slider[max="36"][value="6"] + .viewer-image {
  background-position-y: 14.2857142857%;
}
.viewer-360 .viewer-slider[max="36"][value="7"] + .viewer-image {
  background-position-y: 17.1428571429%;
}
.viewer-360 .viewer-slider[max="36"][value="8"] + .viewer-image {
  background-position-y: 20%;
}
.viewer-360 .viewer-slider[max="36"][value="9"] + .viewer-image {
  background-position-y: 22.8571428571%;
}
.viewer-360 .viewer-slider[max="36"][value="10"] + .viewer-image {
  background-position-y: 25.7142857143%;
}
.viewer-360 .viewer-slider[max="36"][value="11"] + .viewer-image {
  background-position-y: 28.5714285714%;
}
.viewer-360 .viewer-slider[max="36"][value="12"] + .viewer-image {
  background-position-y: 31.4285714286%;
}
.viewer-360 .viewer-slider[max="36"][value="13"] + .viewer-image {
  background-position-y: 34.2857142857%;
}
.viewer-360 .viewer-slider[max="36"][value="14"] + .viewer-image {
  background-position-y: 37.1428571429%;
}
.viewer-360 .viewer-slider[max="36"][value="15"] + .viewer-image {
  background-position-y: 40%;
}
.viewer-360 .viewer-slider[max="36"][value="16"] + .viewer-image {
  background-position-y: 42.8571428571%;
}
.viewer-360 .viewer-slider[max="36"][value="17"] + .viewer-image {
  background-position-y: 45.7142857143%;
}
.viewer-360 .viewer-slider[max="36"][value="18"] + .viewer-image {
  background-position-y: 48.5714285714%;
}
.viewer-360 .viewer-slider[max="36"][value="19"] + .viewer-image {
  background-position-y: 51.4285714286%;
}
.viewer-360 .viewer-slider[max="36"][value="20"] + .viewer-image {
  background-position-y: 54.2857142857%;
}
.viewer-360 .viewer-slider[max="36"][value="21"] + .viewer-image {
  background-position-y: 57.1428571429%;
}
.viewer-360 .viewer-slider[max="36"][value="22"] + .viewer-image {
  background-position-y: 60%;
}
.viewer-360 .viewer-slider[max="36"][value="23"] + .viewer-image {
  background-position-y: 62.8571428571%;
}
.viewer-360 .viewer-slider[max="36"][value="24"] + .viewer-image {
  background-position-y: 65.7142857143%;
}
.viewer-360 .viewer-slider[max="36"][value="25"] + .viewer-image {
  background-position-y: 68.5714285714%;
}
.viewer-360 .viewer-slider[max="36"][value="26"] + .viewer-image {
  background-position-y: 71.4285714286%;
}
.viewer-360 .viewer-slider[max="36"][value="27"] + .viewer-image {
  background-position-y: 74.2857142857%;
}
.viewer-360 .viewer-slider[max="36"][value="28"] + .viewer-image {
  background-position-y: 77.1428571429%;
}
.viewer-360 .viewer-slider[max="36"][value="29"] + .viewer-image {
  background-position-y: 80%;
}
.viewer-360 .viewer-slider[max="36"][value="30"] + .viewer-image {
  background-position-y: 82.8571428571%;
}
.viewer-360 .viewer-slider[max="36"][value="31"] + .viewer-image {
  background-position-y: 85.7142857143%;
}
.viewer-360 .viewer-slider[max="36"][value="32"] + .viewer-image {
  background-position-y: 88.5714285714%;
}
.viewer-360 .viewer-slider[max="36"][value="33"] + .viewer-image {
  background-position-y: 91.4285714286%;
}
.viewer-360 .viewer-slider[max="36"][value="34"] + .viewer-image {
  background-position-y: 94.2857142857%;
}
.viewer-360 .viewer-slider[max="36"][value="35"] + .viewer-image {
  background-position-y: 97.1428571429%;
}
.viewer-360 .viewer-slider[max="36"][value="36"] + .viewer-image {
  background-position-y: 100%;
}
.viewer-360 .viewer-slider {
  cursor: ew-resize;
  -ms-flex-order: 2;
  margin: 1rem;
  order: 2;
  width: 60%;
}
.viewer-360 .viewer-image {
  background-position-y: 0;
  background-repeat: no-repeat;
  background-size: 100%;
  -ms-flex-order: 1;
  max-width: 100%;
  order: 1;
}
public/css/spectre-0.5.9/spectre-exp.min.css
New file
@@ -0,0 +1 @@
/*! Spectre.css Experimentals v0.5.9 | MIT License | github.com/picturepan2/spectre */.form-autocomplete{position:relative}.form-autocomplete .form-autocomplete-input{align-content:flex-start;display:-ms-flexbox;display:flex;-ms-flex-line-pack:start;-ms-flex-wrap:wrap;flex-wrap:wrap;height:auto;min-height:1.6rem;padding:.1rem}.form-autocomplete .form-autocomplete-input.is-focused{border-color:#5755d9;box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.form-autocomplete .form-autocomplete-input .form-input{border-color:transparent;box-shadow:none;display:inline-block;-ms-flex:1 0 auto;flex:1 0 auto;height:1.2rem;line-height:.8rem;margin:.1rem;width:auto}.form-autocomplete .menu{left:0;position:absolute;top:100%;width:100%}.form-autocomplete.autocomplete-oneline .form-autocomplete-input{-ms-flex-wrap:nowrap;flex-wrap:nowrap;overflow-x:auto}.form-autocomplete.autocomplete-oneline .chip{-ms-flex:1 0 auto;flex:1 0 auto}.calendar{border:.05rem solid #dadee4;border-radius:.1rem;display:block;min-width:280px}.calendar .calendar-nav{align-items:center;background:#f7f8f9;border-top-left-radius:.1rem;border-top-right-radius:.1rem;display:-ms-flexbox;display:flex;-ms-flex-align:center;font-size:.9rem;padding:.4rem}.calendar .calendar-body,.calendar .calendar-header{display:-ms-flexbox;display:flex;-ms-flex-pack:center;-ms-flex-wrap:wrap;flex-wrap:wrap;justify-content:center;padding:.4rem 0}.calendar .calendar-body .calendar-date,.calendar .calendar-header .calendar-date{-ms-flex:0 0 14.28%;flex:0 0 14.28%;max-width:14.28%}.calendar .calendar-header{background:#f7f8f9;border-bottom:.05rem solid #dadee4;color:#bcc3ce;font-size:.7rem;text-align:center}.calendar .calendar-body{color:#66758c}.calendar .calendar-date{border:0;padding:.2rem}.calendar .calendar-date .date-item{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:0 0;border:.05rem solid transparent;border-radius:50%;color:#66758c;cursor:pointer;font-size:.7rem;height:1.4rem;line-height:1rem;outline:0;padding:.1rem;position:relative;text-align:center;text-decoration:none;transition:background .2s,border .2s,box-shadow .2s,color .2s;vertical-align:middle;white-space:nowrap;width:1.4rem}.calendar .calendar-date .date-item.date-today{border-color:#e5e5f9;color:#5755d9}.calendar .calendar-date .date-item:focus{box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.calendar .calendar-date .date-item:focus,.calendar .calendar-date .date-item:hover{background:#fefeff;border-color:#e5e5f9;color:#5755d9;text-decoration:none}.calendar .calendar-date .date-item.active,.calendar .calendar-date .date-item:active{background:#4b48d6;border-color:#3634d2;color:#fff}.calendar .calendar-date .date-item.badge::after{position:absolute;right:3px;top:3px;transform:translate(50%,-50%)}.calendar .calendar-date .calendar-event.disabled,.calendar .calendar-date .calendar-event:disabled,.calendar .calendar-date .date-item.disabled,.calendar .calendar-date .date-item:disabled{cursor:default;opacity:.25;pointer-events:none}.calendar .calendar-date.next-month .calendar-event,.calendar .calendar-date.next-month .date-item,.calendar .calendar-date.prev-month .calendar-event,.calendar .calendar-date.prev-month .date-item{opacity:.25}.calendar .calendar-range{position:relative}.calendar .calendar-range::before{background:#f1f1fc;content:"";height:1.4rem;left:0;position:absolute;right:0;top:50%;transform:translateY(-50%)}.calendar .calendar-range.range-start::before{left:50%}.calendar .calendar-range.range-end::before{right:50%}.calendar .calendar-range.range-end .date-item,.calendar .calendar-range.range-start .date-item{background:#4b48d6;border-color:#3634d2;color:#fff}.calendar .calendar-range .date-item{color:#5755d9}.calendar.calendar-lg .calendar-body{padding:0}.calendar.calendar-lg .calendar-body .calendar-date{border-bottom:.05rem solid #dadee4;border-right:.05rem solid #dadee4;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;height:5.5rem;padding:0}.calendar.calendar-lg .calendar-body .calendar-date:nth-child(7n){border-right:0}.calendar.calendar-lg .calendar-body .calendar-date:nth-last-child(-n+7){border-bottom:0}.calendar.calendar-lg .date-item{align-self:flex-end;-ms-flex-item-align:end;height:1.4rem;margin-right:.2rem;margin-top:.2rem}.calendar.calendar-lg .calendar-range::before{top:19px}.calendar.calendar-lg .calendar-range.range-start::before{left:auto;width:19px}.calendar.calendar-lg .calendar-range.range-end::before{right:19px}.calendar.calendar-lg .calendar-events{flex-grow:1;-ms-flex-positive:1;line-height:1;overflow-y:auto;padding:.2rem}.calendar.calendar-lg .calendar-event{border-radius:.1rem;display:block;font-size:.7rem;margin:.1rem auto;overflow:hidden;padding:3px 4px;text-overflow:ellipsis;white-space:nowrap}.carousel .carousel-locator:nth-of-type(1):checked~.carousel-container .carousel-item:nth-of-type(1),.carousel .carousel-locator:nth-of-type(2):checked~.carousel-container .carousel-item:nth-of-type(2),.carousel .carousel-locator:nth-of-type(3):checked~.carousel-container .carousel-item:nth-of-type(3),.carousel .carousel-locator:nth-of-type(4):checked~.carousel-container .carousel-item:nth-of-type(4),.carousel .carousel-locator:nth-of-type(5):checked~.carousel-container .carousel-item:nth-of-type(5),.carousel .carousel-locator:nth-of-type(6):checked~.carousel-container .carousel-item:nth-of-type(6),.carousel .carousel-locator:nth-of-type(7):checked~.carousel-container .carousel-item:nth-of-type(7),.carousel .carousel-locator:nth-of-type(8):checked~.carousel-container .carousel-item:nth-of-type(8){animation:carousel-slidein .75s ease-in-out 1;opacity:1;z-index:100}.carousel .carousel-locator:nth-of-type(1):checked~.carousel-nav .nav-item:nth-of-type(1),.carousel .carousel-locator:nth-of-type(2):checked~.carousel-nav .nav-item:nth-of-type(2),.carousel .carousel-locator:nth-of-type(3):checked~.carousel-nav .nav-item:nth-of-type(3),.carousel .carousel-locator:nth-of-type(4):checked~.carousel-nav .nav-item:nth-of-type(4),.carousel .carousel-locator:nth-of-type(5):checked~.carousel-nav .nav-item:nth-of-type(5),.carousel .carousel-locator:nth-of-type(6):checked~.carousel-nav .nav-item:nth-of-type(6),.carousel .carousel-locator:nth-of-type(7):checked~.carousel-nav .nav-item:nth-of-type(7),.carousel .carousel-locator:nth-of-type(8):checked~.carousel-nav .nav-item:nth-of-type(8){color:#f7f8f9}.carousel{background:#f7f8f9;display:block;overflow:hidden;-webkit-overflow-scrolling:touch;position:relative;width:100%;z-index:1}.carousel .carousel-container{height:100%;left:0;position:relative}.carousel .carousel-container::before{content:"";display:block;padding-bottom:56.25%}.carousel .carousel-container .carousel-item{animation:carousel-slideout 1s ease-in-out 1;height:100%;left:0;margin:0;opacity:0;position:absolute;top:0;width:100%}.carousel .carousel-container .carousel-item:hover .item-next,.carousel .carousel-container .carousel-item:hover .item-prev{opacity:1}.carousel .carousel-container .item-next,.carousel .carousel-container .item-prev{background:rgba(247,248,249,.25);border-color:rgba(247,248,249,.5);color:#f7f8f9;opacity:0;position:absolute;top:50%;transform:translateY(-50%);transition:all .4s;z-index:100}.carousel .carousel-container .item-prev{left:1rem}.carousel .carousel-container .item-next{right:1rem}.carousel .carousel-nav{bottom:.4rem;display:-ms-flexbox;display:flex;-ms-flex-pack:center;justify-content:center;left:50%;position:absolute;transform:translateX(-50%);width:10rem;z-index:100}.carousel .carousel-nav .nav-item{color:rgba(247,248,249,.5);display:block;-ms-flex:1 0 auto;flex:1 0 auto;height:1.6rem;margin:.2rem;max-width:2.5rem;position:relative}.carousel .carousel-nav .nav-item::before{background:currentColor;content:"";display:block;height:.1rem;position:absolute;top:.5rem;width:100%}@keyframes carousel-slidein{0%{transform:translateX(100%)}100%{transform:translateX(0)}}@keyframes carousel-slideout{0%{opacity:1;transform:translateX(0)}100%{opacity:1;transform:translateX(-50%)}}.comparison-slider{height:50vh;overflow:hidden;-webkit-overflow-scrolling:touch;position:relative;width:100%}.comparison-slider .comparison-after,.comparison-slider .comparison-before{height:100%;left:0;margin:0;overflow:hidden;position:absolute;top:0}.comparison-slider .comparison-after img,.comparison-slider .comparison-before img{height:100%;object-fit:cover;object-position:left center;position:absolute;width:100%}.comparison-slider .comparison-before{width:100%;z-index:1}.comparison-slider .comparison-before .comparison-label{right:.8rem}.comparison-slider .comparison-after{max-width:100%;min-width:0;z-index:2}.comparison-slider .comparison-after::before{background:0 0;content:"";cursor:default;height:100%;left:0;position:absolute;right:.8rem;top:0;z-index:1}.comparison-slider .comparison-after::after{background:currentColor;border-radius:50%;box-shadow:0 -5px,0 5px;color:#fff;content:"";height:3px;pointer-events:none;position:absolute;right:.4rem;top:50%;transform:translate(50%,-50%);width:3px}.comparison-slider .comparison-after .comparison-label{left:.8rem}.comparison-slider .comparison-resizer{animation:first-run 1.5s 1 ease-in-out;cursor:ew-resize;height:.8rem;left:0;max-width:100%;min-width:.8rem;opacity:0;outline:0;position:relative;resize:horizontal;top:50%;transform:translateY(-50%) scaleY(30);width:0}.comparison-slider .comparison-label{background:rgba(48,55,66,.5);bottom:.8rem;color:#fff;padding:.2rem .4rem;position:absolute;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}@keyframes first-run{0%{width:0}25%{width:2.4rem}50%{width:.8rem}75%{width:1.2rem}100%{width:0}}.filter .filter-tag#tag-0:checked~.filter-nav .chip[for=tag-0],.filter .filter-tag#tag-1:checked~.filter-nav .chip[for=tag-1],.filter .filter-tag#tag-2:checked~.filter-nav .chip[for=tag-2],.filter .filter-tag#tag-3:checked~.filter-nav .chip[for=tag-3],.filter .filter-tag#tag-4:checked~.filter-nav .chip[for=tag-4],.filter .filter-tag#tag-5:checked~.filter-nav .chip[for=tag-5],.filter .filter-tag#tag-6:checked~.filter-nav .chip[for=tag-6],.filter .filter-tag#tag-7:checked~.filter-nav .chip[for=tag-7],.filter .filter-tag#tag-8:checked~.filter-nav .chip[for=tag-8]{background:#5755d9;color:#fff}.filter .filter-tag#tag-1:checked~.filter-body .filter-item:not([data-tag~=tag-1]),.filter .filter-tag#tag-2:checked~.filter-body .filter-item:not([data-tag~=tag-2]),.filter .filter-tag#tag-3:checked~.filter-body .filter-item:not([data-tag~=tag-3]),.filter .filter-tag#tag-4:checked~.filter-body .filter-item:not([data-tag~=tag-4]),.filter .filter-tag#tag-5:checked~.filter-body .filter-item:not([data-tag~=tag-5]),.filter .filter-tag#tag-6:checked~.filter-body .filter-item:not([data-tag~=tag-6]),.filter .filter-tag#tag-7:checked~.filter-body .filter-item:not([data-tag~=tag-7]),.filter .filter-tag#tag-8:checked~.filter-body .filter-item:not([data-tag~=tag-8]){display:none}.filter .filter-nav{margin:.4rem 0}.filter .filter-body{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap}.meter{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#f7f8f9;border:0;border-radius:.1rem;display:block;height:.8rem;width:100%}.meter::-webkit-meter-inner-element{display:block}.meter::-webkit-meter-bar,.meter::-webkit-meter-even-less-good-value,.meter::-webkit-meter-optimum-value,.meter::-webkit-meter-suboptimum-value{border-radius:.1rem}.meter::-webkit-meter-bar{background:#f7f8f9}.meter::-webkit-meter-optimum-value{background:#32b643}.meter::-webkit-meter-suboptimum-value{background:#ffb700}.meter::-webkit-meter-even-less-good-value{background:#e85600}.meter:-moz-meter-optimum,.meter:-moz-meter-sub-optimum,.meter:-moz-meter-sub-sub-optimum,.meter::-moz-meter-bar{border-radius:.1rem}.meter:-moz-meter-optimum::-moz-meter-bar{background:#32b643}.meter:-moz-meter-sub-optimum::-moz-meter-bar{background:#ffb700}.meter:-moz-meter-sub-sub-optimum::-moz-meter-bar{background:#e85600}.off-canvas{display:-ms-flexbox;display:flex;-ms-flex-flow:nowrap;flex-flow:nowrap;height:100%;position:relative;width:100%}.off-canvas .off-canvas-toggle{display:block;left:.4rem;position:absolute;top:.4rem;transition:none;z-index:1}.off-canvas .off-canvas-sidebar{background:#f7f8f9;bottom:0;left:0;min-width:10rem;overflow-y:auto;position:fixed;top:0;transform:translateX(-100%);transition:transform .25s;z-index:200}.off-canvas .off-canvas-content{-ms-flex:1 1 auto;flex:1 1 auto;height:100%;padding:.4rem .4rem .4rem 4rem}.off-canvas .off-canvas-overlay{background:rgba(48,55,66,.1);border-color:transparent;border-radius:0;bottom:0;display:none;height:100%;left:0;position:fixed;right:0;top:0;width:100%}.off-canvas .off-canvas-sidebar.active,.off-canvas .off-canvas-sidebar:target{transform:translateX(0)}.off-canvas .off-canvas-sidebar.active~.off-canvas-overlay,.off-canvas .off-canvas-sidebar:target~.off-canvas-overlay{display:block;z-index:100}@media (min-width:960px){.off-canvas.off-canvas-sidebar-show .off-canvas-toggle{display:none}.off-canvas.off-canvas-sidebar-show .off-canvas-sidebar{-ms-flex:0 0 auto;flex:0 0 auto;position:relative;transform:none}.off-canvas.off-canvas-sidebar-show .off-canvas-overlay{display:none!important}}.parallax{display:block;height:auto;position:relative;width:auto}.parallax .parallax-content{box-shadow:0 1rem 2.1rem rgba(48,55,66,.3);height:auto;transform:perspective(1000px);transform-style:preserve-3d;transition:all .4s ease;width:100%}.parallax .parallax-content::before{content:"";display:block;height:100%;left:0;position:absolute;top:0;width:100%}.parallax .parallax-front{align-items:center;color:#fff;display:-ms-flexbox;display:flex;-ms-flex-align:center;-ms-flex-pack:center;height:100%;justify-content:center;left:0;position:absolute;text-align:center;text-shadow:0 0 20px rgba(48,55,66,.75);top:0;transform:translateZ(50px) scale(.95);transition:transform .4s;width:100%;z-index:1}.parallax .parallax-top-left{height:50%;left:0;outline:0;position:absolute;top:0;width:50%;z-index:100}.parallax .parallax-top-left:focus~.parallax-content,.parallax .parallax-top-left:hover~.parallax-content{transform:perspective(1000px) rotateX(3deg) rotateY(-3deg)}.parallax .parallax-top-left:focus~.parallax-content::before,.parallax .parallax-top-left:hover~.parallax-content::before{background:linear-gradient(135deg,rgba(255,255,255,.35) 0,transparent 50%)}.parallax .parallax-top-left:focus~.parallax-content .parallax-front,.parallax .parallax-top-left:hover~.parallax-content .parallax-front{transform:translate3d(4.5px,4.5px,50px) scale(.95)}.parallax .parallax-top-right{height:50%;outline:0;position:absolute;right:0;top:0;width:50%;z-index:100}.parallax .parallax-top-right:focus~.parallax-content,.parallax .parallax-top-right:hover~.parallax-content{transform:perspective(1000px) rotateX(3deg) rotateY(3deg)}.parallax .parallax-top-right:focus~.parallax-content::before,.parallax .parallax-top-right:hover~.parallax-content::before{background:linear-gradient(-135deg,rgba(255,255,255,.35) 0,transparent 50%)}.parallax .parallax-top-right:focus~.parallax-content .parallax-front,.parallax .parallax-top-right:hover~.parallax-content .parallax-front{transform:translate3d(-4.5px,4.5px,50px) scale(.95)}.parallax .parallax-bottom-left{bottom:0;height:50%;left:0;outline:0;position:absolute;width:50%;z-index:100}.parallax .parallax-bottom-left:focus~.parallax-content,.parallax .parallax-bottom-left:hover~.parallax-content{transform:perspective(1000px) rotateX(-3deg) rotateY(-3deg)}.parallax .parallax-bottom-left:focus~.parallax-content::before,.parallax .parallax-bottom-left:hover~.parallax-content::before{background:linear-gradient(45deg,rgba(255,255,255,.35) 0,transparent 50%)}.parallax .parallax-bottom-left:focus~.parallax-content .parallax-front,.parallax .parallax-bottom-left:hover~.parallax-content .parallax-front{transform:translate3d(4.5px,-4.5px,50px) scale(.95)}.parallax .parallax-bottom-right{bottom:0;height:50%;outline:0;position:absolute;right:0;width:50%;z-index:100}.parallax .parallax-bottom-right:focus~.parallax-content,.parallax .parallax-bottom-right:hover~.parallax-content{transform:perspective(1000px) rotateX(-3deg) rotateY(3deg)}.parallax .parallax-bottom-right:focus~.parallax-content::before,.parallax .parallax-bottom-right:hover~.parallax-content::before{background:linear-gradient(-45deg,rgba(255,255,255,.35) 0,transparent 50%)}.parallax .parallax-bottom-right:focus~.parallax-content .parallax-front,.parallax .parallax-bottom-right:hover~.parallax-content .parallax-front{transform:translate3d(-4.5px,-4.5px,50px) scale(.95)}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#eef0f3;border:0;border-radius:.1rem;color:#5755d9;height:.2rem;position:relative;width:100%}.progress::-webkit-progress-bar{background:0 0;border-radius:.1rem}.progress::-webkit-progress-value{background:#5755d9;border-radius:.1rem}.progress::-moz-progress-bar{background:#5755d9;border-radius:.1rem}.progress:indeterminate{animation:progress-indeterminate 1.5s linear infinite;background:#eef0f3 linear-gradient(to right,#5755d9 30%,#eef0f3 30%) top left/150% 150% no-repeat}.progress:indeterminate::-moz-progress-bar{background:0 0}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}.slider{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:0 0;display:block;height:1.2rem;width:100%}.slider:focus{box-shadow:0 0 0 .1rem rgba(87,85,217,.2);outline:0}.slider.tooltip:not([data-tooltip])::after{content:attr(value)}.slider::-webkit-slider-thumb{-webkit-appearance:none;background:#5755d9;border:0;border-radius:50%;height:.6rem;margin-top:-.25rem;-webkit-transition:transform .2s;transition:transform .2s;width:.6rem}.slider::-moz-range-thumb{background:#5755d9;border:0;border-radius:50%;height:.6rem;-moz-transition:transform .2s;transition:transform .2s;width:.6rem}.slider::-ms-thumb{background:#5755d9;border:0;border-radius:50%;height:.6rem;-ms-transition:transform .2s;transition:transform .2s;width:.6rem}.slider:active::-webkit-slider-thumb{transform:scale(1.25)}.slider:active::-moz-range-thumb{transform:scale(1.25)}.slider:active::-ms-thumb{transform:scale(1.25)}.slider.disabled::-webkit-slider-thumb,.slider:disabled::-webkit-slider-thumb{background:#f7f8f9;transform:scale(1)}.slider.disabled::-moz-range-thumb,.slider:disabled::-moz-range-thumb{background:#f7f8f9;transform:scale(1)}.slider.disabled::-ms-thumb,.slider:disabled::-ms-thumb{background:#f7f8f9;transform:scale(1)}.slider::-webkit-slider-runnable-track{background:#eef0f3;border-radius:.1rem;height:.1rem;width:100%}.slider::-moz-range-track{background:#eef0f3;border-radius:.1rem;height:.1rem;width:100%}.slider::-ms-track{background:#eef0f3;border-radius:.1rem;height:.1rem;width:100%}.slider::-ms-fill-lower{background:#5755d9}.timeline .timeline-item{display:-ms-flexbox;display:flex;margin-bottom:1.2rem;position:relative}.timeline .timeline-item::before{background:#dadee4;content:"";height:100%;left:11px;position:absolute;top:1.2rem;width:2px}.timeline .timeline-item .timeline-left{-ms-flex:0 0 auto;flex:0 0 auto}.timeline .timeline-item .timeline-content{-ms-flex:1 1 auto;flex:1 1 auto;padding:2px 0 2px .8rem}.timeline .timeline-item .timeline-icon{align-items:center;border-radius:50%;color:#fff;display:-ms-flexbox;display:flex;-ms-flex-align:center;-ms-flex-pack:center;height:1.2rem;justify-content:center;text-align:center;width:1.2rem}.timeline .timeline-item .timeline-icon::before{border:.1rem solid #5755d9;border-radius:50%;content:"";display:block;height:.4rem;left:.4rem;position:absolute;top:.4rem;width:.4rem}.timeline .timeline-item .timeline-icon.icon-lg{background:#5755d9;line-height:1.2rem}.timeline .timeline-item .timeline-icon.icon-lg::before{content:none}.viewer-360{align-items:center;display:-ms-flexbox;display:flex;-ms-flex-align:center;-ms-flex-direction:column;flex-direction:column}.viewer-360 .viewer-slider[max="36"][value="1"]+.viewer-image{background-position-y:0}.viewer-360 .viewer-slider[max="36"][value="2"]+.viewer-image{background-position-y:2.8571428571%}.viewer-360 .viewer-slider[max="36"][value="3"]+.viewer-image{background-position-y:5.7142857143%}.viewer-360 .viewer-slider[max="36"][value="4"]+.viewer-image{background-position-y:8.5714285714%}.viewer-360 .viewer-slider[max="36"][value="5"]+.viewer-image{background-position-y:11.4285714286%}.viewer-360 .viewer-slider[max="36"][value="6"]+.viewer-image{background-position-y:14.2857142857%}.viewer-360 .viewer-slider[max="36"][value="7"]+.viewer-image{background-position-y:17.1428571429%}.viewer-360 .viewer-slider[max="36"][value="8"]+.viewer-image{background-position-y:20%}.viewer-360 .viewer-slider[max="36"][value="9"]+.viewer-image{background-position-y:22.8571428571%}.viewer-360 .viewer-slider[max="36"][value="10"]+.viewer-image{background-position-y:25.7142857143%}.viewer-360 .viewer-slider[max="36"][value="11"]+.viewer-image{background-position-y:28.5714285714%}.viewer-360 .viewer-slider[max="36"][value="12"]+.viewer-image{background-position-y:31.4285714286%}.viewer-360 .viewer-slider[max="36"][value="13"]+.viewer-image{background-position-y:34.2857142857%}.viewer-360 .viewer-slider[max="36"][value="14"]+.viewer-image{background-position-y:37.1428571429%}.viewer-360 .viewer-slider[max="36"][value="15"]+.viewer-image{background-position-y:40%}.viewer-360 .viewer-slider[max="36"][value="16"]+.viewer-image{background-position-y:42.8571428571%}.viewer-360 .viewer-slider[max="36"][value="17"]+.viewer-image{background-position-y:45.7142857143%}.viewer-360 .viewer-slider[max="36"][value="18"]+.viewer-image{background-position-y:48.5714285714%}.viewer-360 .viewer-slider[max="36"][value="19"]+.viewer-image{background-position-y:51.4285714286%}.viewer-360 .viewer-slider[max="36"][value="20"]+.viewer-image{background-position-y:54.2857142857%}.viewer-360 .viewer-slider[max="36"][value="21"]+.viewer-image{background-position-y:57.1428571429%}.viewer-360 .viewer-slider[max="36"][value="22"]+.viewer-image{background-position-y:60%}.viewer-360 .viewer-slider[max="36"][value="23"]+.viewer-image{background-position-y:62.8571428571%}.viewer-360 .viewer-slider[max="36"][value="24"]+.viewer-image{background-position-y:65.7142857143%}.viewer-360 .viewer-slider[max="36"][value="25"]+.viewer-image{background-position-y:68.5714285714%}.viewer-360 .viewer-slider[max="36"][value="26"]+.viewer-image{background-position-y:71.4285714286%}.viewer-360 .viewer-slider[max="36"][value="27"]+.viewer-image{background-position-y:74.2857142857%}.viewer-360 .viewer-slider[max="36"][value="28"]+.viewer-image{background-position-y:77.1428571429%}.viewer-360 .viewer-slider[max="36"][value="29"]+.viewer-image{background-position-y:80%}.viewer-360 .viewer-slider[max="36"][value="30"]+.viewer-image{background-position-y:82.8571428571%}.viewer-360 .viewer-slider[max="36"][value="31"]+.viewer-image{background-position-y:85.7142857143%}.viewer-360 .viewer-slider[max="36"][value="32"]+.viewer-image{background-position-y:88.5714285714%}.viewer-360 .viewer-slider[max="36"][value="33"]+.viewer-image{background-position-y:91.4285714286%}.viewer-360 .viewer-slider[max="36"][value="34"]+.viewer-image{background-position-y:94.2857142857%}.viewer-360 .viewer-slider[max="36"][value="35"]+.viewer-image{background-position-y:97.1428571429%}.viewer-360 .viewer-slider[max="36"][value="36"]+.viewer-image{background-position-y:100%}.viewer-360 .viewer-slider{cursor:ew-resize;-ms-flex-order:2;margin:1rem;order:2;width:60%}.viewer-360 .viewer-image{background-position-y:0;background-repeat:no-repeat;background-size:100%;-ms-flex-order:1;max-width:100%;order:1}
public/css/spectre-0.5.9/spectre-icons.css
New file
@@ -0,0 +1,597 @@
/*! Spectre.css Icons v0.5.9 | MIT License | github.com/picturepan2/spectre */
.icon {
  box-sizing: border-box;
  display: inline-block;
  font-size: inherit;
  font-style: normal;
  height: 1em;
  position: relative;
  text-indent: -9999px;
  vertical-align: middle;
  width: 1em;
}
.icon::before,
.icon::after {
  content: "";
  display: block;
  left: 50%;
  position: absolute;
  top: 50%;
  transform: translate(-50%, -50%);
}
.icon.icon-2x {
  font-size: 1.6rem;
}
.icon.icon-3x {
  font-size: 2.4rem;
}
.icon.icon-4x {
  font-size: 3.2rem;
}
.accordion .icon,
.btn .icon,
.toast .icon,
.menu .icon {
  vertical-align: -10%;
}
.btn-lg .icon {
  vertical-align: -15%;
}
.icon-arrow-down::before,
.icon-arrow-left::before,
.icon-arrow-right::before,
.icon-arrow-up::before,
.icon-downward::before,
.icon-back::before,
.icon-forward::before,
.icon-upward::before {
  border: .1rem solid currentColor;
  border-bottom: 0;
  border-right: 0;
  height: .65em;
  width: .65em;
}
.icon-arrow-down::before {
  transform: translate(-50%, -75%) rotate(225deg);
}
.icon-arrow-left::before {
  transform: translate(-25%, -50%) rotate(-45deg);
}
.icon-arrow-right::before {
  transform: translate(-75%, -50%) rotate(135deg);
}
.icon-arrow-up::before {
  transform: translate(-50%, -25%) rotate(45deg);
}
.icon-back::after,
.icon-forward::after {
  background: currentColor;
  height: .1rem;
  width: .8em;
}
.icon-downward::after,
.icon-upward::after {
  background: currentColor;
  height: .8em;
  width: .1rem;
}
.icon-back::after {
  left: 55%;
}
.icon-back::before {
  transform: translate(-50%, -50%) rotate(-45deg);
}
.icon-downward::after {
  top: 45%;
}
.icon-downward::before {
  transform: translate(-50%, -50%) rotate(-135deg);
}
.icon-forward::after {
  left: 45%;
}
.icon-forward::before {
  transform: translate(-50%, -50%) rotate(135deg);
}
.icon-upward::after {
  top: 55%;
}
.icon-upward::before {
  transform: translate(-50%, -50%) rotate(45deg);
}
.icon-caret::before {
  border-left: .3em solid transparent;
  border-right: .3em solid transparent;
  border-top: .3em solid currentColor;
  height: 0;
  transform: translate(-50%, -25%);
  width: 0;
}
.icon-menu::before {
  background: currentColor;
  box-shadow: 0 -.35em, 0 .35em;
  height: .1rem;
  width: 100%;
}
.icon-apps::before {
  background: currentColor;
  box-shadow: -.35em -.35em, -.35em 0, -.35em .35em, 0 -.35em, 0 .35em, .35em -.35em, .35em 0, .35em .35em;
  height: 3px;
  width: 3px;
}
.icon-resize-horiz::before,
.icon-resize-horiz::after,
.icon-resize-vert::before,
.icon-resize-vert::after {
  border: .1rem solid currentColor;
  border-bottom: 0;
  border-right: 0;
  height: .45em;
  width: .45em;
}
.icon-resize-horiz::before,
.icon-resize-vert::before {
  transform: translate(-50%, -90%) rotate(45deg);
}
.icon-resize-horiz::after,
.icon-resize-vert::after {
  transform: translate(-50%, -10%) rotate(225deg);
}
.icon-resize-horiz::before {
  transform: translate(-90%, -50%) rotate(-45deg);
}
.icon-resize-horiz::after {
  transform: translate(-10%, -50%) rotate(135deg);
}
.icon-more-horiz::before,
.icon-more-vert::before {
  background: currentColor;
  border-radius: 50%;
  box-shadow: -.4em 0, .4em 0;
  height: 3px;
  width: 3px;
}
.icon-more-vert::before {
  box-shadow: 0 -.4em, 0 .4em;
}
.icon-plus::before,
.icon-minus::before,
.icon-cross::before {
  background: currentColor;
  height: .1rem;
  width: 100%;
}
.icon-plus::after,
.icon-cross::after {
  background: currentColor;
  height: 100%;
  width: .1rem;
}
.icon-cross::before {
  width: 100%;
}
.icon-cross::after {
  height: 100%;
}
.icon-cross::before,
.icon-cross::after {
  transform: translate(-50%, -50%) rotate(45deg);
}
.icon-check::before {
  border: .1rem solid currentColor;
  border-right: 0;
  border-top: 0;
  height: .5em;
  transform: translate(-50%, -75%) rotate(-45deg);
  width: .9em;
}
.icon-stop {
  border: .1rem solid currentColor;
  border-radius: 50%;
}
.icon-stop::before {
  background: currentColor;
  height: .1rem;
  transform: translate(-50%, -50%) rotate(45deg);
  width: 1em;
}
.icon-shutdown {
  border: .1rem solid currentColor;
  border-radius: 50%;
  border-top-color: transparent;
}
.icon-shutdown::before {
  background: currentColor;
  content: "";
  height: .5em;
  top: .1em;
  width: .1rem;
}
.icon-refresh::before {
  border: .1rem solid currentColor;
  border-radius: 50%;
  border-right-color: transparent;
  height: 1em;
  width: 1em;
}
.icon-refresh::after {
  border: .2em solid currentColor;
  border-left-color: transparent;
  border-top-color: transparent;
  height: 0;
  left: 80%;
  top: 20%;
  width: 0;
}
.icon-search::before {
  border: .1rem solid currentColor;
  border-radius: 50%;
  height: .75em;
  left: 5%;
  top: 5%;
  transform: translate(0, 0) rotate(45deg);
  width: .75em;
}
.icon-search::after {
  background: currentColor;
  height: .1rem;
  left: 80%;
  top: 80%;
  transform: translate(-50%, -50%) rotate(45deg);
  width: .4em;
}
.icon-edit::before {
  border: .1rem solid currentColor;
  height: .4em;
  transform: translate(-40%, -60%) rotate(-45deg);
  width: .85em;
}
.icon-edit::after {
  border: .15em solid currentColor;
  border-right-color: transparent;
  border-top-color: transparent;
  height: 0;
  left: 5%;
  top: 95%;
  transform: translate(0, -100%);
  width: 0;
}
.icon-delete::before {
  border: .1rem solid currentColor;
  border-bottom-left-radius: .1rem;
  border-bottom-right-radius: .1rem;
  border-top: 0;
  height: .75em;
  top: 60%;
  width: .75em;
}
.icon-delete::after {
  background: currentColor;
  box-shadow: -.25em .2em, .25em .2em;
  height: .1rem;
  top: .05rem;
  width: .5em;
}
.icon-share {
  border: .1rem solid currentColor;
  border-radius: .1rem;
  border-right: 0;
  border-top: 0;
}
.icon-share::before {
  border: .1rem solid currentColor;
  border-left: 0;
  border-top: 0;
  height: .4em;
  left: 100%;
  top: .25em;
  transform: translate(-125%, -50%) rotate(-45deg);
  width: .4em;
}
.icon-share::after {
  border: .1rem solid currentColor;
  border-bottom: 0;
  border-radius: 75% 0;
  border-right: 0;
  height: .5em;
  width: .6em;
}
.icon-flag::before {
  background: currentColor;
  height: 1em;
  left: 15%;
  width: .1rem;
}
.icon-flag::after {
  border: .1rem solid currentColor;
  border-bottom-right-radius: .1rem;
  border-left: 0;
  border-top-right-radius: .1rem;
  height: .65em;
  left: 60%;
  top: 35%;
  width: .8em;
}
.icon-bookmark::before {
  border: .1rem solid currentColor;
  border-bottom: 0;
  border-top-left-radius: .1rem;
  border-top-right-radius: .1rem;
  height: .9em;
  width: .8em;
}
.icon-bookmark::after {
  border: .1rem solid currentColor;
  border-bottom: 0;
  border-left: 0;
  border-radius: .1rem;
  height: .5em;
  transform: translate(-50%, 35%) rotate(-45deg) skew(15deg, 15deg);
  width: .5em;
}
.icon-download,
.icon-upload {
  border-bottom: .1rem solid currentColor;
}
.icon-download::before,
.icon-upload::before {
  border: .1rem solid currentColor;
  border-bottom: 0;
  border-right: 0;
  height: .5em;
  transform: translate(-50%, -60%) rotate(-135deg);
  width: .5em;
}
.icon-download::after,
.icon-upload::after {
  background: currentColor;
  height: .6em;
  top: 40%;
  width: .1rem;
}
.icon-upload::before {
  transform: translate(-50%, -60%) rotate(45deg);
}
.icon-upload::after {
  top: 50%;
}
.icon-copy::before {
  border: .1rem solid currentColor;
  border-bottom: 0;
  border-radius: .1rem;
  border-right: 0;
  height: .8em;
  left: 40%;
  top: 35%;
  width: .8em;
}
.icon-copy::after {
  border: .1rem solid currentColor;
  border-radius: .1rem;
  height: .8em;
  left: 60%;
  top: 60%;
  width: .8em;
}
.icon-time {
  border: .1rem solid currentColor;
  border-radius: 50%;
}
.icon-time::before {
  background: currentColor;
  height: .4em;
  transform: translate(-50%, -75%);
  width: .1rem;
}
.icon-time::after {
  background: currentColor;
  height: .3em;
  transform: translate(-50%, -75%) rotate(90deg);
  transform-origin: 50% 90%;
  width: .1rem;
}
.icon-mail::before {
  border: .1rem solid currentColor;
  border-radius: .1rem;
  height: .8em;
  width: 1em;
}
.icon-mail::after {
  border: .1rem solid currentColor;
  border-right: 0;
  border-top: 0;
  height: .5em;
  transform: translate(-50%, -90%) rotate(-45deg) skew(10deg, 10deg);
  width: .5em;
}
.icon-people::before {
  border: .1rem solid currentColor;
  border-radius: 50%;
  height: .45em;
  top: 25%;
  width: .45em;
}
.icon-people::after {
  border: .1rem solid currentColor;
  border-radius: 50% 50% 0 0;
  height: .4em;
  top: 75%;
  width: .9em;
}
.icon-message {
  border: .1rem solid currentColor;
  border-bottom: 0;
  border-radius: .1rem;
  border-right: 0;
}
.icon-message::before {
  border: .1rem solid currentColor;
  border-bottom-right-radius: .1rem;
  border-left: 0;
  border-top: 0;
  height: .8em;
  left: 65%;
  top: 40%;
  width: .7em;
}
.icon-message::after {
  background: currentColor;
  border-radius: .1rem;
  height: .3em;
  left: 10%;
  top: 100%;
  transform: translate(0, -90%) rotate(45deg);
  width: .1rem;
}
.icon-photo {
  border: .1rem solid currentColor;
  border-radius: .1rem;
}
.icon-photo::before {
  border: .1rem solid currentColor;
  border-radius: 50%;
  height: .25em;
  left: 35%;
  top: 35%;
  width: .25em;
}
.icon-photo::after {
  border: .1rem solid currentColor;
  border-bottom: 0;
  border-left: 0;
  height: .5em;
  left: 60%;
  transform: translate(-50%, 25%) rotate(-45deg);
  width: .5em;
}
.icon-link::before,
.icon-link::after {
  border: .1rem solid currentColor;
  border-radius: 5em 0 0 5em;
  border-right: 0;
  height: .5em;
  width: .75em;
}
.icon-link::before {
  transform: translate(-70%, -45%) rotate(-45deg);
}
.icon-link::after {
  transform: translate(-30%, -55%) rotate(135deg);
}
.icon-location::before {
  border: .1rem solid currentColor;
  border-radius: 50% 50% 50% 0;
  height: .8em;
  transform: translate(-50%, -60%) rotate(-45deg);
  width: .8em;
}
.icon-location::after {
  border: .1rem solid currentColor;
  border-radius: 50%;
  height: .2em;
  transform: translate(-50%, -80%);
  width: .2em;
}
.icon-emoji {
  border: .1rem solid currentColor;
  border-radius: 50%;
}
.icon-emoji::before {
  border-radius: 50%;
  box-shadow: -.17em -.1em, .17em -.1em;
  height: .15em;
  width: .15em;
}
.icon-emoji::after {
  border: .1rem solid currentColor;
  border-bottom-color: transparent;
  border-radius: 50%;
  border-right-color: transparent;
  height: .5em;
  transform: translate(-50%, -40%) rotate(-135deg);
  width: .5em;
}
public/css/spectre-0.5.9/spectre-icons.min.css
New file
@@ -0,0 +1 @@
/*! Spectre.css Icons v0.5.9 | MIT License | github.com/picturepan2/spectre */.icon{box-sizing:border-box;display:inline-block;font-size:inherit;font-style:normal;height:1em;position:relative;text-indent:-9999px;vertical-align:middle;width:1em}.icon::after,.icon::before{content:"";display:block;left:50%;position:absolute;top:50%;transform:translate(-50%,-50%)}.icon.icon-2x{font-size:1.6rem}.icon.icon-3x{font-size:2.4rem}.icon.icon-4x{font-size:3.2rem}.accordion .icon,.btn .icon,.menu .icon,.toast .icon{vertical-align:-10%}.btn-lg .icon{vertical-align:-15%}.icon-arrow-down::before,.icon-arrow-left::before,.icon-arrow-right::before,.icon-arrow-up::before,.icon-back::before,.icon-downward::before,.icon-forward::before,.icon-upward::before{border:.1rem solid currentColor;border-bottom:0;border-right:0;height:.65em;width:.65em}.icon-arrow-down::before{transform:translate(-50%,-75%) rotate(225deg)}.icon-arrow-left::before{transform:translate(-25%,-50%) rotate(-45deg)}.icon-arrow-right::before{transform:translate(-75%,-50%) rotate(135deg)}.icon-arrow-up::before{transform:translate(-50%,-25%) rotate(45deg)}.icon-back::after,.icon-forward::after{background:currentColor;height:.1rem;width:.8em}.icon-downward::after,.icon-upward::after{background:currentColor;height:.8em;width:.1rem}.icon-back::after{left:55%}.icon-back::before{transform:translate(-50%,-50%) rotate(-45deg)}.icon-downward::after{top:45%}.icon-downward::before{transform:translate(-50%,-50%) rotate(-135deg)}.icon-forward::after{left:45%}.icon-forward::before{transform:translate(-50%,-50%) rotate(135deg)}.icon-upward::after{top:55%}.icon-upward::before{transform:translate(-50%,-50%) rotate(45deg)}.icon-caret::before{border-left:.3em solid transparent;border-right:.3em solid transparent;border-top:.3em solid currentColor;height:0;transform:translate(-50%,-25%);width:0}.icon-menu::before{background:currentColor;box-shadow:0 -.35em,0 .35em;height:.1rem;width:100%}.icon-apps::before{background:currentColor;box-shadow:-.35em -.35em,-.35em 0,-.35em .35em,0 -.35em,0 .35em,.35em -.35em,.35em 0,.35em .35em;height:3px;width:3px}.icon-resize-horiz::after,.icon-resize-horiz::before,.icon-resize-vert::after,.icon-resize-vert::before{border:.1rem solid currentColor;border-bottom:0;border-right:0;height:.45em;width:.45em}.icon-resize-horiz::before,.icon-resize-vert::before{transform:translate(-50%,-90%) rotate(45deg)}.icon-resize-horiz::after,.icon-resize-vert::after{transform:translate(-50%,-10%) rotate(225deg)}.icon-resize-horiz::before{transform:translate(-90%,-50%) rotate(-45deg)}.icon-resize-horiz::after{transform:translate(-10%,-50%) rotate(135deg)}.icon-more-horiz::before,.icon-more-vert::before{background:currentColor;border-radius:50%;box-shadow:-.4em 0,.4em 0;height:3px;width:3px}.icon-more-vert::before{box-shadow:0 -.4em,0 .4em}.icon-cross::before,.icon-minus::before,.icon-plus::before{background:currentColor;height:.1rem;width:100%}.icon-cross::after,.icon-plus::after{background:currentColor;height:100%;width:.1rem}.icon-cross::before{width:100%}.icon-cross::after{height:100%}.icon-cross::after,.icon-cross::before{transform:translate(-50%,-50%) rotate(45deg)}.icon-check::before{border:.1rem solid currentColor;border-right:0;border-top:0;height:.5em;transform:translate(-50%,-75%) rotate(-45deg);width:.9em}.icon-stop{border:.1rem solid currentColor;border-radius:50%}.icon-stop::before{background:currentColor;height:.1rem;transform:translate(-50%,-50%) rotate(45deg);width:1em}.icon-shutdown{border:.1rem solid currentColor;border-radius:50%;border-top-color:transparent}.icon-shutdown::before{background:currentColor;content:"";height:.5em;top:.1em;width:.1rem}.icon-refresh::before{border:.1rem solid currentColor;border-radius:50%;border-right-color:transparent;height:1em;width:1em}.icon-refresh::after{border:.2em solid currentColor;border-left-color:transparent;border-top-color:transparent;height:0;left:80%;top:20%;width:0}.icon-search::before{border:.1rem solid currentColor;border-radius:50%;height:.75em;left:5%;top:5%;transform:translate(0,0) rotate(45deg);width:.75em}.icon-search::after{background:currentColor;height:.1rem;left:80%;top:80%;transform:translate(-50%,-50%) rotate(45deg);width:.4em}.icon-edit::before{border:.1rem solid currentColor;height:.4em;transform:translate(-40%,-60%) rotate(-45deg);width:.85em}.icon-edit::after{border:.15em solid currentColor;border-right-color:transparent;border-top-color:transparent;height:0;left:5%;top:95%;transform:translate(0,-100%);width:0}.icon-delete::before{border:.1rem solid currentColor;border-bottom-left-radius:.1rem;border-bottom-right-radius:.1rem;border-top:0;height:.75em;top:60%;width:.75em}.icon-delete::after{background:currentColor;box-shadow:-.25em .2em,.25em .2em;height:.1rem;top:.05rem;width:.5em}.icon-share{border:.1rem solid currentColor;border-radius:.1rem;border-right:0;border-top:0}.icon-share::before{border:.1rem solid currentColor;border-left:0;border-top:0;height:.4em;left:100%;top:.25em;transform:translate(-125%,-50%) rotate(-45deg);width:.4em}.icon-share::after{border:.1rem solid currentColor;border-bottom:0;border-radius:75% 0;border-right:0;height:.5em;width:.6em}.icon-flag::before{background:currentColor;height:1em;left:15%;width:.1rem}.icon-flag::after{border:.1rem solid currentColor;border-bottom-right-radius:.1rem;border-left:0;border-top-right-radius:.1rem;height:.65em;left:60%;top:35%;width:.8em}.icon-bookmark::before{border:.1rem solid currentColor;border-bottom:0;border-top-left-radius:.1rem;border-top-right-radius:.1rem;height:.9em;width:.8em}.icon-bookmark::after{border:.1rem solid currentColor;border-bottom:0;border-left:0;border-radius:.1rem;height:.5em;transform:translate(-50%,35%) rotate(-45deg) skew(15deg,15deg);width:.5em}.icon-download,.icon-upload{border-bottom:.1rem solid currentColor}.icon-download::before,.icon-upload::before{border:.1rem solid currentColor;border-bottom:0;border-right:0;height:.5em;transform:translate(-50%,-60%) rotate(-135deg);width:.5em}.icon-download::after,.icon-upload::after{background:currentColor;height:.6em;top:40%;width:.1rem}.icon-upload::before{transform:translate(-50%,-60%) rotate(45deg)}.icon-upload::after{top:50%}.icon-copy::before{border:.1rem solid currentColor;border-bottom:0;border-radius:.1rem;border-right:0;height:.8em;left:40%;top:35%;width:.8em}.icon-copy::after{border:.1rem solid currentColor;border-radius:.1rem;height:.8em;left:60%;top:60%;width:.8em}.icon-time{border:.1rem solid currentColor;border-radius:50%}.icon-time::before{background:currentColor;height:.4em;transform:translate(-50%,-75%);width:.1rem}.icon-time::after{background:currentColor;height:.3em;transform:translate(-50%,-75%) rotate(90deg);transform-origin:50% 90%;width:.1rem}.icon-mail::before{border:.1rem solid currentColor;border-radius:.1rem;height:.8em;width:1em}.icon-mail::after{border:.1rem solid currentColor;border-right:0;border-top:0;height:.5em;transform:translate(-50%,-90%) rotate(-45deg) skew(10deg,10deg);width:.5em}.icon-people::before{border:.1rem solid currentColor;border-radius:50%;height:.45em;top:25%;width:.45em}.icon-people::after{border:.1rem solid currentColor;border-radius:50% 50% 0 0;height:.4em;top:75%;width:.9em}.icon-message{border:.1rem solid currentColor;border-bottom:0;border-radius:.1rem;border-right:0}.icon-message::before{border:.1rem solid currentColor;border-bottom-right-radius:.1rem;border-left:0;border-top:0;height:.8em;left:65%;top:40%;width:.7em}.icon-message::after{background:currentColor;border-radius:.1rem;height:.3em;left:10%;top:100%;transform:translate(0,-90%) rotate(45deg);width:.1rem}.icon-photo{border:.1rem solid currentColor;border-radius:.1rem}.icon-photo::before{border:.1rem solid currentColor;border-radius:50%;height:.25em;left:35%;top:35%;width:.25em}.icon-photo::after{border:.1rem solid currentColor;border-bottom:0;border-left:0;height:.5em;left:60%;transform:translate(-50%,25%) rotate(-45deg);width:.5em}.icon-link::after,.icon-link::before{border:.1rem solid currentColor;border-radius:5em 0 0 5em;border-right:0;height:.5em;width:.75em}.icon-link::before{transform:translate(-70%,-45%) rotate(-45deg)}.icon-link::after{transform:translate(-30%,-55%) rotate(135deg)}.icon-location::before{border:.1rem solid currentColor;border-radius:50% 50% 50% 0;height:.8em;transform:translate(-50%,-60%) rotate(-45deg);width:.8em}.icon-location::after{border:.1rem solid currentColor;border-radius:50%;height:.2em;transform:translate(-50%,-80%);width:.2em}.icon-emoji{border:.1rem solid currentColor;border-radius:50%}.icon-emoji::before{border-radius:50%;box-shadow:-.17em -.1em,.17em -.1em;height:.15em;width:.15em}.icon-emoji::after{border:.1rem solid currentColor;border-bottom-color:transparent;border-radius:50%;border-right-color:transparent;height:.5em;transform:translate(-50%,-40%) rotate(-135deg);width:.5em}
public/css/spectre-0.5.9/spectre.css
New file
@@ -0,0 +1,3760 @@
/*! Spectre.css v0.5.9 | MIT License | github.com/picturepan2/spectre */
/* Manually forked from Normalize.css */
/* normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */
/** 1. Change the default font family in all browsers (opinionated). 2. Correct the line height in all browsers. 3. Prevent adjustments of font size after orientation changes in IE on Windows Phone and in iOS. */
/* Document ========================================================================== */
html {
  font-family: sans-serif; /* 1 */
  -webkit-text-size-adjust: 100%; /* 3 */
  -ms-text-size-adjust: 100%; /* 3 */
}
/* Sections ========================================================================== */
/** Remove the margin in all browsers (opinionated). */
body {
  margin: 0;
}
/** Add the correct display in IE 9-. */
article,
aside,
footer,
header,
nav,
section {
  display: block;
}
/** Correct the font size and margin on `h1` elements within `section` and `article` contexts in Chrome, Firefox, and Safari. */
h1 {
  font-size: 2em;
  margin: .67em 0;
}
/* Grouping content ========================================================================== */
/** Add the correct display in IE 9-. 1. Add the correct display in IE. */
figcaption,
figure,
main {
  /* 1 */ display: block;
}
/** Add the correct margin in IE 8 (removed). */
/** 1. Add the correct box sizing in Firefox. 2. Show the overflow in Edge and IE. */
hr {
  box-sizing: content-box; /* 1 */
  height: 0; /* 1 */
  overflow: visible; /* 2 */
}
/** 1. Correct the inheritance and scaling of font size in all browsers. (removed) 2. Correct the odd `em` font sizing in all browsers. */
/* Text-level semantics ========================================================================== */
/** 1. Remove the gray background on active links in IE 10. 2. Remove gaps in links underline in iOS 8+ and Safari 8+. */
a {
  background-color: transparent; /* 1 */
  -webkit-text-decoration-skip: objects; /* 2 */
}
/** Remove the outline on focused links when they are also active or hovered in all browsers (opinionated). */
a:active,
a:hover {
  outline-width: 0;
}
/** Modify default styling of address. */
address {
  font-style: normal;
}
/** 1. Remove the bottom border in Firefox 39-. 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. (removed) */
/** Prevent the duplicate application of `bolder` by the next rule in Safari 6. */
b,
strong {
  font-weight: inherit;
}
/** Add the correct font weight in Chrome, Edge, and Safari. */
b,
strong {
  font-weight: bolder;
}
/** 1. Correct the inheritance and scaling of font size in all browsers. 2. Correct the odd `em` font sizing in all browsers. */
code,
kbd,
pre,
samp {
  font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace; /* 1 (changed) */
  font-size: 1em; /* 2 */
}
/** Add the correct font style in Android 4.3-. */
dfn {
  font-style: italic;
}
/** Add the correct background and color in IE 9-. (Removed) */
/** Add the correct font size in all browsers. */
small {
  font-size: 80%;
  font-weight: 400; /* (added) */
}
/** Prevent `sub` and `sup` elements from affecting the line height in all browsers. */
sub,
sup {
  font-size: 75%;
  line-height: 0;
  position: relative;
  vertical-align: baseline;
}
sub {
  bottom: -.25em;
}
sup {
  top: -.5em;
}
/* Embedded content ========================================================================== */
/** Add the correct display in IE 9-. */
audio,
video {
  display: inline-block;
}
/** Add the correct display in iOS 4-7. */
audio:not([controls]) {
  display: none;
  height: 0;
}
/** Remove the border on images inside links in IE 10-. */
img {
  border-style: none;
}
/** Hide the overflow in IE. */
svg:not(:root) {
  overflow: hidden;
}
/* Forms ========================================================================== */
/** 1. Change the font styles in all browsers (opinionated). 2. Remove the margin in Firefox and Safari. */
button,
input,
optgroup,
select,
textarea {
  font-family: inherit; /* 1 (changed) */
  font-size: inherit; /* 1 (changed) */
  line-height: inherit; /* 1 (changed) */
  margin: 0; /* 2 */
}
/** Show the overflow in IE. 1. Show the overflow in Edge. */
button,
input {
  /* 1 */ overflow: visible;
}
/** Remove the inheritance of text transform in Edge, Firefox, and IE. 1. Remove the inheritance of text transform in Firefox. */
button,
select {
  /* 1 */ text-transform: none;
}
/** 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` controls in Android 4. 2. Correct the inability to style clickable types in iOS and Safari. */
button,
html [type="button"],
[type="reset"],
[type="submit"] {
  -webkit-appearance: button; /* 2 */
}
/** Remove the inner border and padding in Firefox. */
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
  border-style: none;
  padding: 0;
}
/** Restore the focus styles unset by the previous rule (removed). */
/** Change the border, margin, and padding in all browsers (opinionated) (changed). */
fieldset {
  border: 0;
  margin: 0;
  padding: 0;
}
/** 1. Correct the text wrapping in Edge and IE. 2. Correct the color inheritance from `fieldset` elements in IE. 3. Remove the padding so developers are not caught out when they zero out `fieldset` elements in all browsers. */
legend {
  box-sizing: border-box; /* 1 */
  color: inherit; /* 2 */
  display: table; /* 1 */
  max-width: 100%; /* 1 */
  padding: 0; /* 3 */
  white-space: normal; /* 1 */
}
/** 1. Add the correct display in IE 9-. 2. Add the correct vertical alignment in Chrome, Firefox, and Opera. */
progress {
  display: inline-block; /* 1 */
  vertical-align: baseline; /* 2 */
}
/** Remove the default vertical scrollbar in IE. */
textarea {
  overflow: auto;
}
/** 1. Add the correct box sizing in IE 10-. 2. Remove the padding in IE 10-. */
[type="checkbox"],
[type="radio"] {
  box-sizing: border-box; /* 1 */
  padding: 0; /* 2 */
}
/** Correct the cursor style of increment and decrement buttons in Chrome. */
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
  height: auto;
}
/** 1. Correct the odd appearance in Chrome and Safari. 2. Correct the outline style in Safari. */
[type="search"] {
  -webkit-appearance: textfield; /* 1 */
  outline-offset: -2px; /* 2 */
}
/** Remove the inner padding and cancel buttons in Chrome and Safari on macOS. */
[type="search"]::-webkit-search-cancel-button,
[type="search"]::-webkit-search-decoration {
  -webkit-appearance: none;
}
/** 1. Correct the inability to style clickable types in iOS and Safari. 2. Change font properties to `inherit` in Safari. */
::-webkit-file-upload-button {
  -webkit-appearance: button; /* 1 */
  font: inherit; /* 2 */
}
/* Interactive ========================================================================== */
/* Add the correct display in IE 9-. 1. Add the correct display in Edge, IE, and Firefox. */
details,
menu {
  display: block;
}
/* Add the correct display in all browsers. */
summary {
  display: list-item;
  outline: none;
}
/* Scripting ========================================================================== */
/** Add the correct display in IE 9-. */
canvas {
  display: inline-block;
}
/** Add the correct display in IE. */
template {
  display: none;
}
/* Hidden ========================================================================== */
/** Add the correct display in IE 10-. */
[hidden] {
  display: none;
}
*,
*::before,
*::after {
  box-sizing: inherit;
}
html {
  box-sizing: border-box;
  font-size: 20px;
  line-height: 1.5;
  -webkit-tap-highlight-color: transparent;
}
body {
  background: #fff;
  color: #3b4351;
  font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
  font-size: .8rem;
  overflow-x: hidden;
  text-rendering: optimizeLegibility;
}
a {
  color: #5755d9;
  outline: none;
  text-decoration: none;
}
a:focus {
  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
}
a:focus,
a:hover,
a:active,
a.active {
  color: #302ecd;
  text-decoration: underline;
}
a:visited {
  color: #807fe2;
}
h1,
h2,
h3,
h4,
h5,
h6 {
  color: inherit;
  font-weight: 500;
  line-height: 1.2;
  margin-bottom: .5em;
  margin-top: 0;
}
.h1,
.h2,
.h3,
.h4,
.h5,
.h6 {
  font-weight: 500;
}
h1,
.h1 {
  font-size: 2rem;
}
h2,
.h2 {
  font-size: 1.6rem;
}
h3,
.h3 {
  font-size: 1.4rem;
}
h4,
.h4 {
  font-size: 1.2rem;
}
h5,
.h5 {
  font-size: 1rem;
}
h6,
.h6 {
  font-size: .8rem;
}
p {
  margin: 0 0 1.2rem;
}
a,
ins,
u {
  -webkit-text-decoration-skip: ink edges;
  text-decoration-skip: ink edges;
}
abbr[title] {
  border-bottom: .05rem dotted;
  cursor: help;
  text-decoration: none;
}
kbd {
  background: #303742;
  border-radius: .1rem;
  color: #fff;
  font-size: .7rem;
  line-height: 1.25;
  padding: .1rem .2rem;
}
mark {
  background: #ffe9b3;
  border-bottom: .05rem solid #ffd367;
  border-radius: .1rem;
  color: #3b4351;
  padding: .05rem .1rem 0;
}
blockquote {
  border-left: .1rem solid #dadee4;
  margin-left: 0;
  padding: .4rem .8rem;
}
blockquote p:last-child {
  margin-bottom: 0;
}
ul,
ol {
  margin: .8rem 0 .8rem .8rem;
  padding: 0;
}
ul ul,
ul ol,
ol ul,
ol ol {
  margin: .8rem 0 .8rem .8rem;
}
ul li,
ol li {
  margin-top: .4rem;
}
ul {
  list-style: disc inside;
}
ul ul {
  list-style-type: circle;
}
ol {
  list-style: decimal inside;
}
ol ol {
  list-style-type: lower-alpha;
}
dl dt {
  font-weight: bold;
}
dl dd {
  margin: .4rem 0 .8rem 0;
}
html:lang(zh),
html:lang(zh-Hans),
.lang-zh,
.lang-zh-hans {
  font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", sans-serif;
}
html:lang(zh-Hant),
.lang-zh-hant {
  font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang TC", "Hiragino Sans CNS", "Microsoft JhengHei", "Helvetica Neue", sans-serif;
}
html:lang(ja),
.lang-ja {
  font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Hiragino Sans", "Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo, "Helvetica Neue", sans-serif;
}
html:lang(ko),
.lang-ko {
  font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Malgun Gothic", "Helvetica Neue", sans-serif;
}
:lang(zh) ins,
:lang(zh) u,
:lang(ja) ins,
:lang(ja) u,
.lang-cjk ins,
.lang-cjk u {
  border-bottom: .05rem solid;
  text-decoration: none;
}
:lang(zh) del + del,
:lang(zh) del + s,
:lang(zh) ins + ins,
:lang(zh) ins + u,
:lang(zh) s + del,
:lang(zh) s + s,
:lang(zh) u + ins,
:lang(zh) u + u,
:lang(ja) del + del,
:lang(ja) del + s,
:lang(ja) ins + ins,
:lang(ja) ins + u,
:lang(ja) s + del,
:lang(ja) s + s,
:lang(ja) u + ins,
:lang(ja) u + u,
.lang-cjk del + del,
.lang-cjk del + s,
.lang-cjk ins + ins,
.lang-cjk ins + u,
.lang-cjk s + del,
.lang-cjk s + s,
.lang-cjk u + ins,
.lang-cjk u + u {
  margin-left: .125em;
}
.table {
  border-collapse: collapse;
  border-spacing: 0;
  text-align: left;
  width: 100%;
}
.table.table-striped tbody tr:nth-of-type(odd) {
  background: #f7f8f9;
}
.table tbody tr.active,
.table.table-striped tbody tr.active {
  background: #eef0f3;
}
.table.table-hover tbody tr:hover {
  background: #eef0f3;
}
.table.table-scroll {
  display: block;
  overflow-x: auto;
  padding-bottom: .75rem;
  white-space: nowrap;
}
.table td,
.table th {
  border-bottom: .05rem solid #dadee4;
  padding: .6rem .4rem;
}
.table th {
  border-bottom-width: .1rem;
}
.btn {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background: #fff;
  border: .05rem solid #5755d9;
  border-radius: .1rem;
  color: #5755d9;
  cursor: pointer;
  display: inline-block;
  font-size: .8rem;
  height: 1.8rem;
  line-height: 1.2rem;
  outline: none;
  padding: .25rem .4rem;
  text-align: center;
  text-decoration: none;
  transition: background .2s, border .2s, box-shadow .2s, color .2s;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
  vertical-align: middle;
  white-space: nowrap;
}
.btn:focus {
  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
}
.btn:focus,
.btn:hover {
  background: #f1f1fc;
  border-color: #4b48d6;
  text-decoration: none;
}
.btn:active,
.btn.active {
  background: #4b48d6;
  border-color: #3634d2;
  color: #fff;
  text-decoration: none;
}
.btn:active.loading::after,
.btn.active.loading::after {
  border-bottom-color: #fff;
  border-left-color: #fff;
}
.btn[disabled],
.btn:disabled,
.btn.disabled {
  cursor: default;
  opacity: .5;
  pointer-events: none;
}
.btn.btn-primary {
  background: #5755d9;
  border-color: #4b48d6;
  color: #fff;
}
.btn.btn-primary:focus,
.btn.btn-primary:hover {
  background: #4240d4;
  border-color: #3634d2;
  color: #fff;
}
.btn.btn-primary:active,
.btn.btn-primary.active {
  background: #3a38d2;
  border-color: #302ecd;
  color: #fff;
}
.btn.btn-primary.loading::after {
  border-bottom-color: #fff;
  border-left-color: #fff;
}
.btn.btn-success {
  background: #32b643;
  border-color: #2faa3f;
  color: #fff;
}
.btn.btn-success:focus {
  box-shadow: 0 0 0 .1rem rgba(50, 182, 67, .2);
}
.btn.btn-success:focus,
.btn.btn-success:hover {
  background: #30ae40;
  border-color: #2da23c;
  color: #fff;
}
.btn.btn-success:active,
.btn.btn-success.active {
  background: #2a9a39;
  border-color: #278e34;
  color: #fff;
}
.btn.btn-success.loading::after {
  border-bottom-color: #fff;
  border-left-color: #fff;
}
.btn.btn-error {
  background: #e85600;
  border-color: #d95000;
  color: #fff;
}
.btn.btn-error:focus {
  box-shadow: 0 0 0 .1rem rgba(232, 86, 0, .2);
}
.btn.btn-error:focus,
.btn.btn-error:hover {
  background: #de5200;
  border-color: #cf4d00;
  color: #fff;
}
.btn.btn-error:active,
.btn.btn-error.active {
  background: #c44900;
  border-color: #b54300;
  color: #fff;
}
.btn.btn-error.loading::after {
  border-bottom-color: #fff;
  border-left-color: #fff;
}
.btn.btn-link {
  background: transparent;
  border-color: transparent;
  color: #5755d9;
}
.btn.btn-link:focus,
.btn.btn-link:hover,
.btn.btn-link:active,
.btn.btn-link.active {
  color: #302ecd;
}
.btn.btn-sm {
  font-size: .7rem;
  height: 1.4rem;
  padding: .05rem .3rem;
}
.btn.btn-lg {
  font-size: .9rem;
  height: 2rem;
  padding: .35rem .6rem;
}
.btn.btn-block {
  display: block;
  width: 100%;
}
.btn.btn-action {
  padding-left: 0;
  padding-right: 0;
  width: 1.8rem;
}
.btn.btn-action.btn-sm {
  width: 1.4rem;
}
.btn.btn-action.btn-lg {
  width: 2rem;
}
.btn.btn-clear {
  background: transparent;
  border: 0;
  color: currentColor;
  height: 1rem;
  line-height: .8rem;
  margin-left: .2rem;
  margin-right: -2px;
  opacity: 1;
  padding: .1rem;
  text-decoration: none;
  width: 1rem;
}
.btn.btn-clear:focus,
.btn.btn-clear:hover {
  background: rgba(247, 248, 249, .5);
  opacity: .95;
}
.btn.btn-clear::before {
  content: "\2715";
}
.btn-group {
  display: -ms-inline-flexbox;
  display: inline-flex;
  -ms-flex-wrap: wrap;
  flex-wrap: wrap;
}
.btn-group .btn {
  -ms-flex: 1 0 auto;
  flex: 1 0 auto;
}
.btn-group .btn:first-child:not(:last-child) {
  border-bottom-right-radius: 0;
  border-top-right-radius: 0;
}
.btn-group .btn:not(:first-child):not(:last-child) {
  border-radius: 0;
  margin-left: -.05rem;
}
.btn-group .btn:last-child:not(:first-child) {
  border-bottom-left-radius: 0;
  border-top-left-radius: 0;
  margin-left: -.05rem;
}
.btn-group .btn:focus,
.btn-group .btn:hover,
.btn-group .btn:active,
.btn-group .btn.active {
  z-index: 1;
}
.btn-group.btn-group-block {
  display: -ms-flexbox;
  display: flex;
}
.btn-group.btn-group-block .btn {
  -ms-flex: 1 0 0;
  flex: 1 0 0;
}
.form-group:not(:last-child) {
  margin-bottom: .4rem;
}
fieldset {
  margin-bottom: .8rem;
}
legend {
  font-size: .9rem;
  font-weight: 500;
  margin-bottom: .8rem;
}
.form-label {
  display: block;
  line-height: 1.2rem;
  padding: .3rem 0;
}
.form-label.label-sm {
  font-size: .7rem;
  padding: .1rem 0;
}
.form-label.label-lg {
  font-size: .9rem;
  padding: .4rem 0;
}
.form-input {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background: #fff;
  background-image: none;
  border: .05rem solid #bcc3ce;
  border-radius: .1rem;
  color: #3b4351;
  display: block;
  font-size: .8rem;
  height: 1.8rem;
  line-height: 1.2rem;
  max-width: 100%;
  outline: none;
  padding: .25rem .4rem;
  position: relative;
  transition: background .2s, border .2s, box-shadow .2s, color .2s;
  width: 100%;
}
.form-input:focus {
  border-color: #5755d9;
  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
}
.form-input:-ms-input-placeholder {
  color: #bcc3ce;
}
.form-input::-ms-input-placeholder {
  color: #bcc3ce;
}
.form-input::placeholder {
  color: #bcc3ce;
}
.form-input.input-sm {
  font-size: .7rem;
  height: 1.4rem;
  padding: .05rem .3rem;
}
.form-input.input-lg {
  font-size: .9rem;
  height: 2rem;
  padding: .35rem .6rem;
}
.form-input.input-inline {
  display: inline-block;
  vertical-align: middle;
  width: auto;
}
.form-input[type="file"] {
  height: auto;
}
textarea.form-input,
textarea.form-input.input-lg,
textarea.form-input.input-sm {
  height: auto;
}
.form-input-hint {
  color: #bcc3ce;
  font-size: .7rem;
  margin-top: .2rem;
}
.has-success .form-input-hint,
.is-success + .form-input-hint {
  color: #32b643;
}
.has-error .form-input-hint,
.is-error + .form-input-hint {
  color: #e85600;
}
.form-select {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background: #fff;
  border: .05rem solid #bcc3ce;
  border-radius: .1rem;
  color: inherit;
  font-size: .8rem;
  height: 1.8rem;
  line-height: 1.2rem;
  outline: none;
  padding: .25rem .4rem;
  vertical-align: middle;
  width: 100%;
}
.form-select:focus {
  border-color: #5755d9;
  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
}
.form-select::-ms-expand {
  display: none;
}
.form-select.select-sm {
  font-size: .7rem;
  height: 1.4rem;
  padding: .05rem 1.1rem .05rem .3rem;
}
.form-select.select-lg {
  font-size: .9rem;
  height: 2rem;
  padding: .35rem 1.4rem .35rem .6rem;
}
.form-select[size],
.form-select[multiple] {
  height: auto;
  padding: .25rem .4rem;
}
.form-select[size] option,
.form-select[multiple] option {
  padding: .1rem .2rem;
}
.form-select:not([multiple]):not([size]) {
  background: #fff url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center/.4rem .5rem;
  padding-right: 1.2rem;
}
.has-icon-left,
.has-icon-right {
  position: relative;
}
.has-icon-left .form-icon,
.has-icon-right .form-icon {
  height: .8rem;
  margin: 0 .25rem;
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: .8rem;
  z-index: 2;
}
.has-icon-left .form-icon {
  left: .05rem;
}
.has-icon-left .form-input {
  padding-left: 1.3rem;
}
.has-icon-right .form-icon {
  right: .05rem;
}
.has-icon-right .form-input {
  padding-right: 1.3rem;
}
.form-checkbox,
.form-radio,
.form-switch {
  display: block;
  line-height: 1.2rem;
  margin: .2rem 0;
  min-height: 1.4rem;
  padding: .1rem .4rem .1rem 1.2rem;
  position: relative;
}
.form-checkbox input,
.form-radio input,
.form-switch input {
  clip: rect(0, 0, 0, 0);
  height: 1px;
  margin: -1px;
  overflow: hidden;
  position: absolute;
  width: 1px;
}
.form-checkbox input:focus + .form-icon,
.form-radio input:focus + .form-icon,
.form-switch input:focus + .form-icon {
  border-color: #5755d9;
  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
}
.form-checkbox input:checked + .form-icon,
.form-radio input:checked + .form-icon,
.form-switch input:checked + .form-icon {
  background: #5755d9;
  border-color: #5755d9;
}
.form-checkbox .form-icon,
.form-radio .form-icon,
.form-switch .form-icon {
  border: .05rem solid #bcc3ce;
  cursor: pointer;
  display: inline-block;
  position: absolute;
  transition: background .2s, border .2s, box-shadow .2s, color .2s;
}
.form-checkbox.input-sm,
.form-radio.input-sm,
.form-switch.input-sm {
  font-size: .7rem;
  margin: 0;
}
.form-checkbox.input-lg,
.form-radio.input-lg,
.form-switch.input-lg {
  font-size: .9rem;
  margin: .3rem 0;
}
.form-checkbox .form-icon,
.form-radio .form-icon {
  background: #fff;
  height: .8rem;
  left: 0;
  top: .3rem;
  width: .8rem;
}
.form-checkbox input:active + .form-icon,
.form-radio input:active + .form-icon {
  background: #eef0f3;
}
.form-checkbox .form-icon {
  border-radius: .1rem;
}
.form-checkbox input:checked + .form-icon::before {
  background-clip: padding-box;
  border: .1rem solid #fff;
  border-left-width: 0;
  border-top-width: 0;
  content: "";
  height: 9px;
  left: 50%;
  margin-left: -3px;
  margin-top: -6px;
  position: absolute;
  top: 50%;
  transform: rotate(45deg);
  width: 6px;
}
.form-checkbox input:indeterminate + .form-icon {
  background: #5755d9;
  border-color: #5755d9;
}
.form-checkbox input:indeterminate + .form-icon::before {
  background: #fff;
  content: "";
  height: 2px;
  left: 50%;
  margin-left: -5px;
  margin-top: -1px;
  position: absolute;
  top: 50%;
  width: 10px;
}
.form-radio .form-icon {
  border-radius: 50%;
}
.form-radio input:checked + .form-icon::before {
  background: #fff;
  border-radius: 50%;
  content: "";
  height: 6px;
  left: 50%;
  position: absolute;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 6px;
}
.form-switch {
  padding-left: 2rem;
}
.form-switch .form-icon {
  background: #bcc3ce;
  background-clip: padding-box;
  border-radius: .45rem;
  height: .9rem;
  left: 0;
  top: .25rem;
  width: 1.6rem;
}
.form-switch .form-icon::before {
  background: #fff;
  border-radius: 50%;
  content: "";
  display: block;
  height: .8rem;
  left: 0;
  position: absolute;
  top: 0;
  transition: background .2s, border .2s, box-shadow .2s, color .2s, left .2s;
  width: .8rem;
}
.form-switch input:checked + .form-icon::before {
  left: 14px;
}
.form-switch input:active + .form-icon::before {
  background: #f7f8f9;
}
.input-group {
  display: -ms-flexbox;
  display: flex;
}
.input-group .input-group-addon {
  background: #f7f8f9;
  border: .05rem solid #bcc3ce;
  border-radius: .1rem;
  line-height: 1.2rem;
  padding: .25rem .4rem;
  white-space: nowrap;
}
.input-group .input-group-addon.addon-sm {
  font-size: .7rem;
  padding: .05rem .3rem;
}
.input-group .input-group-addon.addon-lg {
  font-size: .9rem;
  padding: .35rem .6rem;
}
.input-group .form-input,
.input-group .form-select {
  -ms-flex: 1 1 auto;
  flex: 1 1 auto;
  width: 1%;
}
.input-group .input-group-btn {
  z-index: 1;
}
.input-group .form-input:first-child:not(:last-child),
.input-group .form-select:first-child:not(:last-child),
.input-group .input-group-addon:first-child:not(:last-child),
.input-group .input-group-btn:first-child:not(:last-child) {
  border-bottom-right-radius: 0;
  border-top-right-radius: 0;
}
.input-group .form-input:not(:first-child):not(:last-child),
.input-group .form-select:not(:first-child):not(:last-child),
.input-group .input-group-addon:not(:first-child):not(:last-child),
.input-group .input-group-btn:not(:first-child):not(:last-child) {
  border-radius: 0;
  margin-left: -.05rem;
}
.input-group .form-input:last-child:not(:first-child),
.input-group .form-select:last-child:not(:first-child),
.input-group .input-group-addon:last-child:not(:first-child),
.input-group .input-group-btn:last-child:not(:first-child) {
  border-bottom-left-radius: 0;
  border-top-left-radius: 0;
  margin-left: -.05rem;
}
.input-group .form-input:focus,
.input-group .form-select:focus,
.input-group .input-group-addon:focus,
.input-group .input-group-btn:focus {
  z-index: 2;
}
.input-group .form-select {
  width: auto;
}
.input-group.input-inline {
  display: -ms-inline-flexbox;
  display: inline-flex;
}
.has-success .form-input,
.form-input.is-success,
.has-success .form-select,
.form-select.is-success {
  background: #f9fdfa;
  border-color: #32b643;
}
.has-success .form-input:focus,
.form-input.is-success:focus,
.has-success .form-select:focus,
.form-select.is-success:focus {
  box-shadow: 0 0 0 .1rem rgba(50, 182, 67, .2);
}
.has-error .form-input,
.form-input.is-error,
.has-error .form-select,
.form-select.is-error {
  background: #fffaf7;
  border-color: #e85600;
}
.has-error .form-input:focus,
.form-input.is-error:focus,
.has-error .form-select:focus,
.form-select.is-error:focus {
  box-shadow: 0 0 0 .1rem rgba(232, 86, 0, .2);
}
.has-error .form-checkbox .form-icon,
.form-checkbox.is-error .form-icon,
.has-error .form-radio .form-icon,
.form-radio.is-error .form-icon,
.has-error .form-switch .form-icon,
.form-switch.is-error .form-icon {
  border-color: #e85600;
}
.has-error .form-checkbox input:checked + .form-icon,
.form-checkbox.is-error input:checked + .form-icon,
.has-error .form-radio input:checked + .form-icon,
.form-radio.is-error input:checked + .form-icon,
.has-error .form-switch input:checked + .form-icon,
.form-switch.is-error input:checked + .form-icon {
  background: #e85600;
  border-color: #e85600;
}
.has-error .form-checkbox input:focus + .form-icon,
.form-checkbox.is-error input:focus + .form-icon,
.has-error .form-radio input:focus + .form-icon,
.form-radio.is-error input:focus + .form-icon,
.has-error .form-switch input:focus + .form-icon,
.form-switch.is-error input:focus + .form-icon {
  border-color: #e85600;
  box-shadow: 0 0 0 .1rem rgba(232, 86, 0, .2);
}
.has-error .form-checkbox input:indeterminate + .form-icon,
.form-checkbox.is-error input:indeterminate + .form-icon {
  background: #e85600;
  border-color: #e85600;
}
.form-input:not(:-ms-input-placeholder):invalid {
  border-color: #e85600;
}
.form-input:not(:placeholder-shown):invalid {
  border-color: #e85600;
}
.form-input:not(:-ms-input-placeholder):invalid:focus {
  background: #fffaf7;
  box-shadow: 0 0 0 .1rem rgba(232, 86, 0, .2);
}
.form-input:not(:placeholder-shown):invalid:focus {
  background: #fffaf7;
  box-shadow: 0 0 0 .1rem rgba(232, 86, 0, .2);
}
.form-input:not(:-ms-input-placeholder):invalid + .form-input-hint {
  color: #e85600;
}
.form-input:not(:placeholder-shown):invalid + .form-input-hint {
  color: #e85600;
}
.form-input:disabled,
.form-input.disabled,
.form-select:disabled,
.form-select.disabled {
  background-color: #eef0f3;
  cursor: not-allowed;
  opacity: .5;
}
.form-input[readonly] {
  background-color: #f7f8f9;
}
input:disabled + .form-icon,
input.disabled + .form-icon {
  background: #eef0f3;
  cursor: not-allowed;
  opacity: .5;
}
.form-switch input:disabled + .form-icon::before,
.form-switch input.disabled + .form-icon::before {
  background: #fff;
}
.form-horizontal {
  padding: .4rem 0;
}
.form-horizontal .form-group {
  display: -ms-flexbox;
  display: flex;
  -ms-flex-wrap: wrap;
  flex-wrap: wrap;
}
.form-inline {
  display: inline-block;
}
.label {
  background: #eef0f3;
  border-radius: .1rem;
  color: #455060;
  display: inline-block;
  line-height: 1.25;
  padding: .1rem .2rem;
}
.label.label-rounded {
  border-radius: 5rem;
  padding-left: .4rem;
  padding-right: .4rem;
}
.label.label-primary {
  background: #5755d9;
  color: #fff;
}
.label.label-secondary {
  background: #f1f1fc;
  color: #5755d9;
}
.label.label-success {
  background: #32b643;
  color: #fff;
}
.label.label-warning {
  background: #ffb700;
  color: #fff;
}
.label.label-error {
  background: #e85600;
  color: #fff;
}
code {
  background: #fcf2f2;
  border-radius: .1rem;
  color: #d73e48;
  font-size: 85%;
  line-height: 1.25;
  padding: .1rem .2rem;
}
.code {
  border-radius: .1rem;
  color: #3b4351;
  position: relative;
}
.code::before {
  color: #bcc3ce;
  content: attr(data-lang);
  font-size: .7rem;
  position: absolute;
  right: .4rem;
  top: .1rem;
}
.code code {
  background: #f7f8f9;
  color: inherit;
  display: block;
  line-height: 1.5;
  overflow-x: auto;
  padding: 1rem;
  width: 100%;
}
.img-responsive {
  display: block;
  height: auto;
  max-width: 100%;
}
.img-fit-cover {
  object-fit: cover;
}
.img-fit-contain {
  object-fit: contain;
}
.video-responsive {
  display: block;
  overflow: hidden;
  padding: 0;
  position: relative;
  width: 100%;
}
.video-responsive::before {
  content: "";
  display: block;
  padding-bottom: 56.25%;
}
.video-responsive iframe,
.video-responsive object,
.video-responsive embed {
  border: 0;
  bottom: 0;
  height: 100%;
  left: 0;
  position: absolute;
  right: 0;
  top: 0;
  width: 100%;
}
video.video-responsive {
  height: auto;
  max-width: 100%;
}
video.video-responsive::before {
  content: none;
}
.video-responsive-4-3::before {
  padding-bottom: 75%;
}
.video-responsive-1-1::before {
  padding-bottom: 100%;
}
.figure {
  margin: 0 0 .4rem 0;
}
.figure .figure-caption {
  color: #66758c;
  margin-top: .4rem;
}
.container {
  margin-left: auto;
  margin-right: auto;
  padding-left: .4rem;
  padding-right: .4rem;
  width: 100%;
}
.container.grid-xl {
  max-width: 1296px;
}
.container.grid-lg {
  max-width: 976px;
}
.container.grid-md {
  max-width: 856px;
}
.container.grid-sm {
  max-width: 616px;
}
.container.grid-xs {
  max-width: 496px;
}
.show-xs,
.show-sm,
.show-md,
.show-lg,
.show-xl {
  display: none !important;
}
.cols,
.columns {
  display: -ms-flexbox;
  display: flex;
  -ms-flex-wrap: wrap;
  flex-wrap: wrap;
  margin-left: -.4rem;
  margin-right: -.4rem;
}
.cols.col-gapless,
.columns.col-gapless {
  margin-left: 0;
  margin-right: 0;
}
.cols.col-gapless > .column,
.columns.col-gapless > .column {
  padding-left: 0;
  padding-right: 0;
}
.cols.col-oneline,
.columns.col-oneline {
  -ms-flex-wrap: nowrap;
  flex-wrap: nowrap;
  overflow-x: auto;
}
[class~="col-"],
.column {
  -ms-flex: 1;
  flex: 1;
  max-width: 100%;
  padding-left: .4rem;
  padding-right: .4rem;
}
[class~="col-"].col-12,
[class~="col-"].col-11,
[class~="col-"].col-10,
[class~="col-"].col-9,
[class~="col-"].col-8,
[class~="col-"].col-7,
[class~="col-"].col-6,
[class~="col-"].col-5,
[class~="col-"].col-4,
[class~="col-"].col-3,
[class~="col-"].col-2,
[class~="col-"].col-1,
[class~="col-"].col-auto,
.column.col-12,
.column.col-11,
.column.col-10,
.column.col-9,
.column.col-8,
.column.col-7,
.column.col-6,
.column.col-5,
.column.col-4,
.column.col-3,
.column.col-2,
.column.col-1,
.column.col-auto {
  -ms-flex: none;
  flex: none;
}
.col-12 {
  width: 100%;
}
.col-11 {
  width: 91.66666667%;
}
.col-10 {
  width: 83.33333333%;
}
.col-9 {
  width: 75%;
}
.col-8 {
  width: 66.66666667%;
}
.col-7 {
  width: 58.33333333%;
}
.col-6 {
  width: 50%;
}
.col-5 {
  width: 41.66666667%;
}
.col-4 {
  width: 33.33333333%;
}
.col-3 {
  width: 25%;
}
.col-2 {
  width: 16.66666667%;
}
.col-1 {
  width: 8.33333333%;
}
.col-auto {
  -ms-flex: 0 0 auto;
  flex: 0 0 auto;
  max-width: none;
  width: auto;
}
.col-mx-auto {
  margin-left: auto;
  margin-right: auto;
}
.col-ml-auto {
  margin-left: auto;
}
.col-mr-auto {
  margin-right: auto;
}
@media (max-width: 1280px) {
  .col-xl-12,
  .col-xl-11,
  .col-xl-10,
  .col-xl-9,
  .col-xl-8,
  .col-xl-7,
  .col-xl-6,
  .col-xl-5,
  .col-xl-4,
  .col-xl-3,
  .col-xl-2,
  .col-xl-1,
  .col-xl-auto {
    -ms-flex: none;
    flex: none;
  }
  .col-xl-12 {
    width: 100%;
  }
  .col-xl-11 {
    width: 91.66666667%;
  }
  .col-xl-10 {
    width: 83.33333333%;
  }
  .col-xl-9 {
    width: 75%;
  }
  .col-xl-8 {
    width: 66.66666667%;
  }
  .col-xl-7 {
    width: 58.33333333%;
  }
  .col-xl-6 {
    width: 50%;
  }
  .col-xl-5 {
    width: 41.66666667%;
  }
  .col-xl-4 {
    width: 33.33333333%;
  }
  .col-xl-3 {
    width: 25%;
  }
  .col-xl-2 {
    width: 16.66666667%;
  }
  .col-xl-1 {
    width: 8.33333333%;
  }
  .col-xl-auto {
    width: auto;
  }
  .hide-xl {
    display: none !important;
  }
  .show-xl {
    display: block !important;
  }
}
@media (max-width: 960px) {
  .col-lg-12,
  .col-lg-11,
  .col-lg-10,
  .col-lg-9,
  .col-lg-8,
  .col-lg-7,
  .col-lg-6,
  .col-lg-5,
  .col-lg-4,
  .col-lg-3,
  .col-lg-2,
  .col-lg-1,
  .col-lg-auto {
    -ms-flex: none;
    flex: none;
  }
  .col-lg-12 {
    width: 100%;
  }
  .col-lg-11 {
    width: 91.66666667%;
  }
  .col-lg-10 {
    width: 83.33333333%;
  }
  .col-lg-9 {
    width: 75%;
  }
  .col-lg-8 {
    width: 66.66666667%;
  }
  .col-lg-7 {
    width: 58.33333333%;
  }
  .col-lg-6 {
    width: 50%;
  }
  .col-lg-5 {
    width: 41.66666667%;
  }
  .col-lg-4 {
    width: 33.33333333%;
  }
  .col-lg-3 {
    width: 25%;
  }
  .col-lg-2 {
    width: 16.66666667%;
  }
  .col-lg-1 {
    width: 8.33333333%;
  }
  .col-lg-auto {
    width: auto;
  }
  .hide-lg {
    display: none !important;
  }
  .show-lg {
    display: block !important;
  }
}
@media (max-width: 840px) {
  .col-md-12,
  .col-md-11,
  .col-md-10,
  .col-md-9,
  .col-md-8,
  .col-md-7,
  .col-md-6,
  .col-md-5,
  .col-md-4,
  .col-md-3,
  .col-md-2,
  .col-md-1,
  .col-md-auto {
    -ms-flex: none;
    flex: none;
  }
  .col-md-12 {
    width: 100%;
  }
  .col-md-11 {
    width: 91.66666667%;
  }
  .col-md-10 {
    width: 83.33333333%;
  }
  .col-md-9 {
    width: 75%;
  }
  .col-md-8 {
    width: 66.66666667%;
  }
  .col-md-7 {
    width: 58.33333333%;
  }
  .col-md-6 {
    width: 50%;
  }
  .col-md-5 {
    width: 41.66666667%;
  }
  .col-md-4 {
    width: 33.33333333%;
  }
  .col-md-3 {
    width: 25%;
  }
  .col-md-2 {
    width: 16.66666667%;
  }
  .col-md-1 {
    width: 8.33333333%;
  }
  .col-md-auto {
    width: auto;
  }
  .hide-md {
    display: none !important;
  }
  .show-md {
    display: block !important;
  }
}
@media (max-width: 600px) {
  .col-sm-12,
  .col-sm-11,
  .col-sm-10,
  .col-sm-9,
  .col-sm-8,
  .col-sm-7,
  .col-sm-6,
  .col-sm-5,
  .col-sm-4,
  .col-sm-3,
  .col-sm-2,
  .col-sm-1,
  .col-sm-auto {
    -ms-flex: none;
    flex: none;
  }
  .col-sm-12 {
    width: 100%;
  }
  .col-sm-11 {
    width: 91.66666667%;
  }
  .col-sm-10 {
    width: 83.33333333%;
  }
  .col-sm-9 {
    width: 75%;
  }
  .col-sm-8 {
    width: 66.66666667%;
  }
  .col-sm-7 {
    width: 58.33333333%;
  }
  .col-sm-6 {
    width: 50%;
  }
  .col-sm-5 {
    width: 41.66666667%;
  }
  .col-sm-4 {
    width: 33.33333333%;
  }
  .col-sm-3 {
    width: 25%;
  }
  .col-sm-2 {
    width: 16.66666667%;
  }
  .col-sm-1 {
    width: 8.33333333%;
  }
  .col-sm-auto {
    width: auto;
  }
  .hide-sm {
    display: none !important;
  }
  .show-sm {
    display: block !important;
  }
}
@media (max-width: 480px) {
  .col-xs-12,
  .col-xs-11,
  .col-xs-10,
  .col-xs-9,
  .col-xs-8,
  .col-xs-7,
  .col-xs-6,
  .col-xs-5,
  .col-xs-4,
  .col-xs-3,
  .col-xs-2,
  .col-xs-1,
  .col-xs-auto {
    -ms-flex: none;
    flex: none;
  }
  .col-xs-12 {
    width: 100%;
  }
  .col-xs-11 {
    width: 91.66666667%;
  }
  .col-xs-10 {
    width: 83.33333333%;
  }
  .col-xs-9 {
    width: 75%;
  }
  .col-xs-8 {
    width: 66.66666667%;
  }
  .col-xs-7 {
    width: 58.33333333%;
  }
  .col-xs-6 {
    width: 50%;
  }
  .col-xs-5 {
    width: 41.66666667%;
  }
  .col-xs-4 {
    width: 33.33333333%;
  }
  .col-xs-3 {
    width: 25%;
  }
  .col-xs-2 {
    width: 16.66666667%;
  }
  .col-xs-1 {
    width: 8.33333333%;
  }
  .col-xs-auto {
    width: auto;
  }
  .hide-xs {
    display: none !important;
  }
  .show-xs {
    display: block !important;
  }
}
.hero {
  display: -ms-flexbox;
  display: flex;
  -ms-flex-direction: column;
  flex-direction: column;
  -ms-flex-pack: justify;
  justify-content: space-between;
  padding-bottom: 4rem;
  padding-top: 4rem;
}
.hero.hero-sm {
  padding-bottom: 2rem;
  padding-top: 2rem;
}
.hero.hero-lg {
  padding-bottom: 8rem;
  padding-top: 8rem;
}
.hero .hero-body {
  padding: .4rem;
}
.navbar {
  align-items: stretch;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-align: stretch;
  -ms-flex-pack: justify;
  -ms-flex-wrap: wrap;
  flex-wrap: wrap;
  justify-content: space-between;
}
.navbar .navbar-section {
  align-items: center;
  display: -ms-flexbox;
  display: flex;
  -ms-flex: 1 0 0;
  flex: 1 0 0;
  -ms-flex-align: center;
}
.navbar .navbar-section:not(:first-child):last-child {
  -ms-flex-pack: end;
  justify-content: flex-end;
}
.navbar .navbar-center {
  align-items: center;
  display: -ms-flexbox;
  display: flex;
  -ms-flex: 0 0 auto;
  flex: 0 0 auto;
  -ms-flex-align: center;
}
.navbar .navbar-brand {
  font-size: .9rem;
  text-decoration: none;
}
.accordion input:checked ~ .accordion-header > .icon:first-child,
.accordion[open] .accordion-header > .icon:first-child {
  transform: rotate(90deg);
}
.accordion input:checked ~ .accordion-body,
.accordion[open] .accordion-body {
  max-height: 50rem;
}
.accordion .accordion-header {
  display: block;
  padding: .2rem .4rem;
}
.accordion .accordion-header .icon {
  transition: transform .25s;
}
.accordion .accordion-body {
  margin-bottom: .4rem;
  max-height: 0;
  overflow: hidden;
  transition: max-height .25s;
}
summary.accordion-header::-webkit-details-marker {
  display: none;
}
.avatar {
  background: #5755d9;
  border-radius: 50%;
  color: rgba(255, 255, 255, .85);
  display: inline-block;
  font-size: .8rem;
  font-weight: 300;
  height: 1.6rem;
  line-height: 1.25;
  margin: 0;
  position: relative;
  vertical-align: middle;
  width: 1.6rem;
}
.avatar.avatar-xs {
  font-size: .4rem;
  height: .8rem;
  width: .8rem;
}
.avatar.avatar-sm {
  font-size: .6rem;
  height: 1.2rem;
  width: 1.2rem;
}
.avatar.avatar-lg {
  font-size: 1.2rem;
  height: 2.4rem;
  width: 2.4rem;
}
.avatar.avatar-xl {
  font-size: 1.6rem;
  height: 3.2rem;
  width: 3.2rem;
}
.avatar img {
  border-radius: 50%;
  height: 100%;
  position: relative;
  width: 100%;
  z-index: 1;
}
.avatar .avatar-icon,
.avatar .avatar-presence {
  background: #fff;
  bottom: 14.64%;
  height: 50%;
  padding: .1rem;
  position: absolute;
  right: 14.64%;
  transform: translate(50%, 50%);
  width: 50%;
  z-index: 2;
}
.avatar .avatar-presence {
  background: #bcc3ce;
  border-radius: 50%;
  box-shadow: 0 0 0 .1rem #fff;
  height: .5em;
  width: .5em;
}
.avatar .avatar-presence.online {
  background: #32b643;
}
.avatar .avatar-presence.busy {
  background: #e85600;
}
.avatar .avatar-presence.away {
  background: #ffb700;
}
.avatar[data-initial]::before {
  color: currentColor;
  content: attr(data-initial);
  left: 50%;
  position: absolute;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 1;
}
.badge {
  position: relative;
  white-space: nowrap;
}
.badge[data-badge]::after,
.badge:not([data-badge])::after {
  background: #5755d9;
  background-clip: padding-box;
  border-radius: .5rem;
  box-shadow: 0 0 0 .1rem #fff;
  color: #fff;
  content: attr(data-badge);
  display: inline-block;
  transform: translate(-.05rem, -.5rem);
}
.badge[data-badge]::after {
  font-size: .7rem;
  height: .9rem;
  line-height: 1;
  min-width: .9rem;
  padding: .1rem .2rem;
  text-align: center;
  white-space: nowrap;
}
.badge:not([data-badge])::after,
.badge[data-badge=""]::after {
  height: 6px;
  min-width: 6px;
  padding: 0;
  width: 6px;
}
.badge.btn::after {
  position: absolute;
  right: 0;
  top: 0;
  transform: translate(50%, -50%);
}
.badge.avatar::after {
  position: absolute;
  right: 14.64%;
  top: 14.64%;
  transform: translate(50%, -50%);
  z-index: 100;
}
.breadcrumb {
  list-style: none;
  margin: .2rem 0;
  padding: .2rem 0;
}
.breadcrumb .breadcrumb-item {
  color: #66758c;
  display: inline-block;
  margin: 0;
  padding: .2rem 0;
}
.breadcrumb .breadcrumb-item:not(:last-child) {
  margin-right: .2rem;
}
.breadcrumb .breadcrumb-item:not(:last-child) a {
  color: #66758c;
}
.breadcrumb .breadcrumb-item:not(:first-child)::before {
  color: #66758c;
  content: "/";
  padding-right: .4rem;
}
.bar {
  background: #eef0f3;
  border-radius: .1rem;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-wrap: nowrap;
  flex-wrap: nowrap;
  height: .8rem;
  width: 100%;
}
.bar.bar-sm {
  height: .2rem;
}
.bar .bar-item {
  background: #5755d9;
  color: #fff;
  display: block;
  -ms-flex-negative: 0;
  flex-shrink: 0;
  font-size: .7rem;
  height: 100%;
  line-height: .8rem;
  position: relative;
  text-align: center;
  width: 0;
}
.bar .bar-item:first-child {
  border-bottom-left-radius: .1rem;
  border-top-left-radius: .1rem;
}
.bar .bar-item:last-child {
  border-bottom-right-radius: .1rem;
  border-top-right-radius: .1rem;
  -ms-flex-negative: 1;
  flex-shrink: 1;
}
.bar-slider {
  height: .1rem;
  margin: .4rem 0;
  position: relative;
}
.bar-slider .bar-item {
  left: 0;
  padding: 0;
  position: absolute;
}
.bar-slider .bar-item:not(:last-child):first-child {
  background: #eef0f3;
  z-index: 1;
}
.bar-slider .bar-slider-btn {
  background: #5755d9;
  border: 0;
  border-radius: 50%;
  height: .6rem;
  padding: 0;
  position: absolute;
  right: 0;
  top: 50%;
  transform: translate(50%, -50%);
  width: .6rem;
}
.bar-slider .bar-slider-btn:active {
  box-shadow: 0 0 0 .1rem #5755d9;
}
.card {
  background: #fff;
  border: .05rem solid #dadee4;
  border-radius: .1rem;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-direction: column;
  flex-direction: column;
}
.card .card-header,
.card .card-body,
.card .card-footer {
  padding: .8rem;
  padding-bottom: 0;
}
.card .card-header:last-child,
.card .card-body:last-child,
.card .card-footer:last-child {
  padding-bottom: .8rem;
}
.card .card-body {
  -ms-flex: 1 1 auto;
  flex: 1 1 auto;
}
.card .card-image {
  padding-top: .8rem;
}
.card .card-image:first-child {
  padding-top: 0;
}
.card .card-image:first-child img {
  border-top-left-radius: .1rem;
  border-top-right-radius: .1rem;
}
.card .card-image:last-child img {
  border-bottom-left-radius: .1rem;
  border-bottom-right-radius: .1rem;
}
.chip {
  align-items: center;
  background: #eef0f3;
  border-radius: 5rem;
  display: -ms-inline-flexbox;
  display: inline-flex;
  -ms-flex-align: center;
  font-size: 90%;
  height: 1.2rem;
  line-height: .8rem;
  margin: .1rem;
  max-width: 320px;
  overflow: hidden;
  padding: .2rem .4rem;
  text-decoration: none;
  text-overflow: ellipsis;
  vertical-align: middle;
  white-space: nowrap;
}
.chip.active {
  background: #5755d9;
  color: #fff;
}
.chip .avatar {
  margin-left: -.4rem;
  margin-right: .2rem;
}
.chip .btn-clear {
  border-radius: 50%;
  transform: scale(.75);
}
.dropdown {
  display: inline-block;
  position: relative;
}
.dropdown .menu {
  animation: slide-down .15s ease 1;
  display: none;
  left: 0;
  max-height: 50vh;
  overflow-y: auto;
  position: absolute;
  top: 100%;
}
.dropdown.dropdown-right .menu {
  left: auto;
  right: 0;
}
.dropdown.active .menu,
.dropdown .dropdown-toggle:focus + .menu,
.dropdown .menu:hover {
  display: block;
}
.dropdown .btn-group .dropdown-toggle:nth-last-child(2) {
  border-bottom-right-radius: .1rem;
  border-top-right-radius: .1rem;
}
.empty {
  background: #f7f8f9;
  border-radius: .1rem;
  color: #66758c;
  padding: 3.2rem 1.6rem;
  text-align: center;
}
.empty .empty-icon {
  margin-bottom: .8rem;
}
.empty .empty-title,
.empty .empty-subtitle {
  margin: .4rem auto;
}
.empty .empty-action {
  margin-top: .8rem;
}
.menu {
  background: #fff;
  border-radius: .1rem;
  box-shadow: 0 .05rem .2rem rgba(48, 55, 66, .3);
  list-style: none;
  margin: 0;
  min-width: 180px;
  padding: .4rem;
  transform: translateY(.2rem);
  z-index: 300;
}
.menu.menu-nav {
  background: transparent;
  box-shadow: none;
}
.menu .menu-item {
  margin-top: 0;
  padding: 0 .4rem;
  position: relative;
  text-decoration: none;
}
.menu .menu-item > a {
  border-radius: .1rem;
  color: inherit;
  display: block;
  margin: 0 -.4rem;
  padding: .2rem .4rem;
  text-decoration: none;
}
.menu .menu-item > a:focus,
.menu .menu-item > a:hover {
  background: #f1f1fc;
  color: #5755d9;
}
.menu .menu-item > a:active,
.menu .menu-item > a.active {
  background: #f1f1fc;
  color: #5755d9;
}
.menu .menu-item .form-checkbox,
.menu .menu-item .form-radio,
.menu .menu-item .form-switch {
  margin: .1rem 0;
}
.menu .menu-item + .menu-item {
  margin-top: .2rem;
}
.menu .menu-badge {
  align-items: center;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-align: center;
  height: 100%;
  position: absolute;
  right: 0;
  top: 0;
}
.menu .menu-badge .label {
  margin-right: .4rem;
}
.modal {
  align-items: center;
  bottom: 0;
  display: none;
  -ms-flex-align: center;
  -ms-flex-pack: center;
  justify-content: center;
  left: 0;
  opacity: 0;
  overflow: hidden;
  padding: .4rem;
  position: fixed;
  right: 0;
  top: 0;
}
.modal:target,
.modal.active {
  display: -ms-flexbox;
  display: flex;
  opacity: 1;
  z-index: 400;
}
.modal:target .modal-overlay,
.modal.active .modal-overlay {
  background: rgba(247, 248, 249, .75);
  bottom: 0;
  cursor: default;
  display: block;
  left: 0;
  position: absolute;
  right: 0;
  top: 0;
}
.modal:target .modal-container,
.modal.active .modal-container {
  animation: slide-down .2s ease 1;
  z-index: 1;
}
.modal.modal-sm .modal-container {
  max-width: 320px;
  padding: 0 .4rem;
}
.modal.modal-lg .modal-overlay {
  background: #fff;
}
.modal.modal-lg .modal-container {
  box-shadow: none;
  max-width: 960px;
}
.modal-container {
  background: #fff;
  border-radius: .1rem;
  box-shadow: 0 .2rem .5rem rgba(48, 55, 66, .3);
  display: -ms-flexbox;
  display: flex;
  -ms-flex-direction: column;
  flex-direction: column;
  max-height: 75vh;
  max-width: 640px;
  padding: 0 .8rem;
  width: 100%;
}
.modal-container.modal-fullheight {
  max-height: 100vh;
}
.modal-container .modal-header {
  color: #303742;
  padding: .8rem;
}
.modal-container .modal-body {
  overflow-y: auto;
  padding: .8rem;
  position: relative;
}
.modal-container .modal-footer {
  padding: .8rem;
  text-align: right;
}
.nav {
  display: -ms-flexbox;
  display: flex;
  -ms-flex-direction: column;
  flex-direction: column;
  list-style: none;
  margin: .2rem 0;
}
.nav .nav-item a {
  color: #66758c;
  padding: .2rem .4rem;
  text-decoration: none;
}
.nav .nav-item a:focus,
.nav .nav-item a:hover {
  color: #5755d9;
}
.nav .nav-item.active > a {
  color: #505c6e;
  font-weight: bold;
}
.nav .nav-item.active > a:focus,
.nav .nav-item.active > a:hover {
  color: #5755d9;
}
.nav .nav {
  margin-bottom: .4rem;
  margin-left: .8rem;
}
.pagination {
  display: -ms-flexbox;
  display: flex;
  list-style: none;
  margin: .2rem 0;
  padding: .2rem 0;
}
.pagination .page-item {
  margin: .2rem .05rem;
}
.pagination .page-item span {
  display: inline-block;
  padding: .2rem .2rem;
}
.pagination .page-item a {
  border-radius: .1rem;
  display: inline-block;
  padding: .2rem .4rem;
  text-decoration: none;
}
.pagination .page-item a:focus,
.pagination .page-item a:hover {
  color: #5755d9;
}
.pagination .page-item.disabled a {
  cursor: default;
  opacity: .5;
  pointer-events: none;
}
.pagination .page-item.active a {
  background: #5755d9;
  color: #fff;
}
.pagination .page-item.page-prev,
.pagination .page-item.page-next {
  -ms-flex: 1 0 50%;
  flex: 1 0 50%;
}
.pagination .page-item.page-next {
  text-align: right;
}
.pagination .page-item .page-item-title {
  margin: 0;
}
.pagination .page-item .page-item-subtitle {
  margin: 0;
  opacity: .5;
}
.panel {
  border: .05rem solid #dadee4;
  border-radius: .1rem;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-direction: column;
  flex-direction: column;
}
.panel .panel-header,
.panel .panel-footer {
  -ms-flex: 0 0 auto;
  flex: 0 0 auto;
  padding: .8rem;
}
.panel .panel-nav {
  -ms-flex: 0 0 auto;
  flex: 0 0 auto;
}
.panel .panel-body {
  -ms-flex: 1 1 auto;
  flex: 1 1 auto;
  overflow-y: auto;
  padding: 0 .8rem;
}
.popover {
  display: inline-block;
  position: relative;
}
.popover .popover-container {
  left: 50%;
  opacity: 0;
  padding: .4rem;
  position: absolute;
  top: 0;
  transform: translate(-50%, -50%) scale(0);
  transition: transform .2s;
  width: 320px;
  z-index: 300;
}
.popover *:focus + .popover-container,
.popover:hover .popover-container {
  display: block;
  opacity: 1;
  transform: translate(-50%, -100%) scale(1);
}
.popover.popover-right .popover-container {
  left: 100%;
  top: 50%;
}
.popover.popover-right *:focus + .popover-container,
.popover.popover-right:hover .popover-container {
  transform: translate(0, -50%) scale(1);
}
.popover.popover-bottom .popover-container {
  left: 50%;
  top: 100%;
}
.popover.popover-bottom *:focus + .popover-container,
.popover.popover-bottom:hover .popover-container {
  transform: translate(-50%, 0) scale(1);
}
.popover.popover-left .popover-container {
  left: 0;
  top: 50%;
}
.popover.popover-left *:focus + .popover-container,
.popover.popover-left:hover .popover-container {
  transform: translate(-100%, -50%) scale(1);
}
.popover .card {
  border: 0;
  box-shadow: 0 .2rem .5rem rgba(48, 55, 66, .3);
}
.step {
  display: -ms-flexbox;
  display: flex;
  -ms-flex-wrap: nowrap;
  flex-wrap: nowrap;
  list-style: none;
  margin: .2rem 0;
  width: 100%;
}
.step .step-item {
  -ms-flex: 1 1 0;
  flex: 1 1 0;
  margin-top: 0;
  min-height: 1rem;
  position: relative;
  text-align: center;
}
.step .step-item:not(:first-child)::before {
  background: #5755d9;
  content: "";
  height: 2px;
  left: -50%;
  position: absolute;
  top: 9px;
  width: 100%;
}
.step .step-item a {
  color: #5755d9;
  display: inline-block;
  padding: 20px 10px 0;
  text-decoration: none;
}
.step .step-item a::before {
  background: #5755d9;
  border: .1rem solid #fff;
  border-radius: 50%;
  content: "";
  display: block;
  height: .6rem;
  left: 50%;
  position: absolute;
  top: .2rem;
  transform: translateX(-50%);
  width: .6rem;
  z-index: 1;
}
.step .step-item.active a::before {
  background: #fff;
  border: .1rem solid #5755d9;
}
.step .step-item.active ~ .step-item::before {
  background: #dadee4;
}
.step .step-item.active ~ .step-item a {
  color: #bcc3ce;
}
.step .step-item.active ~ .step-item a::before {
  background: #dadee4;
}
.tab {
  align-items: center;
  border-bottom: .05rem solid #dadee4;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-align: center;
  -ms-flex-wrap: wrap;
  flex-wrap: wrap;
  list-style: none;
  margin: .2rem 0 .15rem 0;
}
.tab .tab-item {
  margin-top: 0;
}
.tab .tab-item a {
  border-bottom: .1rem solid transparent;
  color: inherit;
  display: block;
  margin: 0 .4rem 0 0;
  padding: .4rem .2rem .3rem .2rem;
  text-decoration: none;
}
.tab .tab-item a:focus,
.tab .tab-item a:hover {
  color: #5755d9;
}
.tab .tab-item.active a,
.tab .tab-item a.active {
  border-bottom-color: #5755d9;
  color: #5755d9;
}
.tab .tab-item.tab-action {
  -ms-flex: 1 0 auto;
  flex: 1 0 auto;
  text-align: right;
}
.tab .tab-item .btn-clear {
  margin-top: -.2rem;
}
.tab.tab-block .tab-item {
  -ms-flex: 1 0 0;
  flex: 1 0 0;
  text-align: center;
}
.tab.tab-block .tab-item a {
  margin: 0;
}
.tab.tab-block .tab-item .badge[data-badge]::after {
  position: absolute;
  right: .1rem;
  top: .1rem;
  transform: translate(0, 0);
}
.tab:not(.tab-block) .badge {
  padding-right: 0;
}
.tile {
  align-content: space-between;
  align-items: flex-start;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-align: start;
  -ms-flex-line-pack: justify;
}
.tile .tile-icon,
.tile .tile-action {
  -ms-flex: 0 0 auto;
  flex: 0 0 auto;
}
.tile .tile-content {
  -ms-flex: 1 1 auto;
  flex: 1 1 auto;
}
.tile .tile-content:not(:first-child) {
  padding-left: .4rem;
}
.tile .tile-content:not(:last-child) {
  padding-right: .4rem;
}
.tile .tile-title,
.tile .tile-subtitle {
  line-height: 1.2rem;
}
.tile.tile-centered {
  align-items: center;
  -ms-flex-align: center;
}
.tile.tile-centered .tile-content {
  overflow: hidden;
}
.tile.tile-centered .tile-title,
.tile.tile-centered .tile-subtitle {
  margin-bottom: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.toast {
  background: rgba(48, 55, 66, .95);
  border: .05rem solid #303742;
  border-color: #303742;
  border-radius: .1rem;
  color: #fff;
  display: block;
  padding: .4rem;
  width: 100%;
}
.toast.toast-primary {
  background: rgba(87, 85, 217, .95);
  border-color: #5755d9;
}
.toast.toast-success {
  background: rgba(50, 182, 67, .95);
  border-color: #32b643;
}
.toast.toast-warning {
  background: rgba(255, 183, 0, .95);
  border-color: #ffb700;
}
.toast.toast-error {
  background: rgba(232, 86, 0, .95);
  border-color: #e85600;
}
.toast a {
  color: #fff;
  text-decoration: underline;
}
.toast a:focus,
.toast a:hover,
.toast a:active,
.toast a.active {
  opacity: .75;
}
.toast .btn-clear {
  margin: .1rem;
}
.toast p:last-child {
  margin-bottom: 0;
}
.tooltip {
  position: relative;
}
.tooltip::after {
  background: rgba(48, 55, 66, .95);
  border-radius: .1rem;
  bottom: 100%;
  color: #fff;
  content: attr(data-tooltip);
  display: block;
  font-size: .7rem;
  left: 50%;
  max-width: 320px;
  opacity: 0;
  overflow: hidden;
  padding: .2rem .4rem;
  pointer-events: none;
  position: absolute;
  text-overflow: ellipsis;
  transform: translate(-50%, .4rem);
  transition: opacity .2s, transform .2s;
  white-space: pre;
  z-index: 300;
}
.tooltip:focus::after,
.tooltip:hover::after {
  opacity: 1;
  transform: translate(-50%, -.2rem);
}
.tooltip[disabled],
.tooltip.disabled {
  pointer-events: auto;
}
.tooltip.tooltip-right::after {
  bottom: 50%;
  left: 100%;
  transform: translate(-.2rem, 50%);
}
.tooltip.tooltip-right:focus::after,
.tooltip.tooltip-right:hover::after {
  transform: translate(.2rem, 50%);
}
.tooltip.tooltip-bottom::after {
  bottom: auto;
  top: 100%;
  transform: translate(-50%, -.4rem);
}
.tooltip.tooltip-bottom:focus::after,
.tooltip.tooltip-bottom:hover::after {
  transform: translate(-50%, .2rem);
}
.tooltip.tooltip-left::after {
  bottom: 50%;
  left: auto;
  right: 100%;
  transform: translate(.4rem, 50%);
}
.tooltip.tooltip-left:focus::after,
.tooltip.tooltip-left:hover::after {
  transform: translate(-.2rem, 50%);
}
@keyframes loading {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}
@keyframes slide-down {
  0% {
    opacity: 0;
    transform: translateY(-1.6rem);
  }
  100% {
    opacity: 1;
    transform: translateY(0);
  }
}
.text-primary {
  color: #5755d9 !important;
}
a.text-primary:focus,
a.text-primary:hover {
  color: #4240d4;
}
a.text-primary:visited {
  color: #6c6ade;
}
.text-secondary {
  color: #e5e5f9 !important;
}
a.text-secondary:focus,
a.text-secondary:hover {
  color: #d1d0f4;
}
a.text-secondary:visited {
  color: #fafafe;
}
.text-gray {
  color: #bcc3ce !important;
}
a.text-gray:focus,
a.text-gray:hover {
  color: #adb6c4;
}
a.text-gray:visited {
  color: #cbd0d9;
}
.text-light {
  color: #fff !important;
}
a.text-light:focus,
a.text-light:hover {
  color: #f2f2f2;
}
a.text-light:visited {
  color: white;
}
.text-dark {
  color: #3b4351 !important;
}
a.text-dark:focus,
a.text-dark:hover {
  color: #303742;
}
a.text-dark:visited {
  color: #455060;
}
.text-success {
  color: #32b643 !important;
}
a.text-success:focus,
a.text-success:hover {
  color: #2da23c;
}
a.text-success:visited {
  color: #39c94b;
}
.text-warning {
  color: #ffb700 !important;
}
a.text-warning:focus,
a.text-warning:hover {
  color: #e6a500;
}
a.text-warning:visited {
  color: #ffbe1a;
}
.text-error {
  color: #e85600 !important;
}
a.text-error:focus,
a.text-error:hover {
  color: #cf4d00;
}
a.text-error:visited {
  color: #ff6003;
}
.bg-primary {
  background: #5755d9 !important;
  color: #fff;
}
.bg-secondary {
  background: #f1f1fc !important;
}
.bg-dark {
  background: #303742 !important;
  color: #fff;
}
.bg-gray {
  background: #f7f8f9 !important;
}
.bg-success {
  background: #32b643 !important;
  color: #fff;
}
.bg-warning {
  background: #ffb700 !important;
  color: #fff;
}
.bg-error {
  background: #e85600 !important;
  color: #fff;
}
.c-hand {
  cursor: pointer;
}
.c-move {
  cursor: move;
}
.c-zoom-in {
  cursor: zoom-in;
}
.c-zoom-out {
  cursor: zoom-out;
}
.c-not-allowed {
  cursor: not-allowed;
}
.c-auto {
  cursor: auto;
}
.d-block {
  display: block;
}
.d-inline {
  display: inline;
}
.d-inline-block {
  display: inline-block;
}
.d-flex {
  display: -ms-flexbox;
  display: flex;
}
.d-inline-flex {
  display: -ms-inline-flexbox;
  display: inline-flex;
}
.d-none,
.d-hide {
  display: none !important;
}
.d-visible {
  visibility: visible;
}
.d-invisible {
  visibility: hidden;
}
.text-hide {
  background: transparent;
  border: 0;
  color: transparent;
  font-size: 0;
  line-height: 0;
  text-shadow: none;
}
.text-assistive {
  border: 0;
  clip: rect(0, 0, 0, 0);
  height: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
  width: 1px;
}
.divider,
.divider-vert {
  display: block;
  position: relative;
}
.divider[data-content]::after,
.divider-vert[data-content]::after {
  background: #fff;
  color: #bcc3ce;
  content: attr(data-content);
  display: inline-block;
  font-size: .7rem;
  padding: 0 .4rem;
  transform: translateY(-.65rem);
}
.divider {
  border-top: .05rem solid #f1f3f5;
  height: .05rem;
  margin: .4rem 0;
}
.divider[data-content] {
  margin: .8rem 0;
}
.divider-vert {
  display: block;
  padding: .8rem;
}
.divider-vert::before {
  border-left: .05rem solid #dadee4;
  bottom: .4rem;
  content: "";
  display: block;
  left: 50%;
  position: absolute;
  top: .4rem;
  transform: translateX(-50%);
}
.divider-vert[data-content]::after {
  left: 50%;
  padding: .2rem 0;
  position: absolute;
  top: 50%;
  transform: translate(-50%, -50%);
}
.loading {
  color: transparent !important;
  min-height: .8rem;
  pointer-events: none;
  position: relative;
}
.loading::after {
  animation: loading 500ms infinite linear;
  background: transparent;
  border: .1rem solid #5755d9;
  border-radius: 50%;
  border-right-color: transparent;
  border-top-color: transparent;
  content: "";
  display: block;
  height: .8rem;
  left: 50%;
  margin-left: -.4rem;
  margin-top: -.4rem;
  opacity: 1;
  padding: 0;
  position: absolute;
  top: 50%;
  width: .8rem;
  z-index: 1;
}
.loading.loading-lg {
  min-height: 2rem;
}
.loading.loading-lg::after {
  height: 1.6rem;
  margin-left: -.8rem;
  margin-top: -.8rem;
  width: 1.6rem;
}
.clearfix::after {
  clear: both;
  content: "";
  display: table;
}
.float-left {
  float: left !important;
}
.float-right {
  float: right !important;
}
.p-relative {
  position: relative !important;
}
.p-absolute {
  position: absolute !important;
}
.p-fixed {
  position: fixed !important;
}
.p-sticky {
  position: -webkit-sticky !important;
  position: sticky !important;
}
.p-centered {
  display: block;
  float: none;
  margin-left: auto;
  margin-right: auto;
}
.flex-centered {
  align-items: center;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-align: center;
  -ms-flex-pack: center;
  justify-content: center;
}
.m-0 {
  margin: 0 !important;
}
.mb-0 {
  margin-bottom: 0 !important;
}
.ml-0 {
  margin-left: 0 !important;
}
.mr-0 {
  margin-right: 0 !important;
}
.mt-0 {
  margin-top: 0 !important;
}
.mx-0 {
  margin-left: 0 !important;
  margin-right: 0 !important;
}
.my-0 {
  margin-bottom: 0 !important;
  margin-top: 0 !important;
}
.m-1 {
  margin: .2rem !important;
}
.mb-1 {
  margin-bottom: .2rem !important;
}
.ml-1 {
  margin-left: .2rem !important;
}
.mr-1 {
  margin-right: .2rem !important;
}
.mt-1 {
  margin-top: .2rem !important;
}
.mx-1 {
  margin-left: .2rem !important;
  margin-right: .2rem !important;
}
.my-1 {
  margin-bottom: .2rem !important;
  margin-top: .2rem !important;
}
.m-2 {
  margin: .4rem !important;
}
.mb-2 {
  margin-bottom: .4rem !important;
}
.ml-2 {
  margin-left: .4rem !important;
}
.mr-2 {
  margin-right: .4rem !important;
}
.mt-2 {
  margin-top: .4rem !important;
}
.mx-2 {
  margin-left: .4rem !important;
  margin-right: .4rem !important;
}
.my-2 {
  margin-bottom: .4rem !important;
  margin-top: .4rem !important;
}
.p-0 {
  padding: 0 !important;
}
.pb-0 {
  padding-bottom: 0 !important;
}
.pl-0 {
  padding-left: 0 !important;
}
.pr-0 {
  padding-right: 0 !important;
}
.pt-0 {
  padding-top: 0 !important;
}
.px-0 {
  padding-left: 0 !important;
  padding-right: 0 !important;
}
.py-0 {
  padding-bottom: 0 !important;
  padding-top: 0 !important;
}
.p-1 {
  padding: .2rem !important;
}
.pb-1 {
  padding-bottom: .2rem !important;
}
.pl-1 {
  padding-left: .2rem !important;
}
.pr-1 {
  padding-right: .2rem !important;
}
.pt-1 {
  padding-top: .2rem !important;
}
.px-1 {
  padding-left: .2rem !important;
  padding-right: .2rem !important;
}
.py-1 {
  padding-bottom: .2rem !important;
  padding-top: .2rem !important;
}
.p-2 {
  padding: .4rem !important;
}
.pb-2 {
  padding-bottom: .4rem !important;
}
.pl-2 {
  padding-left: .4rem !important;
}
.pr-2 {
  padding-right: .4rem !important;
}
.pt-2 {
  padding-top: .4rem !important;
}
.px-2 {
  padding-left: .4rem !important;
  padding-right: .4rem !important;
}
.py-2 {
  padding-bottom: .4rem !important;
  padding-top: .4rem !important;
}
.s-rounded {
  border-radius: .1rem;
}
.s-circle {
  border-radius: 50%;
}
.text-left {
  text-align: left;
}
.text-right {
  text-align: right;
}
.text-center {
  text-align: center;
}
.text-justify {
  text-align: justify;
}
.text-lowercase {
  text-transform: lowercase;
}
.text-uppercase {
  text-transform: uppercase;
}
.text-capitalize {
  text-transform: capitalize;
}
.text-normal {
  font-weight: normal;
}
.text-bold {
  font-weight: bold;
}
.text-italic {
  font-style: italic;
}
.text-large {
  font-size: 1.2em;
}
.text-small {
  font-size: .9em;
}
.text-tiny {
  font-size: .8em;
}
.text-muted {
  opacity: .8;
}
.text-ellipsis {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.text-clip {
  overflow: hidden;
  text-overflow: clip;
  white-space: nowrap;
}
.text-break {
  -webkit-hyphens: auto;
  -ms-hyphens: auto;
  hyphens: auto;
  word-break: break-word;
  word-wrap: break-word;
}
public/css/spectre-0.5.9/spectre.min.css
New file
@@ -0,0 +1 @@
/*! Spectre.css v0.5.9 | MIT License | github.com/picturepan2/spectre */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figcaption,figure,main{display:block}hr{box-sizing:content-box;height:0;overflow:visible}a{background-color:transparent;-webkit-text-decoration-skip:objects}a:active,a:hover{outline-width:0}address{font-style:normal}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:"SF Mono","Segoe UI Mono","Roboto Mono",Menlo,Courier,monospace;font-size:1em}dfn{font-style:italic}small{font-size:80%;font-weight:400}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}fieldset{border:0;margin:0;padding:0}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item;outline:0}canvas{display:inline-block}template{display:none}[hidden]{display:none}*,::after,::before{box-sizing:inherit}html{box-sizing:border-box;font-size:20px;line-height:1.5;-webkit-tap-highlight-color:transparent}body{background:#fff;color:#3b4351;font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;font-size:.8rem;overflow-x:hidden;text-rendering:optimizeLegibility}a{color:#5755d9;outline:0;text-decoration:none}a:focus{box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}a.active,a:active,a:focus,a:hover{color:#302ecd;text-decoration:underline}a:visited{color:#807fe2}h1,h2,h3,h4,h5,h6{color:inherit;font-weight:500;line-height:1.2;margin-bottom:.5em;margin-top:0}.h1,.h2,.h3,.h4,.h5,.h6{font-weight:500}.h1,h1{font-size:2rem}.h2,h2{font-size:1.6rem}.h3,h3{font-size:1.4rem}.h4,h4{font-size:1.2rem}.h5,h5{font-size:1rem}.h6,h6{font-size:.8rem}p{margin:0 0 1.2rem}a,ins,u{-webkit-text-decoration-skip:ink edges;text-decoration-skip:ink edges}abbr[title]{border-bottom:.05rem dotted;cursor:help;text-decoration:none}kbd{background:#303742;border-radius:.1rem;color:#fff;font-size:.7rem;line-height:1.25;padding:.1rem .2rem}mark{background:#ffe9b3;border-bottom:.05rem solid #ffd367;border-radius:.1rem;color:#3b4351;padding:.05rem .1rem 0}blockquote{border-left:.1rem solid #dadee4;margin-left:0;padding:.4rem .8rem}blockquote p:last-child{margin-bottom:0}ol,ul{margin:.8rem 0 .8rem .8rem;padding:0}ol ol,ol ul,ul ol,ul ul{margin:.8rem 0 .8rem .8rem}ol li,ul li{margin-top:.4rem}ul{list-style:disc inside}ul ul{list-style-type:circle}ol{list-style:decimal inside}ol ol{list-style-type:lower-alpha}dl dt{font-weight:700}dl dd{margin:.4rem 0 .8rem 0}.lang-zh,.lang-zh-hans,html:lang(zh),html:lang(zh-Hans){font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Helvetica Neue",sans-serif}.lang-zh-hant,html:lang(zh-Hant){font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang TC","Hiragino Sans CNS","Microsoft JhengHei","Helvetica Neue",sans-serif}.lang-ja,html:lang(ja){font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Hiragino Sans","Hiragino Kaku Gothic Pro","Yu Gothic",YuGothic,Meiryo,"Helvetica Neue",sans-serif}.lang-ko,html:lang(ko){font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Malgun Gothic","Helvetica Neue",sans-serif}.lang-cjk ins,.lang-cjk u,:lang(ja) ins,:lang(ja) u,:lang(zh) ins,:lang(zh) u{border-bottom:.05rem solid;text-decoration:none}.lang-cjk del+del,.lang-cjk del+s,.lang-cjk ins+ins,.lang-cjk ins+u,.lang-cjk s+del,.lang-cjk s+s,.lang-cjk u+ins,.lang-cjk u+u,:lang(ja) del+del,:lang(ja) del+s,:lang(ja) ins+ins,:lang(ja) ins+u,:lang(ja) s+del,:lang(ja) s+s,:lang(ja) u+ins,:lang(ja) u+u,:lang(zh) del+del,:lang(zh) del+s,:lang(zh) ins+ins,:lang(zh) ins+u,:lang(zh) s+del,:lang(zh) s+s,:lang(zh) u+ins,:lang(zh) u+u{margin-left:.125em}.table{border-collapse:collapse;border-spacing:0;text-align:left;width:100%}.table.table-striped tbody tr:nth-of-type(odd){background:#f7f8f9}.table tbody tr.active,.table.table-striped tbody tr.active{background:#eef0f3}.table.table-hover tbody tr:hover{background:#eef0f3}.table.table-scroll{display:block;overflow-x:auto;padding-bottom:.75rem;white-space:nowrap}.table td,.table th{border-bottom:.05rem solid #dadee4;padding:.6rem .4rem}.table th{border-bottom-width:.1rem}.btn{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#fff;border:.05rem solid #5755d9;border-radius:.1rem;color:#5755d9;cursor:pointer;display:inline-block;font-size:.8rem;height:1.8rem;line-height:1.2rem;outline:0;padding:.25rem .4rem;text-align:center;text-decoration:none;transition:background .2s,border .2s,box-shadow .2s,color .2s;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;vertical-align:middle;white-space:nowrap}.btn:focus{box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.btn:focus,.btn:hover{background:#f1f1fc;border-color:#4b48d6;text-decoration:none}.btn.active,.btn:active{background:#4b48d6;border-color:#3634d2;color:#fff;text-decoration:none}.btn.active.loading::after,.btn:active.loading::after{border-bottom-color:#fff;border-left-color:#fff}.btn.disabled,.btn:disabled,.btn[disabled]{cursor:default;opacity:.5;pointer-events:none}.btn.btn-primary{background:#5755d9;border-color:#4b48d6;color:#fff}.btn.btn-primary:focus,.btn.btn-primary:hover{background:#4240d4;border-color:#3634d2;color:#fff}.btn.btn-primary.active,.btn.btn-primary:active{background:#3a38d2;border-color:#302ecd;color:#fff}.btn.btn-primary.loading::after{border-bottom-color:#fff;border-left-color:#fff}.btn.btn-success{background:#32b643;border-color:#2faa3f;color:#fff}.btn.btn-success:focus{box-shadow:0 0 0 .1rem rgba(50,182,67,.2)}.btn.btn-success:focus,.btn.btn-success:hover{background:#30ae40;border-color:#2da23c;color:#fff}.btn.btn-success.active,.btn.btn-success:active{background:#2a9a39;border-color:#278e34;color:#fff}.btn.btn-success.loading::after{border-bottom-color:#fff;border-left-color:#fff}.btn.btn-error{background:#e85600;border-color:#d95000;color:#fff}.btn.btn-error:focus{box-shadow:0 0 0 .1rem rgba(232,86,0,.2)}.btn.btn-error:focus,.btn.btn-error:hover{background:#de5200;border-color:#cf4d00;color:#fff}.btn.btn-error.active,.btn.btn-error:active{background:#c44900;border-color:#b54300;color:#fff}.btn.btn-error.loading::after{border-bottom-color:#fff;border-left-color:#fff}.btn.btn-link{background:0 0;border-color:transparent;color:#5755d9}.btn.btn-link.active,.btn.btn-link:active,.btn.btn-link:focus,.btn.btn-link:hover{color:#302ecd}.btn.btn-sm{font-size:.7rem;height:1.4rem;padding:.05rem .3rem}.btn.btn-lg{font-size:.9rem;height:2rem;padding:.35rem .6rem}.btn.btn-block{display:block;width:100%}.btn.btn-action{padding-left:0;padding-right:0;width:1.8rem}.btn.btn-action.btn-sm{width:1.4rem}.btn.btn-action.btn-lg{width:2rem}.btn.btn-clear{background:0 0;border:0;color:currentColor;height:1rem;line-height:.8rem;margin-left:.2rem;margin-right:-2px;opacity:1;padding:.1rem;text-decoration:none;width:1rem}.btn.btn-clear:focus,.btn.btn-clear:hover{background:rgba(247,248,249,.5);opacity:.95}.btn.btn-clear::before{content:"\2715"}.btn-group{display:-ms-inline-flexbox;display:inline-flex;-ms-flex-wrap:wrap;flex-wrap:wrap}.btn-group .btn{-ms-flex:1 0 auto;flex:1 0 auto}.btn-group .btn:first-child:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.btn-group .btn:not(:first-child):not(:last-child){border-radius:0;margin-left:-.05rem}.btn-group .btn:last-child:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0;margin-left:-.05rem}.btn-group .btn.active,.btn-group .btn:active,.btn-group .btn:focus,.btn-group .btn:hover{z-index:1}.btn-group.btn-group-block{display:-ms-flexbox;display:flex}.btn-group.btn-group-block .btn{-ms-flex:1 0 0;flex:1 0 0}.form-group:not(:last-child){margin-bottom:.4rem}fieldset{margin-bottom:.8rem}legend{font-size:.9rem;font-weight:500;margin-bottom:.8rem}.form-label{display:block;line-height:1.2rem;padding:.3rem 0}.form-label.label-sm{font-size:.7rem;padding:.1rem 0}.form-label.label-lg{font-size:.9rem;padding:.4rem 0}.form-input{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#fff;background-image:none;border:.05rem solid #bcc3ce;border-radius:.1rem;color:#3b4351;display:block;font-size:.8rem;height:1.8rem;line-height:1.2rem;max-width:100%;outline:0;padding:.25rem .4rem;position:relative;transition:background .2s,border .2s,box-shadow .2s,color .2s;width:100%}.form-input:focus{border-color:#5755d9;box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.form-input:-ms-input-placeholder{color:#bcc3ce}.form-input::-ms-input-placeholder{color:#bcc3ce}.form-input::placeholder{color:#bcc3ce}.form-input.input-sm{font-size:.7rem;height:1.4rem;padding:.05rem .3rem}.form-input.input-lg{font-size:.9rem;height:2rem;padding:.35rem .6rem}.form-input.input-inline{display:inline-block;vertical-align:middle;width:auto}.form-input[type=file]{height:auto}textarea.form-input,textarea.form-input.input-lg,textarea.form-input.input-sm{height:auto}.form-input-hint{color:#bcc3ce;font-size:.7rem;margin-top:.2rem}.has-success .form-input-hint,.is-success+.form-input-hint{color:#32b643}.has-error .form-input-hint,.is-error+.form-input-hint{color:#e85600}.form-select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#fff;border:.05rem solid #bcc3ce;border-radius:.1rem;color:inherit;font-size:.8rem;height:1.8rem;line-height:1.2rem;outline:0;padding:.25rem .4rem;vertical-align:middle;width:100%}.form-select:focus{border-color:#5755d9;box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.form-select::-ms-expand{display:none}.form-select.select-sm{font-size:.7rem;height:1.4rem;padding:.05rem 1.1rem .05rem .3rem}.form-select.select-lg{font-size:.9rem;height:2rem;padding:.35rem 1.4rem .35rem .6rem}.form-select[multiple],.form-select[size]{height:auto;padding:.25rem .4rem}.form-select[multiple] option,.form-select[size] option{padding:.1rem .2rem}.form-select:not([multiple]):not([size]){background:#fff url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center/.4rem .5rem;padding-right:1.2rem}.has-icon-left,.has-icon-right{position:relative}.has-icon-left .form-icon,.has-icon-right .form-icon{height:.8rem;margin:0 .25rem;position:absolute;top:50%;transform:translateY(-50%);width:.8rem;z-index:2}.has-icon-left .form-icon{left:.05rem}.has-icon-left .form-input{padding-left:1.3rem}.has-icon-right .form-icon{right:.05rem}.has-icon-right .form-input{padding-right:1.3rem}.form-checkbox,.form-radio,.form-switch{display:block;line-height:1.2rem;margin:.2rem 0;min-height:1.4rem;padding:.1rem .4rem .1rem 1.2rem;position:relative}.form-checkbox input,.form-radio input,.form-switch input{clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;position:absolute;width:1px}.form-checkbox input:focus+.form-icon,.form-radio input:focus+.form-icon,.form-switch input:focus+.form-icon{border-color:#5755d9;box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.form-checkbox input:checked+.form-icon,.form-radio input:checked+.form-icon,.form-switch input:checked+.form-icon{background:#5755d9;border-color:#5755d9}.form-checkbox .form-icon,.form-radio .form-icon,.form-switch .form-icon{border:.05rem solid #bcc3ce;cursor:pointer;display:inline-block;position:absolute;transition:background .2s,border .2s,box-shadow .2s,color .2s}.form-checkbox.input-sm,.form-radio.input-sm,.form-switch.input-sm{font-size:.7rem;margin:0}.form-checkbox.input-lg,.form-radio.input-lg,.form-switch.input-lg{font-size:.9rem;margin:.3rem 0}.form-checkbox .form-icon,.form-radio .form-icon{background:#fff;height:.8rem;left:0;top:.3rem;width:.8rem}.form-checkbox input:active+.form-icon,.form-radio input:active+.form-icon{background:#eef0f3}.form-checkbox .form-icon{border-radius:.1rem}.form-checkbox input:checked+.form-icon::before{background-clip:padding-box;border:.1rem solid #fff;border-left-width:0;border-top-width:0;content:"";height:9px;left:50%;margin-left:-3px;margin-top:-6px;position:absolute;top:50%;transform:rotate(45deg);width:6px}.form-checkbox input:indeterminate+.form-icon{background:#5755d9;border-color:#5755d9}.form-checkbox input:indeterminate+.form-icon::before{background:#fff;content:"";height:2px;left:50%;margin-left:-5px;margin-top:-1px;position:absolute;top:50%;width:10px}.form-radio .form-icon{border-radius:50%}.form-radio input:checked+.form-icon::before{background:#fff;border-radius:50%;content:"";height:6px;left:50%;position:absolute;top:50%;transform:translate(-50%,-50%);width:6px}.form-switch{padding-left:2rem}.form-switch .form-icon{background:#bcc3ce;background-clip:padding-box;border-radius:.45rem;height:.9rem;left:0;top:.25rem;width:1.6rem}.form-switch .form-icon::before{background:#fff;border-radius:50%;content:"";display:block;height:.8rem;left:0;position:absolute;top:0;transition:background .2s,border .2s,box-shadow .2s,color .2s,left .2s;width:.8rem}.form-switch input:checked+.form-icon::before{left:14px}.form-switch input:active+.form-icon::before{background:#f7f8f9}.input-group{display:-ms-flexbox;display:flex}.input-group .input-group-addon{background:#f7f8f9;border:.05rem solid #bcc3ce;border-radius:.1rem;line-height:1.2rem;padding:.25rem .4rem;white-space:nowrap}.input-group .input-group-addon.addon-sm{font-size:.7rem;padding:.05rem .3rem}.input-group .input-group-addon.addon-lg{font-size:.9rem;padding:.35rem .6rem}.input-group .form-input,.input-group .form-select{-ms-flex:1 1 auto;flex:1 1 auto;width:1%}.input-group .input-group-btn{z-index:1}.input-group .form-input:first-child:not(:last-child),.input-group .form-select:first-child:not(:last-child),.input-group .input-group-addon:first-child:not(:last-child),.input-group .input-group-btn:first-child:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.input-group .form-input:not(:first-child):not(:last-child),.input-group .form-select:not(:first-child):not(:last-child),.input-group .input-group-addon:not(:first-child):not(:last-child),.input-group .input-group-btn:not(:first-child):not(:last-child){border-radius:0;margin-left:-.05rem}.input-group .form-input:last-child:not(:first-child),.input-group .form-select:last-child:not(:first-child),.input-group .input-group-addon:last-child:not(:first-child),.input-group .input-group-btn:last-child:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0;margin-left:-.05rem}.input-group .form-input:focus,.input-group .form-select:focus,.input-group .input-group-addon:focus,.input-group .input-group-btn:focus{z-index:2}.input-group .form-select{width:auto}.input-group.input-inline{display:-ms-inline-flexbox;display:inline-flex}.form-input.is-success,.form-select.is-success,.has-success .form-input,.has-success .form-select{background:#f9fdfa;border-color:#32b643}.form-input.is-success:focus,.form-select.is-success:focus,.has-success .form-input:focus,.has-success .form-select:focus{box-shadow:0 0 0 .1rem rgba(50,182,67,.2)}.form-input.is-error,.form-select.is-error,.has-error .form-input,.has-error .form-select{background:#fffaf7;border-color:#e85600}.form-input.is-error:focus,.form-select.is-error:focus,.has-error .form-input:focus,.has-error .form-select:focus{box-shadow:0 0 0 .1rem rgba(232,86,0,.2)}.form-checkbox.is-error .form-icon,.form-radio.is-error .form-icon,.form-switch.is-error .form-icon,.has-error .form-checkbox .form-icon,.has-error .form-radio .form-icon,.has-error .form-switch .form-icon{border-color:#e85600}.form-checkbox.is-error input:checked+.form-icon,.form-radio.is-error input:checked+.form-icon,.form-switch.is-error input:checked+.form-icon,.has-error .form-checkbox input:checked+.form-icon,.has-error .form-radio input:checked+.form-icon,.has-error .form-switch input:checked+.form-icon{background:#e85600;border-color:#e85600}.form-checkbox.is-error input:focus+.form-icon,.form-radio.is-error input:focus+.form-icon,.form-switch.is-error input:focus+.form-icon,.has-error .form-checkbox input:focus+.form-icon,.has-error .form-radio input:focus+.form-icon,.has-error .form-switch input:focus+.form-icon{border-color:#e85600;box-shadow:0 0 0 .1rem rgba(232,86,0,.2)}.form-checkbox.is-error input:indeterminate+.form-icon,.has-error .form-checkbox input:indeterminate+.form-icon{background:#e85600;border-color:#e85600}.form-input:not(:-ms-input-placeholder):invalid{border-color:#e85600}.form-input:not(:placeholder-shown):invalid{border-color:#e85600}.form-input:not(:-ms-input-placeholder):invalid:focus{background:#fffaf7;box-shadow:0 0 0 .1rem rgba(232,86,0,.2)}.form-input:not(:placeholder-shown):invalid:focus{background:#fffaf7;box-shadow:0 0 0 .1rem rgba(232,86,0,.2)}.form-input:not(:-ms-input-placeholder):invalid+.form-input-hint{color:#e85600}.form-input:not(:placeholder-shown):invalid+.form-input-hint{color:#e85600}.form-input.disabled,.form-input:disabled,.form-select.disabled,.form-select:disabled{background-color:#eef0f3;cursor:not-allowed;opacity:.5}.form-input[readonly]{background-color:#f7f8f9}input.disabled+.form-icon,input:disabled+.form-icon{background:#eef0f3;cursor:not-allowed;opacity:.5}.form-switch input.disabled+.form-icon::before,.form-switch input:disabled+.form-icon::before{background:#fff}.form-horizontal{padding:.4rem 0}.form-horizontal .form-group{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap}.form-inline{display:inline-block}.label{background:#eef0f3;border-radius:.1rem;color:#455060;display:inline-block;line-height:1.25;padding:.1rem .2rem}.label.label-rounded{border-radius:5rem;padding-left:.4rem;padding-right:.4rem}.label.label-primary{background:#5755d9;color:#fff}.label.label-secondary{background:#f1f1fc;color:#5755d9}.label.label-success{background:#32b643;color:#fff}.label.label-warning{background:#ffb700;color:#fff}.label.label-error{background:#e85600;color:#fff}code{background:#fcf2f2;border-radius:.1rem;color:#d73e48;font-size:85%;line-height:1.25;padding:.1rem .2rem}.code{border-radius:.1rem;color:#3b4351;position:relative}.code::before{color:#bcc3ce;content:attr(data-lang);font-size:.7rem;position:absolute;right:.4rem;top:.1rem}.code code{background:#f7f8f9;color:inherit;display:block;line-height:1.5;overflow-x:auto;padding:1rem;width:100%}.img-responsive{display:block;height:auto;max-width:100%}.img-fit-cover{object-fit:cover}.img-fit-contain{object-fit:contain}.video-responsive{display:block;overflow:hidden;padding:0;position:relative;width:100%}.video-responsive::before{content:"";display:block;padding-bottom:56.25%}.video-responsive embed,.video-responsive iframe,.video-responsive object{border:0;bottom:0;height:100%;left:0;position:absolute;right:0;top:0;width:100%}video.video-responsive{height:auto;max-width:100%}video.video-responsive::before{content:none}.video-responsive-4-3::before{padding-bottom:75%}.video-responsive-1-1::before{padding-bottom:100%}.figure{margin:0 0 .4rem 0}.figure .figure-caption{color:#66758c;margin-top:.4rem}.container{margin-left:auto;margin-right:auto;padding-left:.4rem;padding-right:.4rem;width:100%}.container.grid-xl{max-width:1296px}.container.grid-lg{max-width:976px}.container.grid-md{max-width:856px}.container.grid-sm{max-width:616px}.container.grid-xs{max-width:496px}.show-lg,.show-md,.show-sm,.show-xl,.show-xs{display:none!important}.cols,.columns{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-left:-.4rem;margin-right:-.4rem}.cols.col-gapless,.columns.col-gapless{margin-left:0;margin-right:0}.cols.col-gapless>.column,.columns.col-gapless>.column{padding-left:0;padding-right:0}.cols.col-oneline,.columns.col-oneline{-ms-flex-wrap:nowrap;flex-wrap:nowrap;overflow-x:auto}.column,[class~=col-]{-ms-flex:1;flex:1;max-width:100%;padding-left:.4rem;padding-right:.4rem}.column.col-1,.column.col-10,.column.col-11,.column.col-12,.column.col-2,.column.col-3,.column.col-4,.column.col-5,.column.col-6,.column.col-7,.column.col-8,.column.col-9,.column.col-auto,[class~=col-].col-1,[class~=col-].col-10,[class~=col-].col-11,[class~=col-].col-12,[class~=col-].col-2,[class~=col-].col-3,[class~=col-].col-4,[class~=col-].col-5,[class~=col-].col-6,[class~=col-].col-7,[class~=col-].col-8,[class~=col-].col-9,[class~=col-].col-auto{-ms-flex:none;flex:none}.col-12{width:100%}.col-11{width:91.66666667%}.col-10{width:83.33333333%}.col-9{width:75%}.col-8{width:66.66666667%}.col-7{width:58.33333333%}.col-6{width:50%}.col-5{width:41.66666667%}.col-4{width:33.33333333%}.col-3{width:25%}.col-2{width:16.66666667%}.col-1{width:8.33333333%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;max-width:none;width:auto}.col-mx-auto{margin-left:auto;margin-right:auto}.col-ml-auto{margin-left:auto}.col-mr-auto{margin-right:auto}@media (max-width:1280px){.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{-ms-flex:none;flex:none}.col-xl-12{width:100%}.col-xl-11{width:91.66666667%}.col-xl-10{width:83.33333333%}.col-xl-9{width:75%}.col-xl-8{width:66.66666667%}.col-xl-7{width:58.33333333%}.col-xl-6{width:50%}.col-xl-5{width:41.66666667%}.col-xl-4{width:33.33333333%}.col-xl-3{width:25%}.col-xl-2{width:16.66666667%}.col-xl-1{width:8.33333333%}.col-xl-auto{width:auto}.hide-xl{display:none!important}.show-xl{display:block!important}}@media (max-width:960px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto{-ms-flex:none;flex:none}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-auto{width:auto}.hide-lg{display:none!important}.show-lg{display:block!important}}@media (max-width:840px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto{-ms-flex:none;flex:none}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-auto{width:auto}.hide-md{display:none!important}.show-md{display:block!important}}@media (max-width:600px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto{-ms-flex:none;flex:none}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-auto{width:auto}.hide-sm{display:none!important}.show-sm{display:block!important}}@media (max-width:480px){.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-auto{-ms-flex:none;flex:none}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-auto{width:auto}.hide-xs{display:none!important}.show-xs{display:block!important}}.hero{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:justify;justify-content:space-between;padding-bottom:4rem;padding-top:4rem}.hero.hero-sm{padding-bottom:2rem;padding-top:2rem}.hero.hero-lg{padding-bottom:8rem;padding-top:8rem}.hero .hero-body{padding:.4rem}.navbar{align-items:stretch;display:-ms-flexbox;display:flex;-ms-flex-align:stretch;-ms-flex-pack:justify;-ms-flex-wrap:wrap;flex-wrap:wrap;justify-content:space-between}.navbar .navbar-section{align-items:center;display:-ms-flexbox;display:flex;-ms-flex:1 0 0;flex:1 0 0;-ms-flex-align:center}.navbar .navbar-section:not(:first-child):last-child{-ms-flex-pack:end;justify-content:flex-end}.navbar .navbar-center{align-items:center;display:-ms-flexbox;display:flex;-ms-flex:0 0 auto;flex:0 0 auto;-ms-flex-align:center}.navbar .navbar-brand{font-size:.9rem;text-decoration:none}.accordion input:checked~.accordion-header>.icon:first-child,.accordion[open] .accordion-header>.icon:first-child{transform:rotate(90deg)}.accordion input:checked~.accordion-body,.accordion[open] .accordion-body{max-height:50rem}.accordion .accordion-header{display:block;padding:.2rem .4rem}.accordion .accordion-header .icon{transition:transform .25s}.accordion .accordion-body{margin-bottom:.4rem;max-height:0;overflow:hidden;transition:max-height .25s}summary.accordion-header::-webkit-details-marker{display:none}.avatar{background:#5755d9;border-radius:50%;color:rgba(255,255,255,.85);display:inline-block;font-size:.8rem;font-weight:300;height:1.6rem;line-height:1.25;margin:0;position:relative;vertical-align:middle;width:1.6rem}.avatar.avatar-xs{font-size:.4rem;height:.8rem;width:.8rem}.avatar.avatar-sm{font-size:.6rem;height:1.2rem;width:1.2rem}.avatar.avatar-lg{font-size:1.2rem;height:2.4rem;width:2.4rem}.avatar.avatar-xl{font-size:1.6rem;height:3.2rem;width:3.2rem}.avatar img{border-radius:50%;height:100%;position:relative;width:100%;z-index:1}.avatar .avatar-icon,.avatar .avatar-presence{background:#fff;bottom:14.64%;height:50%;padding:.1rem;position:absolute;right:14.64%;transform:translate(50%,50%);width:50%;z-index:2}.avatar .avatar-presence{background:#bcc3ce;border-radius:50%;box-shadow:0 0 0 .1rem #fff;height:.5em;width:.5em}.avatar .avatar-presence.online{background:#32b643}.avatar .avatar-presence.busy{background:#e85600}.avatar .avatar-presence.away{background:#ffb700}.avatar[data-initial]::before{color:currentColor;content:attr(data-initial);left:50%;position:absolute;top:50%;transform:translate(-50%,-50%);z-index:1}.badge{position:relative;white-space:nowrap}.badge:not([data-badge])::after,.badge[data-badge]::after{background:#5755d9;background-clip:padding-box;border-radius:.5rem;box-shadow:0 0 0 .1rem #fff;color:#fff;content:attr(data-badge);display:inline-block;transform:translate(-.05rem,-.5rem)}.badge[data-badge]::after{font-size:.7rem;height:.9rem;line-height:1;min-width:.9rem;padding:.1rem .2rem;text-align:center;white-space:nowrap}.badge:not([data-badge])::after,.badge[data-badge=""]::after{height:6px;min-width:6px;padding:0;width:6px}.badge.btn::after{position:absolute;right:0;top:0;transform:translate(50%,-50%)}.badge.avatar::after{position:absolute;right:14.64%;top:14.64%;transform:translate(50%,-50%);z-index:100}.breadcrumb{list-style:none;margin:.2rem 0;padding:.2rem 0}.breadcrumb .breadcrumb-item{color:#66758c;display:inline-block;margin:0;padding:.2rem 0}.breadcrumb .breadcrumb-item:not(:last-child){margin-right:.2rem}.breadcrumb .breadcrumb-item:not(:last-child) a{color:#66758c}.breadcrumb .breadcrumb-item:not(:first-child)::before{color:#66758c;content:"/";padding-right:.4rem}.bar{background:#eef0f3;border-radius:.1rem;display:-ms-flexbox;display:flex;-ms-flex-wrap:nowrap;flex-wrap:nowrap;height:.8rem;width:100%}.bar.bar-sm{height:.2rem}.bar .bar-item{background:#5755d9;color:#fff;display:block;-ms-flex-negative:0;flex-shrink:0;font-size:.7rem;height:100%;line-height:.8rem;position:relative;text-align:center;width:0}.bar .bar-item:first-child{border-bottom-left-radius:.1rem;border-top-left-radius:.1rem}.bar .bar-item:last-child{border-bottom-right-radius:.1rem;border-top-right-radius:.1rem;-ms-flex-negative:1;flex-shrink:1}.bar-slider{height:.1rem;margin:.4rem 0;position:relative}.bar-slider .bar-item{left:0;padding:0;position:absolute}.bar-slider .bar-item:not(:last-child):first-child{background:#eef0f3;z-index:1}.bar-slider .bar-slider-btn{background:#5755d9;border:0;border-radius:50%;height:.6rem;padding:0;position:absolute;right:0;top:50%;transform:translate(50%,-50%);width:.6rem}.bar-slider .bar-slider-btn:active{box-shadow:0 0 0 .1rem #5755d9}.card{background:#fff;border:.05rem solid #dadee4;border-radius:.1rem;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.card .card-body,.card .card-footer,.card .card-header{padding:.8rem;padding-bottom:0}.card .card-body:last-child,.card .card-footer:last-child,.card .card-header:last-child{padding-bottom:.8rem}.card .card-body{-ms-flex:1 1 auto;flex:1 1 auto}.card .card-image{padding-top:.8rem}.card .card-image:first-child{padding-top:0}.card .card-image:first-child img{border-top-left-radius:.1rem;border-top-right-radius:.1rem}.card .card-image:last-child img{border-bottom-left-radius:.1rem;border-bottom-right-radius:.1rem}.chip{align-items:center;background:#eef0f3;border-radius:5rem;display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;font-size:90%;height:1.2rem;line-height:.8rem;margin:.1rem;max-width:320px;overflow:hidden;padding:.2rem .4rem;text-decoration:none;text-overflow:ellipsis;vertical-align:middle;white-space:nowrap}.chip.active{background:#5755d9;color:#fff}.chip .avatar{margin-left:-.4rem;margin-right:.2rem}.chip .btn-clear{border-radius:50%;transform:scale(.75)}.dropdown{display:inline-block;position:relative}.dropdown .menu{animation:slide-down .15s ease 1;display:none;left:0;max-height:50vh;overflow-y:auto;position:absolute;top:100%}.dropdown.dropdown-right .menu{left:auto;right:0}.dropdown .dropdown-toggle:focus+.menu,.dropdown .menu:hover,.dropdown.active .menu{display:block}.dropdown .btn-group .dropdown-toggle:nth-last-child(2){border-bottom-right-radius:.1rem;border-top-right-radius:.1rem}.empty{background:#f7f8f9;border-radius:.1rem;color:#66758c;padding:3.2rem 1.6rem;text-align:center}.empty .empty-icon{margin-bottom:.8rem}.empty .empty-subtitle,.empty .empty-title{margin:.4rem auto}.empty .empty-action{margin-top:.8rem}.menu{background:#fff;border-radius:.1rem;box-shadow:0 .05rem .2rem rgba(48,55,66,.3);list-style:none;margin:0;min-width:180px;padding:.4rem;transform:translateY(.2rem);z-index:300}.menu.menu-nav{background:0 0;box-shadow:none}.menu .menu-item{margin-top:0;padding:0 .4rem;position:relative;text-decoration:none}.menu .menu-item>a{border-radius:.1rem;color:inherit;display:block;margin:0 -.4rem;padding:.2rem .4rem;text-decoration:none}.menu .menu-item>a:focus,.menu .menu-item>a:hover{background:#f1f1fc;color:#5755d9}.menu .menu-item>a.active,.menu .menu-item>a:active{background:#f1f1fc;color:#5755d9}.menu .menu-item .form-checkbox,.menu .menu-item .form-radio,.menu .menu-item .form-switch{margin:.1rem 0}.menu .menu-item+.menu-item{margin-top:.2rem}.menu .menu-badge{align-items:center;display:-ms-flexbox;display:flex;-ms-flex-align:center;height:100%;position:absolute;right:0;top:0}.menu .menu-badge .label{margin-right:.4rem}.modal{align-items:center;bottom:0;display:none;-ms-flex-align:center;-ms-flex-pack:center;justify-content:center;left:0;opacity:0;overflow:hidden;padding:.4rem;position:fixed;right:0;top:0}.modal.active,.modal:target{display:-ms-flexbox;display:flex;opacity:1;z-index:400}.modal.active .modal-overlay,.modal:target .modal-overlay{background:rgba(247,248,249,.75);bottom:0;cursor:default;display:block;left:0;position:absolute;right:0;top:0}.modal.active .modal-container,.modal:target .modal-container{animation:slide-down .2s ease 1;z-index:1}.modal.modal-sm .modal-container{max-width:320px;padding:0 .4rem}.modal.modal-lg .modal-overlay{background:#fff}.modal.modal-lg .modal-container{box-shadow:none;max-width:960px}.modal-container{background:#fff;border-radius:.1rem;box-shadow:0 .2rem .5rem rgba(48,55,66,.3);display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;max-height:75vh;max-width:640px;padding:0 .8rem;width:100%}.modal-container.modal-fullheight{max-height:100vh}.modal-container .modal-header{color:#303742;padding:.8rem}.modal-container .modal-body{overflow-y:auto;padding:.8rem;position:relative}.modal-container .modal-footer{padding:.8rem;text-align:right}.nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;list-style:none;margin:.2rem 0}.nav .nav-item a{color:#66758c;padding:.2rem .4rem;text-decoration:none}.nav .nav-item a:focus,.nav .nav-item a:hover{color:#5755d9}.nav .nav-item.active>a{color:#505c6e;font-weight:700}.nav .nav-item.active>a:focus,.nav .nav-item.active>a:hover{color:#5755d9}.nav .nav{margin-bottom:.4rem;margin-left:.8rem}.pagination{display:-ms-flexbox;display:flex;list-style:none;margin:.2rem 0;padding:.2rem 0}.pagination .page-item{margin:.2rem .05rem}.pagination .page-item span{display:inline-block;padding:.2rem .2rem}.pagination .page-item a{border-radius:.1rem;display:inline-block;padding:.2rem .4rem;text-decoration:none}.pagination .page-item a:focus,.pagination .page-item a:hover{color:#5755d9}.pagination .page-item.disabled a{cursor:default;opacity:.5;pointer-events:none}.pagination .page-item.active a{background:#5755d9;color:#fff}.pagination .page-item.page-next,.pagination .page-item.page-prev{-ms-flex:1 0 50%;flex:1 0 50%}.pagination .page-item.page-next{text-align:right}.pagination .page-item .page-item-title{margin:0}.pagination .page-item .page-item-subtitle{margin:0;opacity:.5}.panel{border:.05rem solid #dadee4;border-radius:.1rem;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.panel .panel-footer,.panel .panel-header{-ms-flex:0 0 auto;flex:0 0 auto;padding:.8rem}.panel .panel-nav{-ms-flex:0 0 auto;flex:0 0 auto}.panel .panel-body{-ms-flex:1 1 auto;flex:1 1 auto;overflow-y:auto;padding:0 .8rem}.popover{display:inline-block;position:relative}.popover .popover-container{left:50%;opacity:0;padding:.4rem;position:absolute;top:0;transform:translate(-50%,-50%) scale(0);transition:transform .2s;width:320px;z-index:300}.popover :focus+.popover-container,.popover:hover .popover-container{display:block;opacity:1;transform:translate(-50%,-100%) scale(1)}.popover.popover-right .popover-container{left:100%;top:50%}.popover.popover-right :focus+.popover-container,.popover.popover-right:hover .popover-container{transform:translate(0,-50%) scale(1)}.popover.popover-bottom .popover-container{left:50%;top:100%}.popover.popover-bottom :focus+.popover-container,.popover.popover-bottom:hover .popover-container{transform:translate(-50%,0) scale(1)}.popover.popover-left .popover-container{left:0;top:50%}.popover.popover-left :focus+.popover-container,.popover.popover-left:hover .popover-container{transform:translate(-100%,-50%) scale(1)}.popover .card{border:0;box-shadow:0 .2rem .5rem rgba(48,55,66,.3)}.step{display:-ms-flexbox;display:flex;-ms-flex-wrap:nowrap;flex-wrap:nowrap;list-style:none;margin:.2rem 0;width:100%}.step .step-item{-ms-flex:1 1 0;flex:1 1 0;margin-top:0;min-height:1rem;position:relative;text-align:center}.step .step-item:not(:first-child)::before{background:#5755d9;content:"";height:2px;left:-50%;position:absolute;top:9px;width:100%}.step .step-item a{color:#5755d9;display:inline-block;padding:20px 10px 0;text-decoration:none}.step .step-item a::before{background:#5755d9;border:.1rem solid #fff;border-radius:50%;content:"";display:block;height:.6rem;left:50%;position:absolute;top:.2rem;transform:translateX(-50%);width:.6rem;z-index:1}.step .step-item.active a::before{background:#fff;border:.1rem solid #5755d9}.step .step-item.active~.step-item::before{background:#dadee4}.step .step-item.active~.step-item a{color:#bcc3ce}.step .step-item.active~.step-item a::before{background:#dadee4}.tab{align-items:center;border-bottom:.05rem solid #dadee4;display:-ms-flexbox;display:flex;-ms-flex-align:center;-ms-flex-wrap:wrap;flex-wrap:wrap;list-style:none;margin:.2rem 0 .15rem 0}.tab .tab-item{margin-top:0}.tab .tab-item a{border-bottom:.1rem solid transparent;color:inherit;display:block;margin:0 .4rem 0 0;padding:.4rem .2rem .3rem .2rem;text-decoration:none}.tab .tab-item a:focus,.tab .tab-item a:hover{color:#5755d9}.tab .tab-item a.active,.tab .tab-item.active a{border-bottom-color:#5755d9;color:#5755d9}.tab .tab-item.tab-action{-ms-flex:1 0 auto;flex:1 0 auto;text-align:right}.tab .tab-item .btn-clear{margin-top:-.2rem}.tab.tab-block .tab-item{-ms-flex:1 0 0;flex:1 0 0;text-align:center}.tab.tab-block .tab-item a{margin:0}.tab.tab-block .tab-item .badge[data-badge]::after{position:absolute;right:.1rem;top:.1rem;transform:translate(0,0)}.tab:not(.tab-block) .badge{padding-right:0}.tile{align-content:space-between;align-items:flex-start;display:-ms-flexbox;display:flex;-ms-flex-align:start;-ms-flex-line-pack:justify}.tile .tile-action,.tile .tile-icon{-ms-flex:0 0 auto;flex:0 0 auto}.tile .tile-content{-ms-flex:1 1 auto;flex:1 1 auto}.tile .tile-content:not(:first-child){padding-left:.4rem}.tile .tile-content:not(:last-child){padding-right:.4rem}.tile .tile-subtitle,.tile .tile-title{line-height:1.2rem}.tile.tile-centered{align-items:center;-ms-flex-align:center}.tile.tile-centered .tile-content{overflow:hidden}.tile.tile-centered .tile-subtitle,.tile.tile-centered .tile-title{margin-bottom:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.toast{background:rgba(48,55,66,.95);border:.05rem solid #303742;border-color:#303742;border-radius:.1rem;color:#fff;display:block;padding:.4rem;width:100%}.toast.toast-primary{background:rgba(87,85,217,.95);border-color:#5755d9}.toast.toast-success{background:rgba(50,182,67,.95);border-color:#32b643}.toast.toast-warning{background:rgba(255,183,0,.95);border-color:#ffb700}.toast.toast-error{background:rgba(232,86,0,.95);border-color:#e85600}.toast a{color:#fff;text-decoration:underline}.toast a.active,.toast a:active,.toast a:focus,.toast a:hover{opacity:.75}.toast .btn-clear{margin:.1rem}.toast p:last-child{margin-bottom:0}.tooltip{position:relative}.tooltip::after{background:rgba(48,55,66,.95);border-radius:.1rem;bottom:100%;color:#fff;content:attr(data-tooltip);display:block;font-size:.7rem;left:50%;max-width:320px;opacity:0;overflow:hidden;padding:.2rem .4rem;pointer-events:none;position:absolute;text-overflow:ellipsis;transform:translate(-50%,.4rem);transition:opacity .2s,transform .2s;white-space:pre;z-index:300}.tooltip:focus::after,.tooltip:hover::after{opacity:1;transform:translate(-50%,-.2rem)}.tooltip.disabled,.tooltip[disabled]{pointer-events:auto}.tooltip.tooltip-right::after{bottom:50%;left:100%;transform:translate(-.2rem,50%)}.tooltip.tooltip-right:focus::after,.tooltip.tooltip-right:hover::after{transform:translate(.2rem,50%)}.tooltip.tooltip-bottom::after{bottom:auto;top:100%;transform:translate(-50%,-.4rem)}.tooltip.tooltip-bottom:focus::after,.tooltip.tooltip-bottom:hover::after{transform:translate(-50%,.2rem)}.tooltip.tooltip-left::after{bottom:50%;left:auto;right:100%;transform:translate(.4rem,50%)}.tooltip.tooltip-left:focus::after,.tooltip.tooltip-left:hover::after{transform:translate(-.2rem,50%)}@keyframes loading{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}@keyframes slide-down{0%{opacity:0;transform:translateY(-1.6rem)}100%{opacity:1;transform:translateY(0)}}.text-primary{color:#5755d9!important}a.text-primary:focus,a.text-primary:hover{color:#4240d4}a.text-primary:visited{color:#6c6ade}.text-secondary{color:#e5e5f9!important}a.text-secondary:focus,a.text-secondary:hover{color:#d1d0f4}a.text-secondary:visited{color:#fafafe}.text-gray{color:#bcc3ce!important}a.text-gray:focus,a.text-gray:hover{color:#adb6c4}a.text-gray:visited{color:#cbd0d9}.text-light{color:#fff!important}a.text-light:focus,a.text-light:hover{color:#f2f2f2}a.text-light:visited{color:#fff}.text-dark{color:#3b4351!important}a.text-dark:focus,a.text-dark:hover{color:#303742}a.text-dark:visited{color:#455060}.text-success{color:#32b643!important}a.text-success:focus,a.text-success:hover{color:#2da23c}a.text-success:visited{color:#39c94b}.text-warning{color:#ffb700!important}a.text-warning:focus,a.text-warning:hover{color:#e6a500}a.text-warning:visited{color:#ffbe1a}.text-error{color:#e85600!important}a.text-error:focus,a.text-error:hover{color:#cf4d00}a.text-error:visited{color:#ff6003}.bg-primary{background:#5755d9!important;color:#fff}.bg-secondary{background:#f1f1fc!important}.bg-dark{background:#303742!important;color:#fff}.bg-gray{background:#f7f8f9!important}.bg-success{background:#32b643!important;color:#fff}.bg-warning{background:#ffb700!important;color:#fff}.bg-error{background:#e85600!important;color:#fff}.c-hand{cursor:pointer}.c-move{cursor:move}.c-zoom-in{cursor:zoom-in}.c-zoom-out{cursor:zoom-out}.c-not-allowed{cursor:not-allowed}.c-auto{cursor:auto}.d-block{display:block}.d-inline{display:inline}.d-inline-block{display:inline-block}.d-flex{display:-ms-flexbox;display:flex}.d-inline-flex{display:-ms-inline-flexbox;display:inline-flex}.d-hide,.d-none{display:none!important}.d-visible{visibility:visible}.d-invisible{visibility:hidden}.text-hide{background:0 0;border:0;color:transparent;font-size:0;line-height:0;text-shadow:none}.text-assistive{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.divider,.divider-vert{display:block;position:relative}.divider-vert[data-content]::after,.divider[data-content]::after{background:#fff;color:#bcc3ce;content:attr(data-content);display:inline-block;font-size:.7rem;padding:0 .4rem;transform:translateY(-.65rem)}.divider{border-top:.05rem solid #f1f3f5;height:.05rem;margin:.4rem 0}.divider[data-content]{margin:.8rem 0}.divider-vert{display:block;padding:.8rem}.divider-vert::before{border-left:.05rem solid #dadee4;bottom:.4rem;content:"";display:block;left:50%;position:absolute;top:.4rem;transform:translateX(-50%)}.divider-vert[data-content]::after{left:50%;padding:.2rem 0;position:absolute;top:50%;transform:translate(-50%,-50%)}.loading{color:transparent!important;min-height:.8rem;pointer-events:none;position:relative}.loading::after{animation:loading .5s infinite linear;background:0 0;border:.1rem solid #5755d9;border-radius:50%;border-right-color:transparent;border-top-color:transparent;content:"";display:block;height:.8rem;left:50%;margin-left:-.4rem;margin-top:-.4rem;opacity:1;padding:0;position:absolute;top:50%;width:.8rem;z-index:1}.loading.loading-lg{min-height:2rem}.loading.loading-lg::after{height:1.6rem;margin-left:-.8rem;margin-top:-.8rem;width:1.6rem}.clearfix::after{clear:both;content:"";display:table}.float-left{float:left!important}.float-right{float:right!important}.p-relative{position:relative!important}.p-absolute{position:absolute!important}.p-fixed{position:fixed!important}.p-sticky{position:-webkit-sticky!important;position:sticky!important}.p-centered{display:block;float:none;margin-left:auto;margin-right:auto}.flex-centered{align-items:center;display:-ms-flexbox;display:flex;-ms-flex-align:center;-ms-flex-pack:center;justify-content:center}.m-0{margin:0!important}.mb-0{margin-bottom:0!important}.ml-0{margin-left:0!important}.mr-0{margin-right:0!important}.mt-0{margin-top:0!important}.mx-0{margin-left:0!important;margin-right:0!important}.my-0{margin-bottom:0!important;margin-top:0!important}.m-1{margin:.2rem!important}.mb-1{margin-bottom:.2rem!important}.ml-1{margin-left:.2rem!important}.mr-1{margin-right:.2rem!important}.mt-1{margin-top:.2rem!important}.mx-1{margin-left:.2rem!important;margin-right:.2rem!important}.my-1{margin-bottom:.2rem!important;margin-top:.2rem!important}.m-2{margin:.4rem!important}.mb-2{margin-bottom:.4rem!important}.ml-2{margin-left:.4rem!important}.mr-2{margin-right:.4rem!important}.mt-2{margin-top:.4rem!important}.mx-2{margin-left:.4rem!important;margin-right:.4rem!important}.my-2{margin-bottom:.4rem!important;margin-top:.4rem!important}.p-0{padding:0!important}.pb-0{padding-bottom:0!important}.pl-0{padding-left:0!important}.pr-0{padding-right:0!important}.pt-0{padding-top:0!important}.px-0{padding-left:0!important;padding-right:0!important}.py-0{padding-bottom:0!important;padding-top:0!important}.p-1{padding:.2rem!important}.pb-1{padding-bottom:.2rem!important}.pl-1{padding-left:.2rem!important}.pr-1{padding-right:.2rem!important}.pt-1{padding-top:.2rem!important}.px-1{padding-left:.2rem!important;padding-right:.2rem!important}.py-1{padding-bottom:.2rem!important;padding-top:.2rem!important}.p-2{padding:.4rem!important}.pb-2{padding-bottom:.4rem!important}.pl-2{padding-left:.4rem!important}.pr-2{padding-right:.4rem!important}.pt-2{padding-top:.4rem!important}.px-2{padding-left:.4rem!important;padding-right:.4rem!important}.py-2{padding-bottom:.4rem!important;padding-top:.4rem!important}.s-rounded{border-radius:.1rem}.s-circle{border-radius:50%}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-normal{font-weight:400}.text-bold{font-weight:700}.text-italic{font-style:italic}.text-large{font-size:1.2em}.text-small{font-size:.9em}.text-tiny{font-size:.8em}.text-muted{opacity:.8}.text-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-clip{overflow:hidden;text-overflow:clip;white-space:nowrap}.text-break{-webkit-hyphens:auto;-ms-hyphens:auto;hyphens:auto;word-break:break-word;word-wrap:break-word}
public/index.php
New file
@@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return static function (array $context) {
    return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
public/js/inventory.js
New file
@@ -0,0 +1,596 @@
(function () {
    'use strict';
    var SYNC_INTERVAL = 30 * 1000;
    var SYNC_BATCH = 500;
    var LOG_LIMIT = 200;
    var KEYS = {
        books: 'inventory.books',
        queue: 'inventory.queue',
        log: 'inventory.log',
        syncedAt: 'inventory.syncedAt'
    };
    var root = document.getElementById('inventory');
    var messages = JSON.parse(document.getElementById('messages').textContent);
    var config = {
        booksUrl: root.dataset.booksUrl,
        syncUrl: root.dataset.syncUrl,
        swUrl: root.dataset.swUrl,
        scope: root.dataset.scope,
        bookUrl: root.dataset.bookUrl,
        locale: root.dataset.locale
    };
    var el = {
        form: document.getElementById('scan-form'),
        input: document.getElementById('scan-input'),
        location: document.getElementById('location'),
        message: document.getElementById('message'),
        connection: document.getElementById('connection'),
        booksCount: document.getElementById('books-count'),
        booksDate: document.getElementById('books-date'),
        pendingCount: document.getElementById('pending-count'),
        syncDate: document.getElementById('sync-date'),
        refresh: document.getElementById('refresh'),
        sync: document.getElementById('sync'),
        log: document.getElementById('log'),
        logEmpty: document.getElementById('log-empty'),
        clearLog: document.getElementById('clear-log')
    };
    /* ---------- storage ---------- */
    function load(key, fallback) {
        try {
            var value = localStorage.getItem(key);
            return value === null ? fallback : JSON.parse(value);
        } catch (e) {
            return fallback;
        }
    }
    function save(key, value) {
        try {
            localStorage.setItem(key, JSON.stringify(value));
        } catch (e) {
            showMessage('error', String(e));
        }
    }
    var state = {
        books: load(KEYS.books, {fetchedAt: null, list: []}),
        queue: load(KEYS.queue, []),
        location: null,
        log: load(KEYS.log, []),
        syncedAt: load(KEYS.syncedAt, null),
        syncing: false,
        syncFailed: false,
        byId: {},
        byIsbn: {},
        byIssn: {}
    };
    function indexBooks() {
        state.byId = {};
        state.byIsbn = {};
        state.byIssn = {};
        state.books.list.forEach(function (book) {
            state.byId[book.id] = book;
            book.isbn.forEach(function (isbn) {
                (state.byIsbn[isbn] = state.byIsbn[isbn] || []).push(book);
            });
            (book.issn || []).forEach(function (issn) {
                (state.byIssn[issn] = state.byIssn[issn] || []).push(book);
            });
        });
        // Scans not uploaded yet are newer than whatever the server sent.
        state.queue.forEach(function (scan) {
            if (state.byId[scan.id]) {
                state.byId[scan.id].placement = scan.location;
            }
        });
    }
    /* ---------- input recognition ---------- */
    /** Same rules as App\Model\Isbn: an ISBN-10 or ISBN-13 becomes an ISBN-13, anything else null. */
    function toIsbn13(value) {
        var digits = value.replace(/[- ]/g, '').toUpperCase();
        var i, sum;
        if (/^\d{9}[\dX]$/.test(digits)) {
            for (sum = 0, i = 0; i < 10; i++) {
                sum += (10 - i) * (digits[i] === 'X' ? 10 : +digits[i]);
            }
            if (sum % 11 !== 0) {
                return null;
            }
            var stem = '978' + digits.substring(0, 9);
            return stem + isbn13CheckDigit(stem);
        }
        if (/^97[89]\d{10}$/.test(digits) && digits[12] === isbn13CheckDigit(digits.substring(0, 12))) {
            return digits;
        }
        return null;
    }
    /** Same rules as LabelType::ISSN: the compact form of an ISSN-8 or an ISSN-13 barcode, or null. */
    function toIssn(value) {
        var compact = value.replace(/[- ]/g, '').toUpperCase();
        return /^(\d{7}[\dX]|\d{13})$/.test(compact) ? compact : null;
    }
    function isbn13CheckDigit(stem) {
        for (var sum = 0, i = 0; i < 12; i++) {
            sum += +stem[i] * (i % 2 === 0 ? 1 : 3);
        }
        return String((10 - sum % 10) % 10);
    }
    /**
     * @returns {{type: 'location', value: string}|{type: 'book', book: Object}|{type: 'error', message: string}}
     */
    function recognize(input) {
        if (/^\p{L}/u.test(input) || input.indexOf('/') !== -1) {
            return {type: 'location', value: input};
        }
        if (/^\d{1,8}$/.test(input)) {
            var book = state.byId[+input];
            return book
                ? {type: 'book', book: book}
                : {type: 'error', message: format(messages.unknown_id, {'%id%': +input})};
        }
        // A 13-digit barcode may be either number, so both are looked up.
        var isbn = toIsbn13(input);
        var issn = toIssn(input);
        if (isbn !== null || issn !== null) {
            var matches = [];
            (isbn === null ? [] : state.byIsbn[isbn] || [])
                .concat(issn === null ? [] : state.byIssn[issn] || [])
                .forEach(function (book) {
                    if (matches.indexOf(book) === -1) {
                        matches.push(book);
                    }
                });
            var number = isbn !== null ? 'ISBN ' + isbn : 'ISSN ' + issn;
            if (matches.length === 1) {
                return {type: 'book', book: matches[0]};
            }
            return matches.length === 0
                ? {type: 'error', message: format(messages.unknown_number, {'%number%': number})}
                : {type: 'error', message: format(messages.ambiguous_number, {
                    '%number%': number,
                    '%ids%': matches.map(function (b) { return b.id; }).join(', ')
                })};
        }
        return {type: 'error', message: format(messages.unrecognized, {'%input%': input})};
    }
    /* ---------- actions ---------- */
    function handleInput(raw) {
        var input = raw.trim();
        if (input === '') {
            return;
        }
        var result = recognize(input);
        if (result.type === 'location') {
            state.location = result.value;
            showMessage('primary', format(messages.location_set, {'%location%': result.value}));
            beep(true);
        } else if (result.type === 'error') {
            showMessage('error', result.message);
            beep(false);
        } else if (state.location === null) {
            showMessage('error', messages.no_location);
            beep(false);
        } else {
            assign(result.book, state.location);
            beep(true);
        }
        render();
    }
    function assign(book, location) {
        var previous = book.placement;
        var scan = {
            key: Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 10),
            id: book.id,
            location: location,
            at: new Date().toISOString()
        };
        // Only the latest pending scan of a book matters.
        state.queue = state.queue.filter(function (s) {
            return s.id !== book.id;
        });
        state.queue.push(scan);
        save(KEYS.queue, state.queue);
        state.log.forEach(function (entry) {
            if (entry.id === book.id && entry.status === 'pending') {
                entry.status = 'replaced';
            }
        });
        state.log = state.log.filter(function (entry) {
            return entry.status !== 'replaced';
        });
        state.log.unshift({
            key: scan.key,
            id: book.id,
            title: book.title,
            author: book.author,
            location: location,
            previous: previous,
            borrowed: !!book.borrowed,
            at: scan.at,
            status: 'pending'
        });
        state.log.length = Math.min(state.log.length, LOG_LIMIT);
        save(KEYS.log, state.log);
        book.placement = location;
        var moved = previous && previous !== location;
        var text = format(messages.assigned, {'%id%': book.id, '%title%': book.title, '%location%': location});
        if (moved) {
            text += ' ' + format(messages.moved, {'%previous%': previous});
        }
        // The server closes the loan when the scan arrives.
        if (book.borrowed) {
            text += ' ' + messages.will_return;
        }
        showMessage(moved || book.borrowed ? 'warning' : 'success', text);
    }
    function removeScan(key) {
        state.queue = state.queue.filter(function (s) {
            return s.key !== key;
        });
        save(KEYS.queue, state.queue);
        state.log = state.log.filter(function (e) {
            return e.key !== key;
        });
        save(KEYS.log, state.log);
        // The local placement may now be wrong; the next refresh corrects it.
        render();
    }
    function refreshBooks(quiet) {
        return fetch(config.booksUrl, {headers: {Accept: 'application/json'}, cache: 'no-store'})
            .then(function (response) {
                if (!response.ok) {
                    throw new Error('HTTP ' + response.status);
                }
                return response.json();
            })
            .then(function (data) {
                state.books = {fetchedAt: new Date().toISOString(), list: data.books};
                save(KEYS.books, state.books);
                indexBooks();
                if (!quiet) {
                    showMessage('success', format(messages.books_loaded, {'%count%': data.books.length}));
                }
                render();
            })
            .catch(function () {
                if (!quiet || state.books.list.length === 0) {
                    showMessage('error', messages.books_failed);
                }
            });
    }
    function sync(manual) {
        if (state.syncing || state.queue.length === 0) {
            if (manual && state.queue.length === 0) {
                showMessage('success', format(messages.synced, {'%count%': 0}));
            }
            return Promise.resolve();
        }
        state.syncing = true;
        render();
        // The queue holds at most one scan per book, so it maps directly to {id: location}.
        var batch = state.queue.slice(0, SYNC_BATCH);
        var body = {};
        var sentKeys = {};
        batch.forEach(function (scan) {
            body[scan.id] = scan.location;
            sentKeys[scan.key] = scan.id;
        });
        return fetch(config.syncUrl, {
            method: 'POST',
            headers: {'Content-Type': 'application/json', Accept: 'application/json'},
            body: JSON.stringify(body)
        })
            .then(function (response) {
                if (!response.ok) {
                    throw new Error('HTTP ' + response.status);
                }
                return response.json();
            })
            .then(function (data) {
                var results = data.results || {};
                var rejected = 0;
                var booksChanged = false;
                function resultFor(key) {
                    // A book scanned again while the request was running keeps its newer scan.
                    return Object.prototype.hasOwnProperty.call(sentKeys, key) ? results[sentKeys[key]] : undefined;
                }
                state.queue = state.queue.filter(function (scan) {
                    return resultFor(scan.key) === undefined;
                });
                save(KEYS.queue, state.queue);
                state.log.forEach(function (entry) {
                    var result = resultFor(entry.key);
                    if (result !== undefined) {
                        entry.status = result;
                        if (result === 'returned' && state.byId[entry.id]) {
                            state.byId[entry.id].borrowed = false;
                            booksChanged = true;
                        } else if (result !== 'saved') {
                            rejected++;
                        }
                    }
                });
                save(KEYS.log, state.log);
                if (booksChanged) {
                    save(KEYS.books, state.books);
                }
                state.syncedAt = new Date().toISOString();
                save(KEYS.syncedAt, state.syncedAt);
                state.syncFailed = false;
                if (rejected > 0) {
                    showMessage('error', format(messages.sync_rejected, {'%count%': rejected}));
                } else if (manual) {
                    showMessage('success', format(messages.synced, {'%count%': batch.length}));
                }
            })
            .catch(function () {
                state.syncFailed = true;
                if (manual) {
                    showMessage('error', messages.sync_failed);
                }
            })
            .then(function () {
                state.syncing = false;
                render();
                // More than one batch was waiting.
                if (!state.syncFailed && state.queue.length > 0 && batch.length === SYNC_BATCH) {
                    return sync(manual);
                }
            });
    }
    /* ---------- rendering ---------- */
    function format(template, params) {
        return Object.keys(params).reduce(function (text, key) {
            return text.split(key).join(String(params[key]));
        }, template);
    }
    function formatDate(iso) {
        if (!iso) {
            return messages.never;
        }
        try {
            return new Date(iso).toLocaleString(config.locale);
        } catch (e) {
            return new Date(iso).toLocaleString();
        }
    }
    var messageTimer = null;
    function showMessage(type, text) {
        el.message.className = 'toast message toast-' + type;
        el.message.textContent = text;
        el.message.hidden = false;
        clearTimeout(messageTimer);
        if (type !== 'error') {
            messageTimer = setTimeout(function () {
                el.message.hidden = true;
            }, 8000);
        }
    }
    function render() {
        el.location.textContent = state.location === null ? '–' : state.location;
        el.booksCount.textContent = state.books.list.length;
        el.booksDate.textContent = '(' + formatDate(state.books.fetchedAt) + ')';
        el.pendingCount.textContent = state.queue.length;
        el.syncDate.textContent = '(' + formatDate(state.syncedAt) + ')';
        el.sync.classList.toggle('loading', state.syncing);
        el.sync.disabled = state.syncing;
        var online = navigator.onLine;
        el.connection.textContent = online ? messages.online : messages.offline;
        el.connection.className = 'label label-rounded ' + (online ? 'label-success' : 'label-warning');
        el.logEmpty.hidden = state.log.length > 0;
        el.clearLog.hidden = state.log.length === 0;
        el.log.textContent = '';
        state.log.forEach(function (entry) {
            el.log.appendChild(renderLogEntry(entry));
        });
    }
    function cell(row, content, className) {
        var td = document.createElement('td');
        if (className) {
            td.className = className;
        }
        if (content instanceof Node) {
            td.appendChild(content);
        } else {
            td.textContent = content;
        }
        row.appendChild(td);
        return td;
    }
    var STATUS_CLASS = {
        pending: 'label-warning',
        saved: 'label-success',
        returned: 'label-success',
        unknown_book: 'label-error',
        invalid: 'label-error'
    };
    function renderLogEntry(entry) {
        var row = document.createElement('tr');
        var link = document.createElement('a');
        link.href = config.bookUrl.replace('{id}', entry.id);
        link.textContent = entry.id;
        cell(row, link);
        var title = document.createElement('span');
        title.textContent = entry.title + ' · ' + entry.author;
        cell(row, title, 'log-title');
        var location = document.createElement('span');
        location.textContent = entry.location;
        if (entry.previous && entry.previous !== entry.location) {
            var previous = document.createElement('del');
            previous.className = 'text-gray';
            previous.textContent = entry.previous;
            location = wrap([previous, document.createTextNode(' → '), location]);
        }
        cell(row, location);
        cell(row, formatDate(entry.at), 'text-gray log-date');
        var status = document.createElement('span');
        status.className = 'label label-rounded ' + (STATUS_CLASS[entry.status] || '');
        status.textContent = messages['status_' + entry.status] || entry.status;
        var actions = [status];
        if (entry.status === 'pending') {
            var remove = document.createElement('button');
            remove.type = 'button';
            remove.className = 'btn btn-link btn-sm';
            remove.textContent = messages.remove;
            remove.addEventListener('click', function () {
                removeScan(entry.key);
                el.input.focus();
            });
            actions.push(remove);
        }
        cell(row, wrap(actions), 'text-right');
        return row;
    }
    function wrap(nodes) {
        var span = document.createElement('span');
        nodes.forEach(function (node) {
            span.appendChild(node);
        });
        return span;
    }
    /* ---------- feedback sound, so the screen need not be watched while scanning ---------- */
    var audio = null;
    function beep(ok) {
        try {
            audio = audio || new (window.AudioContext || window.webkitAudioContext)();
            var oscillator = audio.createOscillator();
            var gain = audio.createGain();
            oscillator.frequency.value = ok ? 880 : 220;
            oscillator.type = ok ? 'sine' : 'square';
            gain.gain.value = 0.1;
            oscillator.connect(gain).connect(audio.destination);
            oscillator.start();
            oscillator.stop(audio.currentTime + (ok ? 0.08 : 0.4));
        } catch (e) {
            // No sound available; the message is enough.
        }
    }
    /* ---------- wiring ---------- */
    el.form.addEventListener('submit', function (event) {
        event.preventDefault();
        handleInput(el.input.value);
        el.input.value = '';
        el.input.focus();
    });
    el.refresh.addEventListener('click', function () {
        refreshBooks(false);
        el.input.focus();
    });
    el.sync.addEventListener('click', function () {
        sync(true);
        el.input.focus();
    });
    el.clearLog.addEventListener('click', function () {
        if (confirm(messages.confirm_clear)) {
            // Pending scans stay in the queue; only their history goes.
            state.log = state.log.filter(function (e) {
                return e.status === 'pending';
            });
            save(KEYS.log, state.log);
            render();
        }
        el.input.focus();
    });
    window.addEventListener('online', function () {
        render();
        sync(false);
    });
    window.addEventListener('offline', render);
    window.addEventListener('storage', function () {
        // Another tab scanned something; take over its state.
        state.books = load(KEYS.books, state.books);
        state.queue = load(KEYS.queue, []);
        state.log = load(KEYS.log, []);
        state.syncedAt = load(KEYS.syncedAt, null);
        indexBooks();
        render();
    });
    window.addEventListener('beforeunload', function () {
        if (state.queue.length > 0 && navigator.onLine) {
            sync(false);
        }
    });
    setInterval(function () {
        if (navigator.onLine) {
            sync(false);
        }
    }, SYNC_INTERVAL);
    if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register(config.swUrl, {scope: config.scope}).catch(function () {
            // Without a service worker (plain HTTP on a remote host) the page still works offline
            // once open, it just cannot be reopened without a connection.
        });
    }
    indexBooks();
    render();
    if (state.books.list.length === 0) {
        showMessage('warning', messages.no_books);
    }
    refreshBooks(true).then(function () {
        return sync(false);
    });
})();
search.php
File was deleted
src/Command/ImportLegacyCommand.php
New file
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\LegacyImport\CoverImporter;
use App\LegacyImport\CoverImportReport;
use App\LegacyImport\ImportReport;
use App\LegacyImport\LegacyImporter;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
    name: 'app:import-legacy',
    description: 'Imports books and covers from the old database schema',
    help: <<<'HELP'
        Reads the <info>ksiazki</info>, <info>pozycz</info> and <info>pozycz_historia</info> tables from the database at
        <info>LEGACY_DATABASE_URL</info> and writes them to the database at <info>DATABASE_URL</info>.
        Books already in the target, by ID, are kept as they are along with their loans, so the import can
        be run again to pick up books added to the legacy database since. Rows the new schema cannot hold
        are left out and listed in the output.
        Use <info>--dry-run</info> to run the whole import and roll it back.
        With <info>--covers=DIR</info>, legacy cover files in <info>DIR</info> are attached to the books already
        in the target database.
        HELP,
)]
class ImportLegacyCommand {
    public function __construct(
        private readonly LegacyImporter $importer,
        private readonly CoverImporter  $coverImporter,
    ) {
    }
    public function __invoke(
        SymfonyStyle                                                                       $io,
        #[Option('Run the import, then roll it back')] bool                                $dryRun = false,
        #[Option('Import the cover files from directory into the existing books')] ?string $covers = null,
    ): int {
        $this->import($io, $dryRun);
        if ($covers !== null) {
            $this->importCovers($io, $covers, $dryRun);
        }
        return Command::SUCCESS;
    }
    private function import(SymfonyStyle $io, bool $dryRun): void {
        $report = $this->importer->import($dryRun);
        $this->printReport($io, $report);
        $io->success($dryRun ? 'Dry run finished; nothing was saved.' : 'Import finished.');
    }
    private function printReport(SymfonyStyle $io, ImportReport $report): void {
        $io->table(['Target table', 'Rows'], [
            ['book', $report->books],
            ['ousted_book', $report->oustedBooks],
            ['borrow', $report->borrows],
            ['borrow_history', $report->borrowHistory],
            ['ousted_borrow_history', $report->oustedBorrowHistory],
            ['already in target, kept', $report->alreadyPresent],
        ]);
        if ($report->invalidIsbns) {
            $io->warning(\sprintf('%d numbers are not valid ISBNs and were kept as text labels:', \count($report->invalidIsbns)));
            $io->listing($report->invalidIsbns);
        }
        if ($report->skipped) {
            $io->warning(\sprintf('%d rows were left out:', \count($report->skipped)));
            $io->listing($report->skipped);
        }
    }
    private function importCovers(SymfonyStyle $io, string $directory, bool $dryRun): void {
        $report = $this->coverImporter->import($directory, $dryRun);
        $this->printCoverReport($io, $report);
        $io->success($dryRun ? 'Dry run finished; nothing was saved.' : \sprintf('%d covers imported.', $report->imported()));
    }
    private function printCoverReport(SymfonyStyle $io, CoverImportReport $report): void {
        $io->table(['Source', 'Books'], [
            ['own/, by book ID', $report->ownById],
            ['own/, by ISBN', $report->ownByIsbn],
            ['by ISBN', $report->byIsbn],
            ['already had a cover, kept', $report->alreadyCovered],
        ]);
        if ($report->failed) {
            $io->warning(\sprintf('%d files could not be imported:', \count($report->failed)));
            $io->listing($report->failed);
        }
        if ($report->unmatched) {
            $io->note(\sprintf('%d files match no book:', \count($report->unmatched)));
            $io->listing($report->unmatched);
        }
        if ($report->ignored) {
            $io->note(\sprintf('%d files are named neither by ISBN-13 nor by book ID, and were ignored:', \count($report->ignored)));
            $io->listing($report->ignored);
        }
    }
}
src/Controller/BookController.php
New file
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Book;
use App\Form\AddLabelType;
use App\Form\BookType;
use App\Form\BorrowType;
use App\Form\CoverType;
use App\Form\OustType;
use App\Repository\BookRepository;
use App\Service\Library;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
class BookController extends AbstractController {
    #[Route('/', name: 'home', methods: ['GET'])]
    #[Route('/list', name: 'book_list', methods: ['GET'])]
    public function list(Request $request, BookRepository $books, PaginatorInterface $paginator): Response {
        return $this->render('book/list.html.twig', [
            'pagination' => $paginator->paginate(
                $books->createListQueryBuilder(),
                max(1, $request->query->getInt('page', 1)),
            ),
        ]);
    }
    #[Route('/book/{id}', name: 'book_show', requirements: ['id' => Requirement::DIGITS], methods: ['GET'])]
    public function show(Book $book): Response {
        return $this->render('book/show.html.twig', [
            'book' => $book,
            'borrow_form' => $this->createForm(BorrowType::class, null, [
                'action' => $this->generateUrl('book_lend', ['id' => $book->getId()]),
            ]),
            'cover_form' => $this->createForm(CoverType::class, null, [
                'action' => $this->generateUrl('cover_upload', ['id' => $book->getId()]),
            ]),
            'label_form' => $this->createForm(AddLabelType::class, null, [
                'action' => $this->generateUrl('label_add', ['id' => $book->getId()]),
            ]),
            'oust_form' => $this->createForm(OustType::class, null, [
                'action' => $this->generateUrl('book_oust', ['id' => $book->getId()]),
            ]),
        ]);
    }
    #[Route('/add', name: 'book_add', methods: ['GET'])]
    public function add(): Response {
        return $this->render('book/add.html.twig');
    }
    #[Route('/add/form', name: 'book_new', methods: ['GET', 'POST'])]
    public function new(Request $request, EntityManagerInterface $entityManager, Library $library): Response {
        $book = $this->prefilledBook($request);
        $form = $this->createForm(BookType::class, $book, ['validation_groups' => ['Default', 'creation']]);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $entityManager->persist($book);
            $entityManager->flush();
            $this->addFlash('success', 'book_saved');
            if ($library->downloadCover($book)) {
                $this->addFlash('success', 'cover_downloaded');
            }
            return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
        }
        return $this->render('book/new.html.twig', ['form' => $form]);
    }
    #[Route('/book/{id}/edit', name: 'book_edit', requirements: ['id' => Requirement::DIGITS], methods: ['GET', 'POST'])]
    public function edit(Request $request, Book $book, EntityManagerInterface $entityManager): Response {
        $form = $this->createForm(BookType::class, $book, ['lock_id' => true]);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $entityManager->flush();
            $this->addFlash('success', 'book_saved');
            return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
        }
        return $this->render('book/edit.html.twig', ['book' => $book, 'form' => $form]);
    }
    private function prefilledBook(Request $request): Book {
        $query = $request->query;
        $id = $query->get('id', '');
        $year = $query->get('publicationYear', '');
        return (new Book())
            ->setId(ctype_digit($id) ? (int)$id : null)
            ->setTitle($query->get('title'))
            ->setAuthor($query->get('author'))
            ->setLanguage($query->get('language'))
            ->setPublisher($query->get('publisher'))
            ->setPublicationLoc($query->get('publicationLoc'))
            ->setPublicationYear(ctype_digit($year) ? (int)$year : null)
            ->setIsbn($query->get('isbn'))
            ->setIssn($query->get('issn'));
    }
}
src/Controller/BorrowController.php
New file
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Book;
use App\Form\BorrowType;
use App\Repository\BorrowRepository;
use App\Service\Library;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
class BorrowController extends AbstractController {
    #[Route('/borrowed', name: 'borrow_list', methods: ['GET'])]
    public function list(Request $request, BorrowRepository $borrows, PaginatorInterface $paginator): Response {
        return $this->render('borrow/list.html.twig', [
            'pagination' => $paginator->paginate(
                $borrows->createListQueryBuilder(),
                max(1, $request->query->getInt('page', 1)),
            ),
        ]);
    }
    #[Route('/book/{id}/lend', name: 'book_lend', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
    public function lend(Request $request, Book $book, Library $library): Response {
        $form = $this->createForm(BorrowType::class);
        $form->handleRequest($request);
        if (!$book->isBorrowed() && $form->isSubmitted() && $form->isValid()) {
            $library->lend($book, $form->getData()['who']);
            $this->addFlash('success', 'borrow_lent');
        } else {
            $this->addFlash('error', 'action_rejected');
        }
        return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
    }
    #[Route('/book/{id}/return', name: 'book_return', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
    public function return(Request $request, Book $book, Library $library): Response {
        if ($book->isBorrowed() && $this->isCsrfTokenValid('return' . $book->getId(), $request->getPayload()->getString('_token'))) {
            $library->giveBack($book);
            $this->addFlash('success', 'borrow_returned');
        } else {
            $this->addFlash('error', 'action_rejected');
        }
        return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
    }
}
src/Controller/CoverController.php
New file
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Book;
use App\Form\CoverType;
use App\Service\CoverProcessingException;
use App\Service\CoverStorage;
use App\Service\Library;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
class CoverController extends AbstractController {
    #[Route('/cover/{name}', name: 'cover_original', requirements: ['name' => '[0-9a-f]{32}\.(jpg|png|webp)'], methods: ['GET'])]
    public function original(string $name, CoverStorage $covers): Response {
        $path = $covers->originalPath($name);
        if (!is_file($path)) {
            throw $this->createNotFoundException();
        }
        $response = new BinaryFileResponse($path);
        $response->headers->set('X-Content-Type-Options', 'nosniff');
        return $response;
    }
    #[Route('/book/{id}/cover', name: 'cover_upload', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
    public function upload(Request $request, Book $book, Library $library): Response {
        $form = $this->createForm(CoverType::class);
        $form->handleRequest($request);
        if (!$form->isSubmitted()) {
            $this->addFlash('error', 'cover_too_large');
        } elseif (!$form->isValid()) {
            $this->addFlash('error', $form->getErrors(true)->current()->getMessage());
        } else {
            try {
                $library->changeCover($book, $form->getData()['cover']);
                $this->addFlash('success', 'cover_saved');
            } catch (CoverProcessingException) {
                $this->addFlash('error', 'cover_failed');
            }
        }
        return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
    }
    #[Route('/book/{id}/cover/remove', name: 'cover_remove', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
    public function remove(Request $request, Book $book, Library $library): Response {
        if ($this->isCsrfTokenValid('remove-cover' . $book->getId(), $request->getPayload()->getString('_token'))) {
            $library->changeCover($book, null);
        } else {
            $this->addFlash('error', 'action_rejected');
        }
        return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
    }
}
src/Controller/InventoryController.php
New file
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\BookRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class InventoryController extends AbstractController {
    public const int MAX_SCANS_PER_REQUEST = 500;
    public const string SAVED = 'saved';
    public const string RETURNED = 'returned';
    public const string UNKNOWN_BOOK = 'unknown_book';
    public const string INVALID = 'invalid';
    #[Route('/inventory/', name: 'inventory', methods: ['GET'])]
    public function index(): Response {
        return $this->render('inventory/index.html.twig');
    }
    /**
     * The service worker that caches the page. It is served by the application rather than from
     * public/ so that its scope covers /inventory/ and so that it knows the URLs to cache.
     */
    #[Route('/inventory/sw.js', name: 'inventory_service_worker', methods: ['GET'])]
    public function serviceWorker(): Response {
        $response = $this->render('inventory/sw.js.twig');
        $response->headers->set('Content-Type', 'text/javascript; charset=UTF-8');
        $response->headers->set('Cache-Control', 'no-cache');
        return $response;
    }
    #[Route('/inventory/books', name: 'inventory_books', methods: ['GET'])]
    public function books(BookRepository $books): JsonResponse {
        $response = $this->json(['books' => $books->findInventoryData()]);
        $response->headers->set('Cache-Control', 'no-store');
        return $response;
    }
    /**
     * Accepts {"<book ID>": "<location>", ...} and answers {"results": {"<book ID>": "<status>"}}.
     * Every listed book is marked as seen now.
     * A book found on the shelf while on loan is marked as returned.
     */
    #[Route('/inventory/sync', name: 'inventory_sync', methods: ['POST'])]
    public function sync(Request $request, BookRepository $books, EntityManagerInterface $entityManager): JsonResponse {
        if ($request->getContentTypeFormat() !== 'json') {
            return $this->json(['error' => 'Expected a JSON body.'], Response::HTTP_UNSUPPORTED_MEDIA_TYPE);
        }
        // Decoded as an object: as an array, {"0": "..."} would be indistinguishable from a list.
        try {
            $payload = json_decode($request->getContent(), false, 2, \JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            $payload = null;
        }
        $scans = $payload instanceof \stdClass ? get_object_vars($payload) : null;
        if ($scans === null || \count($scans) > self::MAX_SCANS_PER_REQUEST) {
            return $this->json(['error' => 'Expected an object mapping at most ' . self::MAX_SCANS_PER_REQUEST . ' book IDs to locations.'], Response::HTTP_BAD_REQUEST);
        }
        $valid = [];
        $results = [];
        foreach ($scans as $id => $location) {
            $location = \is_string($location) ? trim($location) : '';
            if (!\is_int($id) || $id < 0 || $location === '' || mb_strlen($location) > 255) {
                $results[$id] = self::INVALID;
            } else {
                $valid[$id] = $location;
                $results[$id] = self::UNKNOWN_BOOK;
            }
        }
        $now = new \DateTimeImmutable();
        foreach ($valid === [] ? [] : $books->findForInventorySync(array_keys($valid)) as $book) {
            $results[$book->getId()] = self::SAVED;
            if ($book->isBorrowed()) {
                $book->giveBack($now);
                $results[$book->getId()] = self::RETURNED;
            }
            $book->seenAt($valid[$book->getId()], $now);
        }
        $entityManager->flush();
        return $this->json(['results' => (object)$results]);
    }
}
src/Controller/LabelController.php
New file
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Book;
use App\Entity\Label;
use App\Form\AddLabelType;
use App\Repository\BookRepository;
use App\Repository\LabelRepository;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
class LabelController extends AbstractController {
    #[Route('/book/{id}/labels', name: 'label_add', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
    public function add(Request $request, Book $book, EntityManagerInterface $entityManager): Response {
        $form = $this->createForm(AddLabelType::class);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $data = $form->getData();
            $book->addLabel($data['label'], $data['type']);
            $entityManager->flush();
        } else {
            $this->addFlash('error', 'action_rejected');
        }
        return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
    }
    #[Route('/label/{id}/remove', name: 'label_remove', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
    public function remove(Request $request, Label $label, EntityManagerInterface $entityManager): Response {
        $book = $label->getBook();
        if ($this->isCsrfTokenValid('remove-label' . $label->getId(), $request->getPayload()->getString('_token'))) {
            $book->removeLabel($label);
            $entityManager->flush();
        } else {
            $this->addFlash('error', 'action_rejected');
        }
        return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
    }
}
src/Controller/LookupController.php
New file
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Lookup\BookLookupInterface;
use App\Lookup\LookupFailedException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class LookupController extends AbstractController {
    #[Route('/add/lookup', name: 'book_lookup', methods: ['GET'])]
    public function lookup(Request $request, BookLookupInterface $lookup): Response {
        $query = $request->query;
        $number = trim($query->getString('isn'));
        $title = trim($query->getString('title'));
        $author = trim($query->getString('author'));
        $publisher = trim($query->getString('publisher'));
        if ($number === '' && $title === '' && $author === '' && $publisher === '') {
            return $this->redirectToRoute('book_add');
        }
        try {
            $candidates = $number !== ''
                ? $lookup->findByStandardNumber($number)
                : $lookup->findByDescription($title, $author, $publisher);
        } catch (LookupFailedException) {
            $this->addFlash('error', 'lookup_failed');
            return $this->redirectToRoute('book_add');
        }
        return $this->render('book/lookup.html.twig', [
            'candidates' => $candidates,
            'book_id' => $query->getString('id'),
        ]);
    }
}
src/Controller/OustedBookController.php
New file
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Book;
use App\Entity\OustedBook;
use App\Form\BulkOustType;
use App\Form\OustType;
use App\Repository\BookRepository;
use App\Repository\OustedBookRepository;
use App\Service\Library;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Contracts\Translation\TranslatorInterface;
class OustedBookController extends AbstractController {
    #[Route('/ousted', name: 'ousted_list', methods: ['GET'])]
    public function list(Request $request, OustedBookRepository $books, PaginatorInterface $paginator): Response {
        return $this->render('ousted/list.html.twig', [
            'pagination' => $paginator->paginate(
                $books->createListQueryBuilder(),
                max(1, $request->query->getInt('page', 1)),
            ),
        ]);
    }
    #[Route('/ousted/{id}', name: 'ousted_show', requirements: ['id' => Requirement::DIGITS], methods: ['GET'])]
    public function show(OustedBook $book): Response {
        return $this->render('ousted/show.html.twig', ['book' => $book]);
    }
    #[Route('/book/{id}/oust', name: 'book_oust', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
    public function oust(Request $request, Book $book, Library $library): Response {
        $form = $this->createForm(OustType::class);
        $form->handleRequest($request);
        if ($book->isBorrowed() || !$form->isSubmitted() || !$form->isValid()) {
            $this->addFlash('error', 'action_rejected');
            return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
        }
        $ousted = $library->oust($book, $form->getData()['reason']);
        $this->addFlash('success', 'ousted_done');
        return $this->redirectToRoute('ousted_show', ['id' => $ousted->getId()]);
    }
    /**
     * Ousts the books that an inventory did not find: those not seen on or after a date.
     */
    #[Route('/ousted/bulk', name: 'ousted_bulk', methods: ['GET', 'POST'])]
    public function bulk(Request $request, BookRepository $books, Library $library, PaginatorInterface $paginator, TranslatorInterface $translator): Response {
        if ($request->isMethod('POST')) {
            $form = $this->createForm(BulkOustType::class, null, ['confirm' => true]);
            $form->handleRequest($request);
            if (!$form->isSubmitted() || !$form->isValid()) {
                $this->addFlash('error', 'action_rejected');
                return $this->redirectToRoute('ousted_bulk');
            }
            ['notSeenSince' => $since, 'reason' => $reason, 'selection' => $selection] = $form->getData();
            $back = ['bulk_oust' => ['notSeenSince' => $since->format('Y-m-d'), 'reason' => $reason]];
            if (!hash_equals($books->notSeenSinceFingerprint($since), (string)$selection)) {
                $this->addFlash('error', 'bulk_oust_changed');
                return $this->redirectToRoute('ousted_bulk', $back);
            }
            $ousted = $library->oustAll($books->createNotSeenSinceQueryBuilder($since)->getQuery()->getResult(), $reason);
            $this->addFlash('success', $translator->trans('bulk_oust_done', ['%count%' => $ousted]));
            return $this->redirectToRoute('ousted_list');
        }
        $form = $this->createForm(BulkOustType::class);
        $form->handleRequest($request);
        $parameters = ['form' => $form, 'pagination' => null];
        if ($form->isSubmitted() && $form->isValid()) {
            ['notSeenSince' => $since, 'reason' => $reason] = $form->getData();
            $parameters['since'] = $since;
            $parameters['borrowed'] = $books->countNotSeenSince($since, borrowed: true);
            $parameters['pagination'] = $paginator->paginate(
                $books->createNotSeenSinceQueryBuilder($since),
                max(1, $request->query->getInt('page', 1)),
            );
            $parameters['confirm_form'] = $this->createForm(BulkOustType::class, [
                'notSeenSince' => $since,
                'reason' => $reason,
                'selection' => $books->notSeenSinceFingerprint($since),
            ], ['confirm' => true, 'action' => $this->generateUrl('ousted_bulk')]);
        }
        return $this->render('ousted/bulk.html.twig', $parameters);
    }
    #[Route('/ousted/{id}/restore', name: 'ousted_restore', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
    public function restore(Request $request, OustedBook $book, Library $library): Response {
        if (!$this->isCsrfTokenValid('restore' . $book->getId(), $request->getPayload()->getString('_token'))) {
            $this->addFlash('error', 'action_rejected');
            return $this->redirectToRoute('ousted_show', ['id' => $book->getId()]);
        }
        $restored = $library->restore($book);
        $this->addFlash('success', 'ousted_restored');
        return $this->redirectToRoute('book_show', ['id' => $restored->getId()]);
    }
}
src/Controller/SearchController.php
New file
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Form\BookSearchType;
use App\Model\BookSearch;
use App\Repository\BookRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class SearchController extends AbstractController {
    #[Route('/search', name: 'search', methods: ['GET'])]
    public function search(Request $request, BookRepository $books, PaginatorInterface $paginator): Response {
        $search = new BookSearch();
        $form = $this->createForm(BookSearchType::class, $search);
        $form->handleRequest($request);
        $quickQuery = trim($request->query->getString('q'));
        $builder = match (true) {
            '' !== $quickQuery => $books->createQuickSearchQueryBuilder($quickQuery),
            !$search->isEmpty() => $books->createSearchQueryBuilder($search),
            default => null,
        };
        return $this->render('search/index.html.twig', [
            'form' => $form,
            'pagination' => $builder ? $paginator->paginate($builder, max(1, $request->query->getInt('page', 1))) : null,
        ]);
    }
}
src/Entity/AbstractBook.php
New file
@@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\LabelType;
use App\Validator\AvailableBookId;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
 * Bibliographic data shared by books on the shelves and books taken out of the collection.
 *
 * The ID is assigned by hand: it is the code on the sticker put on the physical book.
 */
#[ORM\MappedSuperclass]
abstract class AbstractBook {
    #[ORM\Id]
    #[ORM\Column]
    #[Assert\NotNull]
    #[Assert\PositiveOrZero]
    #[AvailableBookId(groups: ['creation'])]
    protected ?int $id = null;
    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    protected ?string $title = null;
    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    protected ?string $author = null;
    #[ORM\Column(length: 255, nullable: true)]
    #[Assert\Length(max: 255)]
    protected ?string $language = null;
    #[ORM\Column(length: 255, nullable: true)]
    #[Assert\Length(max: 255)]
    protected ?string $edition = null;
    #[ORM\Column(length: 255, nullable: true)]
    #[Assert\Length(max: 255)]
    protected ?string $publisher = null;
    #[ORM\Column(length: 255, nullable: true)]
    #[Assert\Length(max: 255)]
    protected ?string $publicationLoc = null;
    #[ORM\Column(type: Types::INTEGER, nullable: true)]
    #[Assert\Range(min: 1000, max: 2200)]
    protected ?int $publicationYear = null;
    #[ORM\Column(length: 255, nullable: true)]
    #[Assert\Length(max: 255)]
    protected ?string $placement = null;
    #[ORM\Column(length: 64, nullable: true)]
    protected ?string $cover = null;
    /**
     * When the book was last found on its shelf during an inventory.
     */
    #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
    protected ?\DateTimeImmutable $lastSeen = null;
    public function getId(): ?int {
        return $this->id;
    }
    /**
     * File name of the original cover, see CoverStorage.
     */
    public function getCover(): ?string {
        return $this->cover;
    }
    public function setCover(?string $cover): static {
        $this->cover = $cover;
        return $this;
    }
    public function setId(?int $id): static {
        $this->id = $id;
        return $this;
    }
    public function getTitle(): ?string {
        return $this->title;
    }
    public function setTitle(?string $title): static {
        $this->title = $title;
        return $this;
    }
    public function getAuthor(): ?string {
        return $this->author;
    }
    public function setAuthor(?string $author): static {
        $this->author = $author;
        return $this;
    }
    public function getLanguage(): ?string {
        return $this->language;
    }
    public function setLanguage(?string $language): static {
        $this->language = $language;
        return $this;
    }
    public function getEdition(): ?string {
        return $this->edition;
    }
    public function setEdition(?string $edition): static {
        $this->edition = $edition;
        return $this;
    }
    public function getPublisher(): ?string {
        return $this->publisher;
    }
    public function setPublisher(?string $publisher): static {
        $this->publisher = $publisher;
        return $this;
    }
    public function getPublicationLoc(): ?string {
        return $this->publicationLoc;
    }
    public function setPublicationLoc(?string $publicationLoc): static {
        $this->publicationLoc = $publicationLoc;
        return $this;
    }
    public function getPublicationYear(): ?int {
        return $this->publicationYear;
    }
    public function setPublicationYear(?int $publicationYear): static {
        $this->publicationYear = $publicationYear;
        return $this;
    }
    #[Assert\Callback]
    public function validateStandardNumbers(ExecutionContextInterface $context): void {
        foreach ([LabelType::ISBN, LabelType::ISSN] as $type) {
            $value = $this->firstLabel($type)?->getLabel();
            if ($value !== null && !$type->isValid($value)) {
                $context->buildViolation('label_invalid_number')
                    ->setTranslationDomain('messages')
                    ->atPath($type->value)
                    ->addViolation();
            }
        }
    }
    public function getIsbn(): ?string {
        return $this->firstLabel(LabelType::ISBN)?->getLabel();
    }
    public function setIsbn(?string $isbn): static {
        $this->replaceFirstLabel(LabelType::ISBN, $isbn);
        return $this;
    }
    public function getIssn(): ?string {
        return $this->firstLabel(LabelType::ISSN)?->getLabel();
    }
    public function setIssn(?string $issn): static {
        $this->replaceFirstLabel(LabelType::ISSN, $issn);
        return $this;
    }
    /**
     * @return Collection<int, covariant AbstractLabel>
     */
    abstract public function getLabels(): Collection;
    abstract protected function createLabel(string $label, LabelType $type): AbstractLabel;
    /**
     * @return list<AbstractLabel>
     */
    public function getTextLabels(): array {
        return array_values($this->getLabels()->filter(
            static fn(AbstractLabel $label): bool => $label->getType() === LabelType::TEXT,
        )->toArray());
    }
    public function addLabel(string $text, LabelType $type = LabelType::TEXT): void {
        $text = $type->normalize($text);
        foreach ($this->getLabels() as $label) {
            if ($label->getType() === $type && $label->getLabel() === $text) {
                return;
            }
        }
        $this->getLabels()->add($this->createLabel($text, $type));
    }
    public function removeLabel(AbstractLabel $label): void {
        $this->getLabels()->removeElement($label);
    }
    public function getPlacement(): ?string {
        return $this->placement;
    }
    public function setPlacement(?string $placement): static {
        $this->placement = $placement;
        return $this;
    }
    public function getLastSeen(): ?\DateTimeImmutable {
        return $this->lastSeen;
    }
    public function setLastSeen(?\DateTimeImmutable $lastSeen): static {
        $this->lastSeen = $lastSeen;
        return $this;
    }
    /**
     * Records that the book was found at $placement during an inventory.
     */
    public function seenAt(string $placement, \DateTimeImmutable $at): void {
        $this->placement = $placement;
        $this->lastSeen = $at;
    }
    /**
     * Copies the bibliographic data and labels, keeping the ID; used when a book moves between
     * collections.
     */
    protected function copyDataFrom(self $source): void {
        $this->id = $source->id;
        $this->title = $source->title;
        $this->author = $source->author;
        $this->language = $source->language;
        $this->edition = $source->edition;
        $this->publisher = $source->publisher;
        $this->publicationLoc = $source->publicationLoc;
        $this->publicationYear = $source->publicationYear;
        $this->placement = $source->placement;
        $this->cover = $source->cover;
        $this->lastSeen = $source->lastSeen;
        foreach ($source->getLabels() as $label) {
            $this->addLabel($label->getLabel(), $label->getType());
        }
    }
    private function firstLabel(LabelType $type): ?AbstractLabel {
        foreach ($this->getLabels() as $label) {
            if ($label->getType() === $type) {
                return $label;
            }
        }
        return null;
    }
    private function replaceFirstLabel(LabelType $type, ?string $value): void {
        $value = $type->normalize((string)$value);
        $current = $this->firstLabel($type);
        if ($current?->getLabel() === $value) {
            return;
        }
        if ($current !== null) {
            $this->removeLabel($current);
        }
        if ($value !== '') {
            $this->addLabel($value, $type);
        }
    }
}
src/Entity/AbstractBorrowHistory.php
New file
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
 * A finished loan. It has a surrogate key because Doctrine cannot use dates in identifiers;
 * the (book, at) pair stays unique through a constraint on each concrete table.
 */
#[ORM\MappedSuperclass]
abstract class AbstractBorrowHistory {
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;
    #[ORM\Column(length: 255)]
    private string $who;
    #[ORM\Column]
    private \DateTimeImmutable $at;
    #[ORM\Column]
    private \DateTimeImmutable $till;
    public function __construct(string $who, \DateTimeImmutable $at, \DateTimeImmutable $till) {
        $this->who = $who;
        $this->at = $at;
        $this->till = $till;
    }
    public function getId(): ?int {
        return $this->id;
    }
    public function getWho(): string {
        return $this->who;
    }
    public function getAt(): \DateTimeImmutable {
        return $this->at;
    }
    public function getTill(): \DateTimeImmutable {
        return $this->till;
    }
}
src/Entity/AbstractLabel.php
New file
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\LabelType;
use Doctrine\ORM\Mapping as ORM;
#[ORM\MappedSuperclass]
#[ORM\HasLifecycleCallbacks]
abstract class AbstractLabel {
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;
    #[ORM\Column(length: 16, enumType: LabelType::class)]
    private LabelType $type;
    #[ORM\Column(length: 255)]
    private string $label;
    public function __construct(string $label, LabelType $type) {
        $this->label = $label;
        $this->type = $type;
    }
    /**
     * Last line of defence: forms validate first, but no path may store a malformed number.
     * Labels are immutable, so checking them once is enough.
     */
    #[ORM\PrePersist]
    public function assertValid(): void {
        if (!$this->type->isValid($this->label)) {
            throw new \DomainException(\sprintf('"%s" is not a valid %s label.', $this->label, $this->type->value));
        }
    }
    public function getId(): ?int {
        return $this->id;
    }
    public function getType(): LabelType {
        return $this->type;
    }
    public function getLabel(): string {
        return $this->label;
    }
}
src/Entity/Book.php
New file
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\LabelType;
use App\Repository\BookRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: BookRepository::class)]
class Book extends AbstractBook {
    #[ORM\OneToOne(targetEntity: Borrow::class, mappedBy: 'book', cascade: ['persist', 'remove'], orphanRemoval: true)]
    private ?Borrow $borrow = null;
    /** @var Collection<int, Label> */
    #[ORM\OneToMany(targetEntity: Label::class, mappedBy: 'book', cascade: ['persist', 'remove'], fetch: 'EAGER', orphanRemoval: true)]
    #[ORM\OrderBy(['label' => 'ASC'])]
    private Collection $labels;
    /** @var Collection<int, BorrowHistory> */
    #[ORM\OneToMany(targetEntity: BorrowHistory::class, mappedBy: 'book', cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[ORM\OrderBy(['at' => 'DESC'])]
    private Collection $borrowHistory;
    public function __construct() {
        $this->labels = new ArrayCollection();
        $this->borrowHistory = new ArrayCollection();
    }
    public static function fromOusted(OustedBook $ousted): self {
        $book = new self();
        $book->copyDataFrom($ousted);
        return $book;
    }
    public function getBorrow(): ?Borrow {
        return $this->borrow;
    }
    public function isBorrowed(): bool {
        return $this->borrow !== null;
    }
    public function lendTo(string $who, \DateTimeImmutable $at): void {
        if ($this->isBorrowed()) {
            throw new \LogicException(\sprintf('Book %d is already borrowed.', $this->id));
        }
        $this->borrow = new Borrow($this, $who, $at);
    }
    public function giveBack(\DateTimeImmutable $at): void {
        if (!$this->borrow instanceof Borrow) {
            throw new \LogicException(\sprintf('Book %d is not borrowed.', $this->id));
        }
        $this->borrowHistory->add(new BorrowHistory($this, $this->borrow->getWho(), $this->borrow->getAt(), $at));
        $this->borrow = null;
    }
    /**
     * @return Collection<int, Label>
     */
    public function getLabels(): Collection {
        return $this->labels;
    }
    protected function createLabel(string $label, LabelType $type): Label {
        return new Label($this, $label, $type);
    }
    /**
     * @return Collection<int, BorrowHistory>
     */
    public function getBorrowHistory(): Collection {
        return $this->borrowHistory;
    }
    public function addBorrowHistory(string $who, \DateTimeImmutable $at, \DateTimeImmutable $till): void {
        $this->borrowHistory->add(new BorrowHistory($this, $who, $at, $till));
    }
}
src/Entity/Borrow.php
New file
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\BorrowRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: BorrowRepository::class)]
class Borrow {
    #[ORM\Id]
    #[ORM\OneToOne(targetEntity: Book::class, inversedBy: 'borrow')]
    #[ORM\JoinColumn(nullable: false)]
    private Book $book;
    #[ORM\Column(length: 255)]
    private string $who;
    #[ORM\Column]
    private \DateTimeImmutable $at;
    public function __construct(Book $book, string $who, \DateTimeImmutable $at) {
        $this->book = $book;
        $this->who = $who;
        $this->at = $at;
    }
    public function getBook(): Book {
        return $this->book;
    }
    public function getWho(): string {
        return $this->who;
    }
    public function getAt(): \DateTimeImmutable {
        return $this->at;
    }
}
src/Entity/BorrowHistory.php
New file
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\BorrowHistoryRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: BorrowHistoryRepository::class)]
#[ORM\UniqueConstraint(name: 'borrow_history_book_at', columns: ['book_id', 'at'])]
class BorrowHistory extends AbstractBorrowHistory {
    #[ORM\ManyToOne(targetEntity: Book::class, inversedBy: 'borrowHistory')]
    #[ORM\JoinColumn(nullable: false)]
    private Book $book;
    public function __construct(Book $book, string $who, \DateTimeImmutable $at, \DateTimeImmutable $till) {
        parent::__construct($who, $at, $till);
        $this->book = $book;
    }
    public function getBook(): Book {
        return $this->book;
    }
}
src/Entity/Label.php
New file
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\LabelType;
use App\Repository\LabelRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: LabelRepository::class)]
#[ORM\UniqueConstraint(name: 'label_book_type_label', columns: ['book_id', 'type', 'label'])]
#[ORM\Index(name: 'label_label', columns: ['label'])]
class Label extends AbstractLabel {
    #[ORM\ManyToOne(targetEntity: Book::class, inversedBy: 'labels')]
    #[ORM\JoinColumn(nullable: false)]
    private Book $book;
    public function __construct(Book $book, string $label, LabelType $type = LabelType::TEXT) {
        parent::__construct($label, $type);
        $this->book = $book;
    }
    public function getBook(): Book {
        return $this->book;
    }
}
src/Entity/OustedBook.php
New file
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\LabelType;
use App\Repository\OustedBookRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
 * A book taken out of the collection: given away, lost, destroyed.
 */
#[ORM\Entity(repositoryClass: OustedBookRepository::class)]
class OustedBook extends AbstractBook {
    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    private string $reason;
    /** @var Collection<int, OustedLabel> */
    #[ORM\OneToMany(targetEntity: OustedLabel::class, mappedBy: 'book', cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[ORM\OrderBy(['label' => 'ASC'])]
    private Collection $labels;
    /** @var Collection<int, OustedBorrowHistory> */
    #[ORM\OneToMany(targetEntity: OustedBorrowHistory::class, mappedBy: 'book', cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[ORM\OrderBy(['at' => 'DESC'])]
    private Collection $borrowHistory;
    public function __construct(string $reason) {
        $this->reason = $reason;
        $this->labels = new ArrayCollection();
        $this->borrowHistory = new ArrayCollection();
    }
    public static function fromBook(Book $book, string $reason): self {
        $ousted = new self($reason);
        $ousted->copyDataFrom($book);
        return $ousted;
    }
    public function getReason(): string {
        return $this->reason;
    }
    /**
     * @return Collection<int, OustedLabel>
     */
    public function getLabels(): Collection {
        return $this->labels;
    }
    protected function createLabel(string $label, LabelType $type): OustedLabel {
        return new OustedLabel($this, $label, $type);
    }
    /**
     * @return Collection<int, OustedBorrowHistory>
     */
    public function getBorrowHistory(): Collection {
        return $this->borrowHistory;
    }
    public function addBorrowHistory(string $who, \DateTimeImmutable $at, \DateTimeImmutable $till): void {
        $this->borrowHistory->add(new OustedBorrowHistory($this, $who, $at, $till));
    }
}
src/Entity/OustedBorrowHistory.php
New file
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\OustedBorrowHistoryRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: OustedBorrowHistoryRepository::class)]
#[ORM\UniqueConstraint(name: 'ousted_borrow_history_book_at', columns: ['book_id', 'at'])]
class OustedBorrowHistory extends AbstractBorrowHistory {
    #[ORM\ManyToOne(targetEntity: OustedBook::class, inversedBy: 'borrowHistory')]
    #[ORM\JoinColumn(nullable: false)]
    private OustedBook $book;
    public function __construct(OustedBook $book, string $who, \DateTimeImmutable $at, \DateTimeImmutable $till) {
        parent::__construct($who, $at, $till);
        $this->book = $book;
    }
    public function getBook(): OustedBook {
        return $this->book;
    }
}
src/Entity/OustedLabel.php
New file
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\LabelType;
use App\Repository\OustedLabelRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: OustedLabelRepository::class)]
#[ORM\UniqueConstraint(name: 'ousted_label_book_type_label', columns: ['book_id', 'type', 'label'])]
class OustedLabel extends AbstractLabel {
    #[ORM\ManyToOne(targetEntity: OustedBook::class, inversedBy: 'labels')]
    #[ORM\JoinColumn(nullable: false)]
    private OustedBook $book;
    public function __construct(OustedBook $book, string $label, LabelType $type = LabelType::TEXT) {
        parent::__construct($label, $type);
        $this->book = $book;
    }
    public function getBook(): OustedBook {
        return $this->book;
    }
}
src/Enum/LabelType.php
New file
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Enum;
use App\Model\Isbn;
enum LabelType: string {
    case TEXT = 'text';
    case ISBN = 'isbn';
    case ISSN = 'issn';
    /**
     * Brings a value to its stored form; an ISBN-10 becomes an ISBN-13. A value that cannot be
     * normalized is returned as it is, and isValid() then rejects it.
     */
    public function normalize(string $value): string {
        $compact = strtoupper(str_replace(['-', ' '], '', $value));
        return match ($this) {
            self::TEXT => trim($value),
            self::ISBN => Isbn::toIsbn13($compact) ?? $compact,
            self::ISSN => $compact,
        };
    }
    public function isValid(string $normalizedValue): bool {
        return match ($this) {
            self::TEXT => $normalizedValue !== '',
            self::ISBN => $normalizedValue === Isbn::toIsbn13($normalizedValue) && \strlen($normalizedValue) === 13,
            self::ISSN => preg_match('/^(\d{7}[\dX]|\d{13})$/', $normalizedValue) === 1,
        };
    }
}
src/Form/AddLabelType.php
New file
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Enum\LabelType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
 * @extends AbstractType<array{type: LabelType, label: string}>
 */
class AddLabelType extends AbstractType {
    public function buildForm(FormBuilderInterface $builder, array $options): void {
        $builder
            ->add('type', EnumType::class, [
                'class' => LabelType::class,
                'label' => 'label_type_header',
                'choice_label' => static fn(LabelType $type): string => 'label_type_' . $type->value,
            ])
            ->add('label', TextType::class, [
                'label' => 'label_header',
                'constraints' => [new NotBlank(), new Length(max: 255)],
            ])
            ->add('save', SubmitType::class, ['label' => 'label_action_add']);
    }
    public function configureOptions(OptionsResolver $resolver): void {
        $resolver->setDefaults([
            'constraints' => [new Callback(self::validateFormat(...))],
        ]);
    }
    /**
     * @param array{type: ?LabelType, label: ?string} $data
     */
    public static function validateFormat(array $data, ExecutionContextInterface $context): void {
        $type = $data['type'];
        if ($type !== null && !$type->isValid($type->normalize((string)$data['label']))) {
            $context->buildViolation('label_invalid_number')->setTranslationDomain('messages')->atPath('[label]')->addViolation();
        }
    }
}
src/Form/BookSearchType.php
New file
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Model\BookSearch;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
 * @extends AbstractType<BookSearch>
 */
class BookSearchType extends AbstractType {
    public function buildForm(FormBuilderInterface $builder, array $options): void {
        $builder
            ->add('author', TextType::class, ['label' => 'book_author_header', 'required' => false])
            ->add('title', TextType::class, ['label' => 'book_title_header', 'required' => false])
            ->add('edition', TextType::class, ['label' => 'book_edition_header', 'required' => false])
            ->add('publisher', TextType::class, ['label' => 'book_publisher_header', 'required' => false]);
    }
    public function configureOptions(OptionsResolver $resolver): void {
        $resolver->setDefaults([
            'data_class' => BookSearch::class,
            'method' => 'GET',
            'csrf_protection' => false,
        ]);
    }
    public function getBlockPrefix(): string {
        return '';
    }
}
src/Form/BookType.php
New file
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Book;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
 * @extends AbstractType<Book>
 */
class BookType extends AbstractType {
    public function buildForm(FormBuilderInterface $builder, array $options): void {
        $builder
            ->add('id', IntegerType::class, ['label' => 'book_id_header', 'disabled' => $options['lock_id']])
            ->add('title', TextType::class, ['label' => 'book_title_header'])
            ->add('author', TextType::class, ['label' => 'book_author_header'])
            ->add('language', TextType::class, ['label' => 'book_language_header', 'required' => false])
            ->add('edition', TextType::class, ['label' => 'book_edition_header', 'required' => false])
            ->add('publisher', TextType::class, ['label' => 'book_publisher_header', 'required' => false])
            ->add('publicationLoc', TextType::class, ['label' => 'book_publication_loc_header', 'required' => false])
            ->add('publicationYear', IntegerType::class, ['label' => 'book_publication_year_header', 'required' => false])
            ->add('isbn', TextType::class, ['label' => 'book_isbn_header', 'required' => false])
            ->add('issn', TextType::class, ['label' => 'book_issn_header', 'required' => false])
            ->add('placement', TextType::class, ['label' => 'book_placement_header', 'required' => false])
            ->add('save', SubmitType::class, ['label' => 'book_action_save']);
    }
    public function configureOptions(OptionsResolver $resolver): void {
        $resolver->setDefaults([
            'data_class' => Book::class,
            'lock_id' => false,
        ]);
        $resolver->setAllowedTypes('lock_id', 'bool');
    }
}
src/Form/BorrowType.php
New file
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
 * @extends AbstractType<array{who: string}>
 */
class BorrowType extends AbstractType {
    public function buildForm(FormBuilderInterface $builder, array $options): void {
        $builder
            ->add('who', TextType::class, [
                'label' => 'borrow_who_header',
                'constraints' => [new NotBlank(), new Length(max: 255)],
            ])
            ->add('save', SubmitType::class, ['label' => 'borrow_action_lend']);
    }
}
src/Form/BulkOustType.php
New file
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
class BulkOustType extends AbstractType {
    public function buildForm(FormBuilderInterface $builder, array $options): void {
        $confirm = $options['confirm'];
        $builder
            ->add('notSeenSince', DateType::class, [
                'label' => 'bulk_oust_not_seen_since_header',
                'widget' => 'single_text',
                'input' => 'datetime_immutable',
                'constraints' => [new NotNull()],
            ])
            ->add('reason', TextType::class, [
                'label' => 'ousted_reason_header',
                'constraints' => [new NotBlank(), new Length(max: 255)],
            ]);
        if ($confirm) {
            $builder
                ->add('selection', HiddenType::class, ['constraints' => [new NotBlank()]])
                ->add('save', SubmitType::class, ['label' => 'bulk_oust_action_oust', 'attr' => ['class' => 'btn-error']]);
        } else {
            $builder->add('preview', SubmitType::class, ['label' => 'bulk_oust_action_preview']);
        }
    }
    public function configureOptions(OptionsResolver $resolver): void {
        $resolver->setDefaults(['confirm' => false]);
        $resolver->setAllowedTypes('confirm', 'bool');
        $resolver->setNormalizer('method', static fn($options): string => $options['confirm'] ? 'POST' : 'GET');
        $resolver->setNormalizer('csrf_protection', static fn($options, $value): bool => $options['confirm'] && $value);
    }
}
src/Form/CoverType.php
New file
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Service\CoverStorage;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Validator\Constraints\Image;
use Symfony\Component\Validator\Constraints\NotNull;
/**
 * @extends AbstractType<array{cover: UploadedFile}>
 */
class CoverType extends AbstractType {
    private const int MAX_PIXELS = 20_000_000;
    public function buildForm(FormBuilderInterface $builder, array $options): void {
        $builder
            ->add('cover', FileType::class, [
                'label' => 'cover_file_header',
                'attr' => ['accept' => implode(',', array_keys(CoverStorage::EXTENSIONS))],
                'constraints' => [
                    new NotNull(),
                    new Image(
                        maxSize: '10M',
                        mimeTypes: array_keys(CoverStorage::EXTENSIONS),
                        maxPixels: self::MAX_PIXELS,
                        detectCorrupted: true,
                    ),
                ],
            ])
            ->add('save', SubmitType::class, ['label' => 'cover_action_upload']);
    }
}
src/Form/OustType.php
New file
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
 * @extends AbstractType<array{reason: string}>
 */
class OustType extends AbstractType {
    public function buildForm(FormBuilderInterface $builder, array $options): void {
        $builder
            ->add('reason', TextType::class, [
                'label' => 'ousted_reason_header',
                'constraints' => [new NotBlank(), new Length(max: 255)],
            ])
            ->add('save', SubmitType::class, ['label' => 'ousted_action_oust']);
    }
}
src/Kernel.php
New file
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel {
    use MicroKernelTrait;
}
src/LegacyImport/CoverImportReport.php
New file
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\LegacyImport;
class CoverImportReport {
    /** Books that got a photo of the copy itself, found by the book ID. */
    public int $ownById = 0;
    /** Books that got a photo of the copy itself, found by ISBN. */
    public int $ownByIsbn = 0;
    /** Books that got the publisher's cover, found by ISBN. */
    public int $byIsbn = 0;
    /** Books that already had a cover, which was kept. */
    public int $alreadyCovered = 0;
    /** @var list<string> Files whose name does not match a book. */
    public array $unmatched = [];
    /** @var list<string> Files that are neither an ISBN-13 nor a book ID. */
    public array $ignored = [];
    /** @var list<string> Files that could not be stored as covers, with the reason. */
    public array $failed = [];
    public function imported(): int {
        return $this->ownById + $this->ownByIsbn + $this->byIsbn;
    }
}
src/LegacyImport/CoverImporter.php
New file
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\LegacyImport;
use App\Entity\AbstractBook;
use App\Entity\AbstractLabel;
use App\Entity\Book;
use App\Entity\OustedBook;
use App\Enum\LabelType;
use App\Service\CoverProcessingException;
use App\Service\CoverStorage;
use Doctrine\ORM\EntityManagerInterface;
class CoverImporter {
    private const string OWN_DIR = 'own';
    private const string ISBN_PATTERN = '/^\d{13}$/';
    private const string ID_PATTERN = '/^\d{8}$/';
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly CoverStorage           $covers,
    ) {
    }
    /**
     * @throws \InvalidArgumentException when the directory cannot be read
     */
    public function import(string $directory, bool $dryRun = false): CoverImportReport {
        $directory = rtrim($directory, '/');
        if (!is_dir($directory) || !is_readable($directory)) {
            throw new \InvalidArgumentException(\sprintf('"%s" is not a readable directory.', $directory));
        }
        $report = new CoverImportReport();
        $byIsbn = $this->listFiles($directory, self::ISBN_PATTERN, null, $report);
        $ownByIsbn = [];
        $ownById = [];
        if (is_dir($directory . '/' . self::OWN_DIR)) {
            $ownByIsbn = $this->listFiles($directory . '/' . self::OWN_DIR, self::ISBN_PATTERN, self::ID_PATTERN, $report, $ownById);
        }
        $report->ignored = array_map(static fn(string $path): string => self::relative($directory, $path), $report->ignored);
        $used = [];
        $failed = [];
        $stored = [];
        $connection = $this->entityManager->getConnection();
        $connection->beginTransaction();
        try {
            foreach ($this->books() as $book) {
                $candidates = self::candidates($book, $ownById, $ownByIsbn, $byIsbn);
                foreach ($candidates as $path) {
                    $used[$path] = true;
                }
                if ($candidates === []) {
                    continue;
                }
                if ($book->getCover() !== null) {
                    ++$report->alreadyCovered;
                    continue;
                }
                // A file that is not an image gives way to the next one in order of preference.
                foreach ($candidates as $source => $path) {
                    if (isset($failed[$path])) {
                        continue;
                    }
                    try {
                        $name = $this->covers->store((string)file_get_contents($path));
                    } catch (CoverProcessingException $exception) {
                        $failed[$path] = true;
                        $report->failed[] = \sprintf('%s: %s', self::relative($directory, $path), $exception->getMessage());
                        continue;
                    }
                    $stored[] = $name;
                    $book->setCover($name);
                    ++$report->{$source};
                    break;
                }
            }
            $this->entityManager->flush();
            $dryRun ? $connection->rollBack() : $connection->commit();
        } catch (\Throwable $exception) {
            if ($connection->isTransactionActive()) {
                $connection->rollBack();
            }
            $this->deleteAll($stored);
            throw $exception;
        }
        if ($dryRun) {
            $this->deleteAll($stored);
        }
        $this->entityManager->clear();
        foreach ([...$byIsbn, ...$ownByIsbn, ...$ownById] as $path) {
            if (!isset($used[$path])) {
                $report->unmatched[] = self::relative($directory, $path);
            }
        }
        sort($report->unmatched);
        return $report;
    }
    /**
     * @return iterable<AbstractBook>
     */
    private function books(): iterable {
        yield from $this->entityManager->getRepository(Book::class)->findBy([], ['id' => 'ASC']);
        yield from $this->entityManager->getRepository(OustedBook::class)->findBy([], ['id' => 'ASC']);
    }
    /**
     * @param array<int, string> $byId receives the files named by book ID, when $idPattern is given
     *
     * @return array<string, string> paths of the files named by ISBN, by ISBN
     */
    private function listFiles(string $directory, string $isbnPattern, ?string $idPattern, CoverImportReport $report, array &$byId = []): array {
        $byIsbn = [];
        $names = scandir($directory);
        if ($names === false) {
            throw new \InvalidArgumentException(\sprintf('"%s" could not be listed.', $directory));
        }
        sort($names, \SORT_STRING);
        foreach ($names as $name) {
            $path = $directory . '/' . $name;
            // Empty files are placeholders for missing covers, not images.
            if (str_starts_with($name, '.') || is_dir($path) || filesize($path) === 0) {
                continue;
            }
            if (preg_match($isbnPattern, $name) === 1) {
                $byIsbn[$name] = $path;
            } elseif ($idPattern !== null && preg_match($idPattern, $name) === 1) {
                $byId[(int)$name] = $path;
            } else {
                $report->ignored[] = $path;
            }
        }
        return $byIsbn;
    }
    /**
     * @param array<int, string> $ownById
     * @param array<string, string> $ownByIsbn
     * @param array<string, string> $byIsbn
     *
     * @return array<'ownById'|'ownByIsbn'|'byIsbn', string> matching files in order of preference,
     *                                                       by the report counter each adds to
     */
    private static function candidates(AbstractBook $book, array $ownById, array $ownByIsbn, array $byIsbn): array {
        $isbns = self::isbns($book);
        return array_filter([
            'ownById' => $ownById[(int)$book->getId()] ?? null,
            'ownByIsbn' => self::firstMatch($ownByIsbn, $isbns),
            'byIsbn' => self::firstMatch($byIsbn, $isbns),
        ], static fn(?string $path): bool => $path !== null);
    }
    /**
     * @return list<string>
     */
    private static function isbns(AbstractBook $book): array {
        $isbns = [];
        /** @var AbstractLabel $label */
        foreach ($book->getLabels() as $label) {
            if ($label->getType() === LabelType::ISBN) {
                $isbns[] = $label->getLabel();
            }
        }
        return $isbns;
    }
    /**
     * @param array<string, string> $files
     * @param list<string> $isbns
     */
    private static function firstMatch(array $files, array $isbns): ?string {
        foreach ($isbns as $isbn) {
            if (isset($files[$isbn])) {
                return $files[$isbn];
            }
        }
        return null;
    }
    /**
     * @param list<string> $names
     */
    private function deleteAll(array $names): void {
        foreach ($names as $name) {
            $this->covers->delete($name);
        }
    }
    private static function relative(string $directory, string $path): string {
        return substr($path, \strlen($directory) + 1);
    }
}
src/LegacyImport/ImportReport.php
New file
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\LegacyImport;
class ImportReport {
    public int $books = 0;
    public int $oustedBooks = 0;
    public int $borrows = 0;
    public int $borrowHistory = 0;
    public int $oustedBorrowHistory = 0;
    /** Legacy books whose ID is already taken in the target; they and their loans were left as they are. */
    public int $alreadyPresent = 0;
    /** @var list<string> Rows that have no place in the new schema and were left out. */
    public array $skipped = [];
    /** @var list<string> Numbers that are not valid ISBNs, kept as text labels instead. */
    public array $invalidIsbns = [];
}
src/LegacyImport/LegacyImporter.php
New file
@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace App\LegacyImport;
use App\Entity\AbstractBook;
use App\Entity\Book;
use App\Entity\OustedBook;
use App\Enum\LabelType;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
/**
 * Copies the old "ksiazki", "pozycz" and "pozycz_historia" tables into the current schema.
 *
 * The source is never written to. The import is one transaction, so a failure leaves the
 * target untouched.
 *
 * The import can be repeated: a legacy book whose ID is already taken in the target, as a book or
 * as an ousted book, is left as it is along with its loans, so edits made since are kept and only
 * new books are added.
 */
class LegacyImporter
{
    private const int ISBN10_LENGTH = 10;
    private const int ISSN_LENGTH = 8;
    public function __construct(
        private readonly Connection $legacyConnection,
        private readonly EntityManagerInterface $entityManager,
    ) {
    }
    public function import(bool $dryRun = false): ImportReport
    {
        $existingIds = $this->existingIds();
        // Everything is read up front: the source and the target may be the same SQLite file,
        // which could not be written to during an open read.
        $bookRows = $this->fetchRows('ksiazki', ['id']);
        $borrowRows = $this->fetchRows('pozycz', ['id', 'od']);
        $historyRows = $this->fetchRows('pozycz_historia', ['id', 'od']);
        $connection = $this->entityManager->getConnection();
        $connection->beginTransaction();
        try {
            $report = new ImportReport();
            [$books, $oustedBooks] = $this->importBooks($bookRows, $existingIds, $report);
            $this->importBorrows($borrowRows, $books, $oustedBooks, $existingIds, $report);
            $this->importHistory($historyRows, $books, $oustedBooks, $existingIds, $report);
            $this->entityManager->flush();
            $dryRun ? $connection->rollBack() : $connection->commit();
        } catch (\Throwable $exception) {
            if ($connection->isTransactionActive()) {
                $connection->rollBack();
            }
            throw $exception;
        }
        $this->entityManager->clear();
        return $report;
    }
    /**
     * @return array<int, true> the IDs of the books and ousted books already in the target
     */
    private function existingIds(): array
    {
        $ids = [];
        foreach ([Book::class, OustedBook::class] as $entity) {
            $rows = $this->entityManager->createQueryBuilder()
                ->select('b.id')
                ->from($entity, 'b')
                ->getQuery()
                ->getSingleColumnResult();
            foreach ($rows as $id) {
                $ids[(int) $id] = true;
            }
        }
        return $ids;
    }
    /**
     * @param list<string> $orderBy
     *
     * @return list<array<string, mixed>> rows with lower-case keys, whatever case the source reports
     */
    private function fetchRows(string $table, array $orderBy): array
    {
        $rows = $this->legacyConnection->createQueryBuilder()
            ->select('*')
            ->from($this->legacyConnection->quoteSingleIdentifier($table))
            ->orderBy(implode(', ', $orderBy))
            ->executeQuery()
            ->fetchAllAssociative();
        return array_map(static fn (array $row): array => array_change_key_case($row, \CASE_LOWER), $rows);
    }
    /**
     * @param list<array<string, mixed>> $rows
     * @param array<int, true>           $existingIds
     *
     * @return array{array<int, Book>, array<int, OustedBook>}
     */
    private function importBooks(array $rows, array $existingIds, ImportReport $report): array
    {
        $books = [];
        $oustedBooks = [];
        foreach ($rows as $row) {
            $id = (int) $row['id'];
            if (isset($existingIds[$id])) {
                ++$report->alreadyPresent;
                continue;
            }
            if ((string) $row['wycofana'] === '1') {
                $book = new OustedBook((string) $row['powod']);
                $oustedBooks[$id] = $book;
                ++$report->oustedBooks;
            } else {
                $book = new Book();
                $books[$id] = $book;
                ++$report->books;
            }
            $this->fillBook($book, $row, $report);
            $this->entityManager->persist($book);
        }
        return [$books, $oustedBooks];
    }
    /**
     * @param array<string, mixed> $row
     */
    private function fillBook(AbstractBook $book, array $row, ImportReport $report): void
    {
        $year = (int) $row['rok'];
        $book
            ->setId((int) $row['id'])
            ->setTitle((string) $row['tytul'])
            ->setAuthor((string) $row['autor'])
            ->setLanguage(self::text($row['jezyk']))
            ->setEdition(self::text($row['wydanie']))
            ->setPublisher(self::text($row['wydawnictwo']))
            ->setPublicationLoc(self::text($row['miejsce']))
            ->setPublicationYear($year === 0 ? null : $year)
            ->setIssn(self::standardNumber($row['issn'], self::ISSN_LENGTH))
            ->setPlacement(self::placement($row['regal'], $row['polka'], $row['rzad']));
        $isbn = self::standardNumber($row['isbn'], self::ISBN10_LENGTH);
        if ($isbn === null) {
            return;
        }
        if (LabelType::ISBN->isValid(LabelType::ISBN->normalize($isbn))) {
            $book->setIsbn($isbn);
        } else {
            $book->addLabel($isbn);
            $report->invalidIsbns[] = \sprintf('book %d: %s', $row['id'], $isbn);
        }
    }
    /**
     * @param list<array<string, mixed>> $rows
     * @param array<int, Book>           $books
     * @param array<int, OustedBook>     $oustedBooks
     * @param array<int, true>           $existingIds books kept as they are, whose loans are not imported
     */
    private function importBorrows(array $rows, array $books, array $oustedBooks, array $existingIds, ImportReport $report): void
    {
        // The old key was (id, od), so a book could be lent twice at once; only one loan fits
        // now, and the rows are sorted, so reversing keeps the most recent.
        foreach (array_reverse($rows) as $row) {
            $id = (int) $row['id'];
            if (isset($existingIds[$id])) {
                continue;
            }
            $book = $books[$id] ?? null;
            if ($book === null) {
                $report->skipped[] = isset($oustedBooks[$id])
                    ? \sprintf('pozycz: book %d is on loan but ousted', $id)
                    : \sprintf('pozycz: book %d does not exist', $id);
                continue;
            }
            if ($book->isBorrowed()) {
                $report->skipped[] = \sprintf('pozycz: book %d has more than one open loan, kept the latest', $id);
                continue;
            }
            $book->lendTo((string) $row['kto'], self::dateTime($row['od']));
            ++$report->borrows;
        }
    }
    /**
     * @param list<array<string, mixed>> $rows
     * @param array<int, Book>           $books
     * @param array<int, OustedBook>     $oustedBooks
     * @param array<int, true>           $existingIds books kept as they are, whose loans are not imported
     */
    private function importHistory(array $rows, array $books, array $oustedBooks, array $existingIds, ImportReport $report): void
    {
        foreach ($rows as $row) {
            $id = (int) $row['id'];
            if (isset($existingIds[$id])) {
                continue;
            }
            $book = $books[$id] ?? $oustedBooks[$id] ?? null;
            if ($book === null) {
                $report->skipped[] = \sprintf('pozycz_historia: book %d does not exist', $id);
                continue;
            }
            if ($row['do'] === null) {
                $report->skipped[] = \sprintf('pozycz_historia: loan of book %d from %s has no end date', $id, self::dateTime($row['od'])->format('Y-m-d'));
                continue;
            }
            $book->addBorrowHistory((string) $row['kto'], self::dateTime($row['od']), self::dateTime($row['do']));
            $book instanceof Book ? ++$report->borrowHistory : ++$report->oustedBorrowHistory;
        }
    }
    private static function text(mixed $value): ?string
    {
        return $value === null || (string) $value === '' ? null : (string) $value;
    }
    /**
     * Numbers were stored as DECIMAL, which dropped leading zeros.
     */
    private static function standardNumber(mixed $value, int $shortLength): ?string
    {
        $number = (string) $value;
        return $number === '' || $number === '0' ? null : str_pad($number, $shortLength, '0', \STR_PAD_LEFT);
    }
    /**
     * Joins bookcase, shelf and row as "HOL/4/1", without trailing separators for missing parts.
     */
    private static function placement(mixed $bookcase, mixed $shelf, mixed $row): ?string
    {
        if ($bookcase === null) {
            return null;
        }
        return rtrim(implode('/', [(string) $bookcase, (string) $shelf, (string) $row]), '/');
    }
    private static function dateTime(mixed $timestamp): \DateTimeImmutable
    {
        // A "@" date is in UTC, but Doctrine stores the wall clock of the object it is given.
        return new \DateTimeImmutable('@'.(int) $timestamp)
            ->setTimezone(new \DateTimeZone(date_default_timezone_get()));
    }
}
src/Lookup/BookCandidate.php
New file
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Lookup;
readonly class BookCandidate {
    public function __construct(
        public string  $title,
        public ?string $author = null,
        public ?string $language = null,
        public ?string $publisher = null,
        public ?string $publicationLoc = null,
        public ?int    $publicationYear = null,
        public ?string $isbn = null,
        public ?string $issn = null,
    ) {
    }
}
src/Lookup/BookLookupInterface.php
New file
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Lookup;
interface BookLookupInterface {
    /**
     * @return BookCandidate[]
     * @throws LookupFailedException
     */
    public function findByStandardNumber(string $number): array;
    /**
     * @return BookCandidate[]
     * @throws LookupFailedException
     */
    public function findByDescription(string $title, string $author, string $publisher): array;
}
src/Lookup/LookupFailedException.php
New file
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Lookup;
final class LookupFailedException extends \RuntimeException {
}
src/Lookup/NationalLibraryLookup.php
New file
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Lookup;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
 * Looks books up in the Polish National Library catalogue (https://data.bn.org.pl).
 */
class NationalLibraryLookup implements BookLookupInterface {
    private const int LIMIT = 10;
    public function __construct(private readonly HttpClientInterface $bnClient) {
    }
    public function findByStandardNumber(string $number): array {
        return $this->fetch(['isbnIssn' => str_replace(['-', ' '], '', $number)]);
    }
    public function findByDescription(string $title, string $author, string $publisher): array {
        return $this->fetch(array_filter(
            ['title' => $title, 'author' => $author, 'publisher' => $publisher],
            static fn(string $value): bool => $value !== '',
        ));
    }
    /**
     * @param array<string, string> $query
     *
     * @return list<BookCandidate>
     */
    private function fetch(array $query): array {
        try {
            $response = $this->bnClient->request('GET', '/api/networks/bibs.json', [
                'query' => $query + ['limit' => self::LIMIT, 'kind' => 'książka'],
            ]);
            $records = $response->toArray()['bibs'] ?? [];
        } catch (ExceptionInterface $exception) {
            throw new LookupFailedException('The catalogue request failed.', previous: $exception);
        }
        return array_map(self::toCandidate(...), $records);
    }
    /**
     * @param array<string, mixed> $record
     */
    private static function toCandidate(array $record): BookCandidate {
        $number = preg_replace('/[^\dX]/', '', strtoupper((string)($record['isbnIssn'] ?? ''))) ?: null;
        $isIssn = $number !== null && \strlen($number) === 8;
        $year = (string)($record['publicationYear'] ?? '');
        return new BookCandidate(
            title: trim((string)($record['title'] ?? ''), " \t\n\r/:;,."),
            author: self::firstPerson((string)($record['author'] ?? '')),
            language: self::blankToNull((string)($record['language'] ?? '')),
            publisher: self::singlePublisher((string)($record['publisher'] ?? '')),
            publicationLoc: self::blankToNull(trim(explode(':', (string)($record['placeOfPublication'] ?? ''))[0])),
            publicationYear: preg_match('/\d{4}/', $year, $match) === 1 ? (int)$match[0] : null,
            isbn: $isIssn ? null : $number,
            issn: $isIssn ? $number : null,
        );
    }
    /**
     * "MacLean, Alistair (1922-1987) Ginalski, Robert ..." becomes "Alistair MacLean".
     */
    private static function firstPerson(string $authors): ?string {
        $first = preg_split('/\s*\(\d{4}/', $authors, 2)[0];
        $parts = array_map(trim(...), explode(',', rtrim($first, " .\t\n\r")));
        return self::blankToNull(\count($parts) === 2 ? $parts[1] . ' ' . $parts[0] : implode(', ', $parts));
    }
    private static function singlePublisher(string $publisher): ?string {
        $publisher = trim($publisher, " \t\n\r\",;:.");
        return self::blankToNull(preg_replace('/^(.+)\s+\1$/u', '$1', $publisher) ?? $publisher);
    }
    private static function blankToNull(string $value): ?string {
        return trim($value) === '' ? null : trim($value);
    }
}
src/Model/BookSearch.php
New file
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Model;
class BookSearch {
    public ?string $author = null;
    public ?string $title = null;
    public ?string $edition = null;
    public ?string $publisher = null;
    public function isEmpty(): bool {
        return trim(implode('', [$this->author, $this->title, $this->edition, $this->publisher])) === '';
    }
}
src/Model/Isbn.php
New file
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Model;
class Isbn {
    /**
     * Returns the ISBN-13 form of a valid ISBN-10 or ISBN-13, or null if the value is neither.
     */
    public static function toIsbn13(string $value): ?string {
        $digits = strtoupper(str_replace(['-', ' '], '', $value));
        if (preg_match('/^\d{9}[\dX]$/', $digits) === 1) {
            if (!self::isValidIsbn10($digits)) {
                return null;
            }
            $stem = '978' . substr($digits, 0, 9);
            return $stem . self::isbn13CheckDigit($stem);
        }
        if (preg_match('/^97[89]\d{10}$/', $digits) === 1
            && $digits[12] === self::isbn13CheckDigit(substr($digits, 0, 12))) {
            return $digits;
        }
        return null;
    }
    private static function isValidIsbn10(string $digits): bool {
        $sum = 0;
        foreach (str_split($digits) as $position => $digit) {
            $sum += (10 - $position) * ($digit === 'X' ? 10 : (int)$digit);
        }
        return $sum % 11 === 0;
    }
    private static function isbn13CheckDigit(string $stem): string {
        $sum = 0;
        foreach (str_split($stem) as $position => $digit) {
            $sum += (int)$digit * ($position % 2 === 0 ? 1 : 3);
        }
        return (string)((10 - $sum % 10) % 10);
    }
}
src/Repository/BookRepository.php
New file
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Book;
use App\Enum\LabelType;
use App\Model\BookSearch;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
 * @extends ServiceEntityRepository<Book>
 */
class BookRepository extends ServiceEntityRepository {
    public function __construct(ManagerRegistry $registry) {
        parent::__construct($registry, Book::class);
    }
    public function createListQueryBuilder(): QueryBuilder {
        // Book::$borrow is the inverse side of a one-to-one, so Doctrine would otherwise load it
        // row by row. Labels are batch-loaded through their EAGER mapping.
        return $this->createQueryBuilder('b')
            ->addSelect('br')
            ->leftJoin('b.borrow', 'br')
            ->orderBy('b.id', 'ASC');
    }
    public function createSearchQueryBuilder(BookSearch $search): QueryBuilder {
        $builder = $this->createListQueryBuilder();
        $fields = [
            'title' => $search->title,
            'author' => $search->author,
            'edition' => $search->edition,
            'publisher' => $search->publisher,
        ];
        foreach ($fields as $field => $value) {
            if (trim((string)$value) !== '') {
                $builder
                    ->andWhere(\sprintf("b.%s LIKE :%s ESCAPE '!'", $field, $field))
                    ->setParameter($field, self::containsPattern($value));
            }
        }
        return $builder;
    }
    /**
     * Quick search: an ID or ISBN/ISSN matches exactly, anything else word by word.
     */
    public function createQuickSearchQueryBuilder(string $query): QueryBuilder {
        $builder = $this->createListQueryBuilder();
        $words = preg_split('/\s+/u', trim($query), flags: \PREG_SPLIT_NO_EMPTY) ?: [];
        foreach ($words as $index => $word) {
            $conditions = [
                \sprintf("b.title LIKE :word%d ESCAPE '!'", $index),
                \sprintf("b.author LIKE :word%d ESCAPE '!'", $index),
                \sprintf("b.publisher LIKE :word%d ESCAPE '!'", $index),
            ];
            $builder->setParameter('word' . $index, self::containsPattern($word));
            if (\count($words) === 1) {
                $conditions[] = 'EXISTS (SELECT 1 FROM App\Entity\Label n WHERE n.book = b AND n.type <> :textType AND n.label = :number)';
                $builder
                    ->setParameter('textType', LabelType::TEXT->value)
                    ->setParameter('number', LabelType::ISBN->normalize($word));
                if (ctype_digit($word) && \strlen($word) <= 9) {
                    $conditions[] = 'b.id = :id';
                    $builder->setParameter('id', (int)$word);
                }
            }
            $builder->andWhere($builder->expr()->orX(...$conditions));
        }
        return $builder;
    }
    /**
     * @param list<int> $ids
     * @return list<Book>
     */
    public function findForInventorySync(array $ids): array {
        return $this->createListQueryBuilder()
            ->andWhere('b.id IN (:ids)')
            ->setParameter('ids', $ids)
            ->getQuery()
            ->getResult();
    }
    /**
     * Books on the shelves (not lent out) that were not seen during an inventory on or after
     * $since, including those never seen at all.
     */
    public function createNotSeenSinceQueryBuilder(\DateTimeImmutable $since): QueryBuilder {
        return $this->createListQueryBuilder()
            ->andWhere('b.lastSeen IS NULL OR b.lastSeen < :since')
            ->andWhere('br.book IS NULL')
            ->setParameter('since', $since, Types::DATETIME_IMMUTABLE);
    }
    public function countNotSeenSince(\DateTimeImmutable $since, bool $borrowed = false): int {
        return (int)$this->createQueryBuilder('b')
            ->select('COUNT(b.id)')
            ->leftJoin('b.borrow', 'br')
            ->andWhere('b.lastSeen IS NULL OR b.lastSeen < :since')
            ->andWhere($borrowed ? 'br.book IS NOT NULL' : 'br.book IS NULL')
            ->setParameter('since', $since, Types::DATETIME_IMMUTABLE)
            ->getQuery()
            ->getSingleScalarResult();
    }
    /**
     * A short fingerprint of exactly which books createNotSeenSinceQueryBuilder() selects.
     */
    public function notSeenSinceFingerprint(\DateTimeImmutable $since): string {
        $ids = $this->createNotSeenSinceQueryBuilder($since)
            ->select('b.id')
            ->getQuery()
            ->getSingleColumnResult();
        return hash('sha256', implode(',', $ids));
    }
    /**
     * Everything the offline inventory page needs, as plain arrays: hydrating thousands of books
     * with their labels would be far slower.
     *
     * @return list<array{id: int, title: string, author: string, placement: ?string, lastSeen: ?string, borrowed: bool, isbn: list<string>, issn: list<string>}>
     */
    public function findInventoryData(): array {
        $entityManager = $this->getEntityManager();
        $numbers = [];
        $labels = $entityManager->createQuery('SELECT IDENTITY(l.book) AS book, l.type, l.label FROM App\Entity\Label l WHERE l.type IN (:types) ORDER BY l.label')
            ->setParameter('types', [LabelType::ISBN->value, LabelType::ISSN->value])
            ->getArrayResult();
        foreach ($labels as $label) {
            $numbers[(int)$label['book']][$label['type']->value][] = $label['label'];
        }
        $rows = $this->createQueryBuilder('b')
            ->select('b.id, b.title, b.author, b.placement, b.lastSeen, IDENTITY(br.book) AS borrowedBook')
            ->leftJoin('b.borrow', 'br')
            ->orderBy('b.id', 'ASC')
            ->getQuery()
            ->getArrayResult();
        return array_map(static fn(array $row): array => [
            'id' => $row['id'],
            'title' => $row['title'],
            'author' => $row['author'],
            'placement' => $row['placement'],
            'lastSeen' => $row['lastSeen']?->format(\DateTimeInterface::ATOM),
            'borrowed' => $row['borrowedBook'] !== null,
            'isbn' => $numbers[$row['id']][LabelType::ISBN->value] ?? [],
            'issn' => $numbers[$row['id']][LabelType::ISSN->value] ?? [],
        ], $rows);
    }
    private static function containsPattern(string $value): string {
        return '%' . strtr(trim($value), ['!' => '!!', '%' => '!%', '_' => '!_']) . '%';
    }
}
src/Repository/BorrowHistoryRepository.php
New file
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\BorrowHistory;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
 * @extends ServiceEntityRepository<BorrowHistory>
 */
class BorrowHistoryRepository extends ServiceEntityRepository {
    public function __construct(ManagerRegistry $registry) {
        parent::__construct($registry, BorrowHistory::class);
    }
}
src/Repository/BorrowRepository.php
New file
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Borrow;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
 * @extends ServiceEntityRepository<Borrow>
 */
class BorrowRepository extends ServiceEntityRepository {
    public function __construct(ManagerRegistry $registry) {
        parent::__construct($registry, Borrow::class);
    }
    public function createListQueryBuilder(): QueryBuilder {
        return $this->createQueryBuilder('br')
            ->addSelect('b')
            ->join('br.book', 'b')
            ->orderBy('br.at', 'ASC');
    }
}
src/Repository/LabelRepository.php
New file
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Label;
use App\Enum\LabelType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
 * @extends ServiceEntityRepository<Label>
 */
class LabelRepository extends ServiceEntityRepository {
    public function __construct(ManagerRegistry $registry) {
        parent::__construct($registry, Label::class);
    }
    /**
     * @return list<array{label: string, books: int}>
     */
    public function findUsage(): array {
        /** @var list<array{label: string, books: int}> */
        return $this->createQueryBuilder('l')
            ->select('l.label AS label', 'COUNT(l.id) AS books')
            ->where('l.type = :type')
            ->setParameter('type', LabelType::TEXT->value)
            ->groupBy('l.label')
            ->orderBy('l.label', 'ASC')
            ->getQuery()
            ->getResult();
    }
}
src/Repository/OustedBookRepository.php
New file
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\OustedBook;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
 * @extends ServiceEntityRepository<OustedBook>
 */
class OustedBookRepository extends ServiceEntityRepository {
    public function __construct(ManagerRegistry $registry) {
        parent::__construct($registry, OustedBook::class);
    }
    public function createListQueryBuilder(): QueryBuilder {
        return $this->createQueryBuilder('b')->orderBy('b.id', 'ASC');
    }
}
src/Repository/OustedBorrowHistoryRepository.php
New file
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\OustedBorrowHistory;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
 * @extends ServiceEntityRepository<OustedBorrowHistory>
 */
class OustedBorrowHistoryRepository extends ServiceEntityRepository {
    public function __construct(ManagerRegistry $registry) {
        parent::__construct($registry, OustedBorrowHistory::class);
    }
}
src/Repository/OustedLabelRepository.php
New file
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\OustedLabel;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
 * @extends ServiceEntityRepository<OustedLabel>
 */
class OustedLabelRepository extends ServiceEntityRepository {
    public function __construct(ManagerRegistry $registry) {
        parent::__construct($registry, OustedLabel::class);
    }
}
src/Service/CoverProcessingException.php
New file
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Service;
final class CoverProcessingException extends \RuntimeException {
}
src/Service/CoverStorage.php
New file
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
class CoverStorage {
    public const array EXTENSIONS = [
        'image/jpeg' => 'jpg',
        'image/png' => 'png',
        'image/webp' => 'webp',
    ];
    public function __construct(
        #[Autowire('%app.cover_originals_dir%')]
        private readonly string           $originalsDir,
        #[Autowire('%app.cover_thumbnails_dir%')]
        private readonly string           $thumbnailsDir,
        private readonly CoverThumbnailer $thumbnailer,
        private readonly Filesystem       $filesystem,
    ) {
    }
    /**
     * @param string $contents the image, kept byte for byte as the original
     *
     * @return string the name of the stored cover
     *
     * @throws CoverProcessingException
     */
    public function store(string $contents): string {
        // The extension comes from the detected content, never from the client's file name.
        $extension = self::EXTENSIONS[@getimagesizefromstring($contents)['mime'] ?? ''] ?? null;
        if ($extension === null) {
            throw new CoverProcessingException('Unsupported image type.');
        }
        $name = bin2hex(random_bytes(16)) . '.' . $extension;
        $this->filesystem->mkdir([$this->originalsDir, $this->thumbnailsDir]);
        try {
            $this->thumbnailer->createThumbnails(
                $contents,
                fn(string $size): string => $this->thumbnailPath($name, $size),
            );
            $this->filesystem->dumpFile($this->originalPath($name), $contents);
        } catch (\Throwable $exception) {
            $this->delete($name);
            throw $exception;
        }
        return $name;
    }
    public function delete(string $name): void {
        $this->filesystem->remove([
            $this->originalPath($name),
            ...array_map(
                fn(string $size): string => $this->thumbnailPath($name, $size),
                array_keys(CoverThumbnailer::SIZES),
            ),
        ]);
    }
    public function originalPath(string $name): string {
        return $this->originalsDir . '/' . $name;
    }
    public function thumbnailPath(string $name, string $size): string {
        return $this->thumbnailsDir . '/' . $name . '.' . $size . '.jpg';
    }
}
src/Service/CoverThumbnailer.php
New file
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
 * Scales covers down to JPEG thumbnails. Smaller images are never enlarged.
 */
class CoverThumbnailer {
    private const int JPEG_QUALITY = 85;
    /**
     * Sizes are twice the display size, for high-density screens.
     *
     * @var array<string, array{int, int}> maximum width and height by size name
     */
    public const array SIZES = [
        'list' => [150, 225],
        'details' => [600, 900],
    ];
    /**
     * @param string $data the image file's contents
     * @param callable(string): string $targetPath gives the file for a size name
     *
     * @throws CoverProcessingException
     */
    public function createThumbnails(string $data, callable $targetPath): void {
        $image = $data === '' ? false : @imagecreatefromstring($data);
        if ($image === false) {
            throw new CoverProcessingException('The file could not be read as an image.');
        }
        $image = self::applyExifOrientation($image, $data);
        foreach (self::SIZES as $name => [$maxWidth, $maxHeight]) {
            if (!imagejpeg(self::scale($image, $maxWidth, $maxHeight), $targetPath($name), self::JPEG_QUALITY)) {
                throw new CoverProcessingException('The thumbnail could not be written.');
            }
        }
    }
    private static function scale(\GdImage $image, int $maxWidth, int $maxHeight): \GdImage {
        $width = imagesx($image);
        $height = imagesy($image);
        $ratio = min(1, $maxWidth / $width, $maxHeight / $height);
        $targetWidth = max(1, (int)round($width * $ratio));
        $targetHeight = max(1, (int)round($height * $ratio));
        // JPEG has no transparency, so transparent PNG and WebP covers go on a white background.
        $thumbnail = imagecreatetruecolor($targetWidth, $targetHeight);
        imagefill($thumbnail, 0, 0, (int)imagecolorallocate($thumbnail, 255, 255, 255));
        imagecopyresampled($thumbnail, $image, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
        return $thumbnail;
    }
    /**
     * Phones store the rotation of a photo as metadata, which thumbnails would otherwise lose.
     */
    private static function applyExifOrientation(\GdImage $image, string $data): \GdImage {
        if (\IMAGETYPE_JPEG !== (@getimagesizefromstring($data)[2] ?? null)) {
            return $image;
        }
        $orientation = @exif_read_data('data://image/jpeg;base64,' . base64_encode($data))['Orientation'] ?? 1;
        if (\in_array($orientation, [2, 4, 5, 7], true)) {
            imageflip($image, \IMG_FLIP_HORIZONTAL);
        }
        $angle = match ($orientation) {
            3, 4 => 180,
            5, 8 => 90,
            6, 7 => 270,
            default => 0,
        };
        return $angle === 0 ? $image : imagerotate($image, $angle, 0);
    }
}
src/Service/Library.php
New file
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Book;
use App\Entity\OustedBook;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
 * Operations that change where a book is: lent out, back on the shelf, out of the collection.
 */
class Library {
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly CoverStorage           $covers,
        private readonly LibraryThingCovers     $libraryThing,
    ) {
    }
    /**
     * Gives a book without a cover the one LibraryThing has for its ISBN, if any.
     *
     * @return bool whether a cover was added
     */
    public function downloadCover(Book $book): bool {
        $isbn = $book->getIsbn();
        if ($book->getCover() !== null || $isbn === null || !$this->libraryThing->isEnabled()) {
            return false;
        }
        $data = $this->libraryThing->download($isbn);
        if ($data === null) {
            return false;
        }
        try {
            $book->setCover($this->covers->store($data));
        } catch (CoverProcessingException) {
            // LibraryThing may send a format that cannot be stored, such as GIF.
            return false;
        }
        $this->entityManager->flush();
        return true;
    }
    /**
     * Stores a new cover and drops the previous one; null only removes it.
     *
     * @throws CoverProcessingException
     */
    public function changeCover(Book $book, ?UploadedFile $file): void {
        $previous = $book->getCover();
        $book->setCover($file ? $this->covers->store($file->getContent()) : null);
        $this->entityManager->flush();
        if ($previous !== null) {
            $this->covers->delete($previous);
        }
    }
    public function lend(Book $book, string $who): void {
        $book->lendTo($who, new \DateTimeImmutable());
        $this->entityManager->flush();
    }
    public function giveBack(Book $book): void {
        $book->giveBack(new \DateTimeImmutable());
        $this->entityManager->flush();
    }
    /**
     * Moves a book to the ousted collection with its labels and loan history.
     */
    public function oust(Book $book, string $reason): OustedBook {
        if ($book->isBorrowed()) {
            throw new \LogicException(\sprintf('Book %d must be returned before it is ousted.', $book->getId()));
        }
        $ousted = OustedBook::fromBook($book, $reason);
        foreach ($book->getBorrowHistory() as $entry) {
            $ousted->addBorrowHistory($entry->getWho(), $entry->getAt(), $entry->getTill());
        }
        $this->entityManager->wrapInTransaction(function () use ($book, $ousted): void {
            $this->entityManager->remove($book);
            $this->entityManager->flush();
            $this->entityManager->persist($ousted);
        });
        return $ousted;
    }
    /**
     * Ousts all the given books, or none of them if one fails.
     *
     * @param iterable<Book> $books
     * @return int how many books were ousted
     */
    public function oustAll(iterable $books, string $reason): int {
        return $this->entityManager->wrapInTransaction(function () use ($books, $reason): int {
            $count = 0;
            foreach ($books as $book) {
                $this->oust($book, $reason);
                $count++;
            }
            $this->entityManager->flush();
            return $count;
        });
    }
    public function restore(OustedBook $ousted): Book {
        $book = Book::fromOusted($ousted);
        foreach ($ousted->getBorrowHistory() as $entry) {
            $book->addBorrowHistory($entry->getWho(), $entry->getAt(), $entry->getTill());
        }
        $this->entityManager->wrapInTransaction(function () use ($book, $ousted): void {
            $this->entityManager->remove($ousted);
            $this->entityManager->flush();
            $this->entityManager->persist($book);
        });
        return $book;
    }
}
src/Service/LibraryThingCovers.php
New file
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
 * Downloads covers from LibraryThing (https://www.librarything.com/services/covers.php).
 */
class LibraryThingCovers {
    private const int MAX_BYTES = 10 * 1024 * 1024;
    public function __construct(
        #[Target('librarything.client')]
        private readonly HttpClientInterface $libraryThingClient,
        private readonly LoggerInterface     $logger,
        #[Autowire('%app.librarything_api_key%')]
        private readonly ?string             $apiKey,
    ) {
    }
    public function isEnabled(): bool {
        return trim((string)$this->apiKey) !== '';
    }
    /**
     * @return string|null the image data, or null when there is no cover or it could not be fetched
     */
    public function download(string $isbn): ?string {
        if (!$this->isEnabled() || preg_match('/^[\dX]{10,13}$/', $isbn) !== 1) {
            return null;
        }
        try {
            $response = $this->libraryThingClient->request(
                'GET',
                \sprintf('/devkey/%s/large/isbn/%s', rawurlencode(trim((string)$this->apiKey)), $isbn),
            );
            if ($response->getStatusCode() !== 200) {
                $this->logger->warning('LibraryThing returned HTTP {status} for ISBN {isbn}.', [
                    'status' => $response->getStatusCode(),
                    'isbn' => $isbn,
                ]);
                return null;
            }
            $data = $response->getContent();
        } catch (ExceptionInterface $exception) {
            // The message would contain the URL, and with it the key.
            $this->logger->warning('The LibraryThing cover for ISBN {isbn} could not be downloaded.', ['isbn' => $isbn]);
            return null;
        }
        if (\strlen($data) > self::MAX_BYTES) {
            return null;
        }
        $size = @getimagesizefromstring($data);
        return $size === false || $size[0] <= 1 || $size[1] <= 1 ? null : $data;
    }
}
src/Validator/AvailableBookId.php
New file
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Validator;
use Symfony\Component\Validator\Attribute\HasNamedArguments;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class AvailableBookId extends Constraint {
    public string $message = 'book_id_taken';
    /**
     * @param list<string>|null $groups
     */
    #[HasNamedArguments]
    public function __construct(?array $groups = null, mixed $payload = null) {
        parent::__construct(null, $groups, $payload);
    }
}
src/Validator/AvailableBookIdValidator.php
New file
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Validator;
use App\Repository\BookRepository;
use App\Repository\OustedBookRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class AvailableBookIdValidator extends ConstraintValidator {
    public function __construct(
        private readonly BookRepository       $books,
        private readonly OustedBookRepository $oustedBooks,
    ) {
    }
    public function validate(mixed $value, Constraint $constraint): void {
        if (!$constraint instanceof AvailableBookId) {
            throw new UnexpectedTypeException($constraint, AvailableBookId::class);
        }
        if ($value === null) {
            return;
        }
        if ($this->books->find($value) || $this->oustedBooks->find($value)) {
            $this->context->buildViolation($constraint->message)->setTranslationDomain('messages')->addViolation();
        }
    }
}
sync.php
File was deleted
templates/base.html.twig
New file
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="{{ app.locale }}">
<head>
    <title>{% trans %}general_app_name{% endtrans %}
        - {% block title %}{% trans %}general_app_slogan{% endtrans %}{% endblock %}</title>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <link rel="stylesheet" href="{{ asset('css/spectre/spectre.min.css') }}"/>
    <link rel="stylesheet" href="{{ asset('css/spectre/spectre-icons.min.css') }}"/>
    <link rel="stylesheet" href="{{ asset('css/custom.css') }}"/>
</head>
<body>
{% set route = app.current_route %}
<header class="navbar">
    <section class="navbar-section">
        <a href="{{ path('home') }}" class="navbar-brand mr-2">{% trans %}general_app_name{% endtrans %}</a>
    </section>
    <section class="navbar-section">
        <form action="{{ path('search') }}" method="get" class="input-group input-inline">
            <input class="form-input" type="text" name="q" value="{{ app.request.query.get('q') }}"
                   placeholder="{% trans %}general_search_placeholder{% endtrans %}">
            <button class="btn btn-primary input-group-btn">{% trans %}general_search_button{% endtrans %}</button>
        </form>
    </section>
</header>
<ul class="tab tab-block">
    <li class="tab-item{% if route in ['home', 'book_list', 'book_show', 'book_edit'] %} active{% endif %}">
        <a href="{{ path('book_list') }}">{% trans %}menu_list{% endtrans %}</a>
    </li>
    <li class="tab-item{% if route == 'search' %} active{% endif %}">
        <a href="{{ path('search') }}">{% trans %}menu_search{% endtrans %}</a>
    </li>
    <li class="tab-item{% if route in ['book_add', 'book_new', 'book_lookup'] %} active{% endif %}">
        <a href="{{ path('book_add') }}">{% trans %}menu_add{% endtrans %}</a>
    </li>
    <li class="tab-item{% if route == 'borrow_list' %} active{% endif %}">
        <a href="{{ path('borrow_list') }}">{% trans %}menu_borrowed{% endtrans %}</a>
    </li>
    <li class="tab-item{% if route in ['ousted_list', 'ousted_show', 'ousted_bulk'] %} active{% endif %}">
        <a href="{{ path('ousted_list') }}">{% trans %}menu_ousted{% endtrans %}</a>
    </li>
    <li class="tab-item">
        <a href="{{ path('inventory') }}">{% trans %}menu_inventory{% endtrans %}</a>
    </li>
</ul>
{% for type, messages in app.flashes %}
    {% for message in messages %}
        <div class="toast toast-{{ type }} flash">{{ message|trans }}</div>
    {% endfor %}
{% endfor %}
{% block content %}{% endblock %}
</body>
</html>
templates/book/_cover.html.twig
New file
@@ -0,0 +1,3 @@
<a href="{{ path('cover_original', {name: book.cover}) }}" title="{% trans %}cover_show_original{% endtrans %}">
    <img class="book-cover" src="{{ asset('covers/' ~ book.cover ~ '.details.jpg') }}" alt="{{ book.title }}"/>
</a>
templates/book/_details.html.twig
New file
@@ -0,0 +1,42 @@
<div class="panel book-panel">
    <div class="panel-header">
        <div class="panel-title h5">{% trans %}book_details_title{% endtrans %}</div>
    </div>
    <div class="panel-body">
        <table class="table">
            <tr>
                <th>{% trans %}book_id_header{% endtrans %}</th>
                <td>{{ book.id }}</td>
            </tr>
            <tr>
                <th>{% trans %}book_title_header{% endtrans %}</th>
                <td>{{ book.title }}</td>
            </tr>
            <tr>
                <th>{% trans %}book_author_header{% endtrans %}</th>
                <td>{{ book.author }}</td>
            </tr>
            {% for key, value in {
                book_language_header: book.language,
                book_edition_header: book.edition,
                book_publisher_header: book.publisher,
                book_publication_loc_header: book.publicationLoc,
                book_publication_year_header: book.publicationYear,
                book_isbn_header: book.isbn,
                book_issn_header: book.issn,
                book_placement_header: book.placement,
            } %}
                {% if value is not null %}
                    <tr>
                    <th>{{ key|trans }}</th>
                    <td>{{ value }}</td></tr>{% endif %}
            {% endfor %}
            <tr class="book-last-seen">
                <th>{% trans %}book_last_seen_header{% endtrans %}</th>
                <td>{% if book.lastSeen %}{{ book.lastSeen|date('Y-m-d H:i') }}{% else %}<span
                            class="text-gray">{% trans %}book_last_seen_never{% endtrans %}</span>{% endif %}</td>
            </tr>
        </table>
    </div>
    {% block footer %}{% endblock %}
</div>
templates/book/_history.html.twig
New file
@@ -0,0 +1,29 @@
<div class="panel book-panel">
    <div class="panel-header">
        <div class="panel-title h5">{% trans %}borrow_history_title{% endtrans %}</div>
    </div>
    <div class="panel-body">
        {% if history is empty %}
            <p class="text-gray">{% trans %}borrow_history_empty{% endtrans %}</p>
        {% else %}
            <table class="table">
                <thead>
                <tr>
                    <th>{% trans %}borrow_who_header{% endtrans %}</th>
                    <th>{% trans %}borrow_at_header{% endtrans %}</th>
                    <th>{% trans %}borrow_till_header{% endtrans %}</th>
                </tr>
                </thead>
                <tbody>
                {% for entry in history %}
                    <tr>
                        <td>{{ entry.who }}</td>
                        <td>{{ entry.at|date('Y-m-d') }}</td>
                        <td>{{ entry.till|date('Y-m-d') }}</td>
                    </tr>
                {% endfor %}
                </tbody>
            </table>
        {% endif %}
    </div>
</div>
templates/book/_results.html.twig
New file
@@ -0,0 +1,7 @@
<div class="results">
    {% for book in pagination %}
        {{ include('book/_tile.html.twig') }}
    {% endfor %}
    {{ knp_pagination_render(pagination) }}
</div>
templates/book/_tile.html.twig
New file
@@ -0,0 +1,35 @@
{% set show_url = path(show_path|default('book_show'), {id: book.id}) %}
<div class="tile{% if book.borrowed|default(false) %} tile-borrowed{% endif %}">
    <a class="tile-icon" href="{{ show_url }}" tabindex="-1" aria-hidden="true">
        {% if book.cover %}
            <img class="tile-cover" src="{{ asset('covers/' ~ book.cover ~ '.list.jpg') }}" alt="" loading="lazy"/>
        {% else %}
            <i class="icon icon-photo centered"></i>
        {% endif %}
    </a>
    <div class="tile-content">
        <div class="tile-title"><a href="{{ show_url }}"><strong>{{ book.title }}</strong></a> · {{ book.author }}</div>
        <div class="tile-subtitle">{% if book.edition %}{% trans %}book_list_edition_pre{% endtrans %} {{ book.edition }} {% trans %}book_list_edition_post{% endtrans %} · {% endif %}
            <span class="text-gray">{% if book.publisher %}{{ book.publisher }} · {% endif %}{{ book.publicationLoc }}{% if book.publicationYear %} {{ book.publicationYear }}{% endif %}</span>
        </div>
        <div class="tile-subtitle">
            <span class="label label-rounded label-primary">{{ book.id }}</span>
            {% if book.placement %}<span
                    class="label label-rounded label-secondary">{{ book.placement }}</span>{% endif %}
            {% block status %}
                {% if book.borrowed %}<span
                        class="label label-rounded label-warning">{% trans %}borrow_status_lent{% endtrans %}</span>{% endif %}
            {% endblock %}
            {% for label in book.textLabels %}<span class="label label-rounded">{{ label.label }}</span> {% endfor %}
        </div>
        {% block details %}{% endblock %}
    </div>
    <div class="tile-action">
        {% block actions %}
            <a class="btn btn-link"
               href="{{ path('book_edit', {id: book.id}) }}">{% trans %}book_action_edit{% endtrans %}</a>
            <br/>
            <a class="btn btn-link" href="{{ show_url }}">{% trans %}book_action_more{% endtrans %}</a>
        {% endblock %}
    </div>
</div>
templates/book/add.html.twig
New file
@@ -0,0 +1,100 @@
{% extends 'base.html.twig' %}
{% block title %}{% trans %}add_page_title{% endtrans %}{% endblock %}
{% block content %}
    <div class="add page_content">
        <h2>{% trans %}add_page_title{% endtrans %}</h2>
        <p>{% trans %}add_page_description{% endtrans %}</p>
        <div class="columns">
            <div class="column col-4 col-md-12">
                <div class="card">
                    <div class="card-header">
                        <div class="card-title h5">{% trans %}add_section_isn_title{% endtrans %}</div>
                        <div class="card-subtitle text-gray">{% trans %}add_section_isn_desc{% endtrans %}</div>
                    </div>
                    <div class="card-body">
                        <form class="form-horizontal" action="{{ path('book_lookup') }}" method="get">
                            <div class="form-group">
                                <div class="col-12">
                                    <label class="form-label"
                                           for="isn-id">{% trans %}book_id_header{% endtrans %}</label>
                                </div>
                                <div class="col-12">
                                    <input class="form-input" type="text" id="isn-id" name="id"
                                           placeholder="{% trans %}book_id_header{% endtrans %}"/>
                                </div>
                            </div>
                            <div class="form-group">
                                <div class="col-12">
                                    <label class="form-label"
                                           for="isn-isn">{% trans %}book_isn_header{% endtrans %}</label>
                                </div>
                                <div class="col-12">
                                    <input class="form-input" type="text" id="isn-isn" name="isn" required
                                           placeholder="{% trans %}book_isn_description{% endtrans %}"/>
                                </div>
                            </div>
                            <div class="form-group">
                                <button class="btn btn-primary">{% trans %}add_action_add_isn{% endtrans %}</button>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
            <div class="column col-4 col-md-12">
                <div class="card">
                    <div class="card-header">
                        <div class="card-title h5">{% trans %}add_section_search_title{% endtrans %}</div>
                        <div class="card-subtitle text-gray">{% trans %}add_section_search_desc{% endtrans %}</div>
                    </div>
                    <div class="card-body">
                        <form class="form-horizontal" action="{{ path('book_lookup') }}" method="get">
                            {% for name, label in {id: 'book_id_header', title: 'book_title_header', author: 'book_author_header', publisher: 'book_publisher_header'} %}
                                <div class="form-group">
                                    <div class="col-12">
                                        <label class="form-label" for="search-{{ name }}">{{ label|trans }}</label>
                                    </div>
                                    <div class="col-12">
                                        <input class="form-input" type="text" id="search-{{ name }}" name="{{ name }}"
                                               placeholder="{{ label|trans }}"/>
                                    </div>
                                </div>
                            {% endfor %}
                            <div class="form-group">
                                <button class="btn btn-primary">{% trans %}add_action_add_search{% endtrans %}</button>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
            <div class="column col-4 col-md-12">
                <div class="card">
                    <div class="card-header">
                        <div class="card-title h5">{% trans %}add_section_manual_title{% endtrans %}</div>
                        <div class="card-subtitle text-gray">{% trans %}add_section_manual_desc{% endtrans %}</div>
                    </div>
                    <div class="card-body">
                        <form class="form-horizontal" action="{{ path('book_new') }}" method="get">
                            <div class="form-group">
                                <div class="col-12">
                                    <label class="form-label"
                                           for="manual-id">{% trans %}book_id_header{% endtrans %}</label>
                                </div>
                                <div class="col-12">
                                    <input class="form-input" type="text" id="manual-id" name="id"
                                           placeholder="{% trans %}book_id_header{% endtrans %}"/>
                                </div>
                            </div>
                            <div class="form-group">
                                <button class="btn btn-primary">{% trans %}add_action_add_manual{% endtrans %}</button>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
{% endblock %}
templates/book/edit.html.twig
New file
@@ -0,0 +1,13 @@
{% extends 'base.html.twig' %}
{% block title %}{% trans %}book_action_edit{% endtrans %}{% endblock %}
{% block content %}
    <div class="add page_content">
        <h2>{% trans %}book_action_edit{% endtrans %}</h2>
        <p>{% trans %}add_form_description{% endtrans %}</p>
        {{ form(form) }}
    </div>
{% endblock %}
templates/book/list.html.twig
New file
@@ -0,0 +1,27 @@
{% extends 'base.html.twig' %}
{% block title %}{% trans %}list_page_title{% endtrans %}{% endblock %}
{% block content %}
    {% if pagination.getTotalItemCount() == 0 %}
        <div class="results">
            <div class="empty">
                <div class="empty-icon">
                    <i class="icon icon-bookmark icon-2x"></i>
                </div>
                <p class="empty-title h5">{% trans %}list_no_books{% endtrans %}</p>
                <p class="empty-subtitle">{% trans %}list_no_books_add{% endtrans %}</p>
                <div class="empty-action">
                    <a class="btn btn-primary"
                       href="{{ path('book_add') }}">{% trans %}list_no_books_add_button{% endtrans %}</a>
                </div>
            </div>
        </div>
    {% else %}
        <div class="page_content">
            <h2>{% trans %}list_page_title{% endtrans %}</h2>
        </div>
        {{ include('book/_results.html.twig') }}
    {% endif %}
{% endblock %}
templates/book/lookup.html.twig
New file
@@ -0,0 +1,49 @@
{% extends 'base.html.twig' %}
{% block title %}{% trans %}lookup_page_title{% endtrans %}{% endblock %}
{% block content %}
    <div class="page_content">
        <h2>{% trans %}lookup_page_title{% endtrans %}</h2>
        {% if candidates is empty %}
            <p>{% trans %}lookup_no_results{% endtrans %}</p>
            <a class="btn btn-primary"
               href="{{ path('book_new', {id: book_id}) }}">{% trans %}add_action_add_manual{% endtrans %}</a>
        {% else %}
            <p>{% trans %}lookup_pick{% endtrans %}</p>
            <table class="table">
                <thead>
                <tr>
                    <th>{% trans %}book_title_header{% endtrans %}</th>
                    <th>{% trans %}book_author_header{% endtrans %}</th>
                    <th>{% trans %}book_publisher_header{% endtrans %}</th>
                    <th>{% trans %}book_publication_year_header{% endtrans %}</th>
                    <th></th>
                </tr>
                </thead>
                <tbody>
                {% for candidate in candidates %}
                    <tr>
                        <td>{{ candidate.title }}</td>
                        <td>{{ candidate.author }}</td>
                        <td>{{ candidate.publisher }}</td>
                        <td>{{ candidate.publicationYear }}</td>
                        <td><a class="btn btn-sm" href="{{ path('book_new', {
                                id: book_id,
                                title: candidate.title,
                                author: candidate.author,
                                language: candidate.language,
                                publisher: candidate.publisher,
                                publicationLoc: candidate.publicationLoc,
                                publicationYear: candidate.publicationYear,
                                isbn: candidate.isbn,
                                issn: candidate.issn,
                            }) }}">{% trans %}lookup_use{% endtrans %}</a></td>
                    </tr>
                {% endfor %}
                </tbody>
            </table>
        {% endif %}
    </div>
{% endblock %}
templates/book/new.html.twig
New file
@@ -0,0 +1,13 @@
{% extends 'base.html.twig' %}
{% block title %}{% trans %}add_form_title{% endtrans %}{% endblock %}
{% block content %}
    <div class="add page_content">
        <h2>{% trans %}add_form_title{% endtrans %}</h2>
        <p>{% trans %}add_form_description{% endtrans %}</p>
        {{ form(form) }}
    </div>
{% endblock %}
templates/book/show.html.twig
New file
@@ -0,0 +1,106 @@
{% extends 'base.html.twig' %}
{% block title %}{{ book.title }}{% endblock %}
{% block content %}
    <div class="page_content">
        <h2>{{ book.title }}</h2>
        <div class="columns">
            <div class="column col-7 col-md-12">
                {% embed 'book/_details.html.twig' %}
                    {% block footer %}
                        <div class="panel-footer">
                            <a class="btn"
                               href="{{ path('book_edit', {id: book.id}) }}">{% trans %}book_action_edit{% endtrans %}</a>
                        </div>
                    {% endblock %}
                {% endembed %}
                {{ include('book/_history.html.twig', {history: book.borrowHistory}) }}
            </div>
            <div class="column col-5 col-md-12">
                <div class="panel book-panel">
                    <div class="panel-header">
                        <div class="panel-title h5">{% trans %}cover_section_title{% endtrans %}</div>
                    </div>
                    {% if book.cover %}
                        <div class="panel-body">
                            {{ include('book/_cover.html.twig') }}
                            <form action="{{ path('cover_remove', {id: book.id}) }}" method="post">
                                <input type="hidden" name="_token" value="{{ csrf_token('remove-cover' ~ book.id) }}"/>
                                <button class="btn btn-link btn-sm">{% trans %}cover_action_remove{% endtrans %}</button>
                            </form>
                        </div>
                    {% endif %}
                    <div class="panel-footer">
                        {{ form(cover_form) }}
                    </div>
                </div>
                <div class="panel book-panel">
                    <div class="panel-header">
                        <div class="panel-title h5">{% trans %}borrow_section_title{% endtrans %}</div>
                    </div>
                    {% if book.borrow %}
                        <div class="panel-body">
                            <p>{{ 'borrow_lent_to'|trans({'%who%': book.borrow.who, '%date%': book.borrow.at|date('Y-m-d')}) }}</p>
                        </div>
                        <div class="panel-footer">
                            <form action="{{ path('book_return', {id: book.id}) }}" method="post">
                                <input type="hidden" name="_token" value="{{ csrf_token('return' ~ book.id) }}"/>
                                <button class="btn btn-primary">{% trans %}borrow_action_return{% endtrans %}</button>
                            </form>
                        </div>
                    {% else %}
                        <div class="panel-footer">
                            {{ form(borrow_form) }}
                        </div>
                    {% endif %}
                </div>
                <div class="panel book-panel">
                    <div class="panel-header">
                        <div class="panel-title h5">{% trans %}menu_labels{% endtrans %}</div>
                    </div>
                    {% if book.labels is not empty %}
                        <div class="panel-body">
                            <ul class="book-labels">
                                {% for label in book.labels %}
                                    <li>
                                        {% if label.type.value == 'text' %}
                                            <span class="label label-rounded">{{ label.label }}</span>
                                        {% else %}
                                            <span class="label label-rounded">{{ ('label_type_' ~ label.type.value)|trans }}: {{ label.label }}</span>
                                        {% endif %}
                                        <form class="inline" action="{{ path('label_remove', {id: label.id}) }}"
                                              method="post">
                                            <input type="hidden" name="_token"
                                                   value="{{ csrf_token('remove-label' ~ label.id) }}"/>
                                            <button class="btn btn-link btn-sm">{% trans %}label_action_remove{% endtrans %}</button>
                                        </form>
                                    </li>
                                {% endfor %}
                            </ul>
                        </div>
                    {% endif %}
                    <div class="panel-footer">
                        {{ form(label_form) }}
                    </div>
                </div>
                {% if not book.borrowed %}
                    <div class="panel book-panel">
                        <div class="panel-header">
                            <div class="panel-title h5">{% trans %}ousted_section_title{% endtrans %}</div>
                            <div class="panel-subtitle text-gray">{% trans %}ousted_section_desc{% endtrans %}</div>
                        </div>
                        <div class="panel-footer">
                            {{ form(oust_form) }}
                        </div>
                    </div>
                {% endif %}
            </div>
        </div>
    </div>
{% endblock %}
templates/borrow/list.html.twig
New file
@@ -0,0 +1,27 @@
{% extends 'base.html.twig' %}
{% block title %}{% trans %}borrow_list_title{% endtrans %}{% endblock %}
{% block content %}
    <div class="page_content">
        <h2>{% trans %}borrow_list_title{% endtrans %}</h2>
        {% if pagination.getTotalItemCount() == 0 %}
            <p>{% trans %}borrow_list_empty{% endtrans %}</p>
        {% endif %}
    </div>
    {% if pagination.getTotalItemCount() > 0 %}
        <div class="results">
            {% for borrow in pagination %}
                {% embed 'book/_tile.html.twig' with {book: borrow.book} %}
                    {% block status %}
                        <span class="label label-rounded label-warning">{{ 'borrow_lent_to'|trans({'%who%': borrow.who, '%date%': borrow.at|date('Y-m-d')}) }}</span>
                    {% endblock %}
                {% endembed %}
            {% endfor %}
            {{ knp_pagination_render(pagination) }}
        </div>
    {% endif %}
{% endblock %}
templates/inventory/index.html.twig
New file
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="{{ app.locale }}">
<head>
    <title>{% trans %}general_app_name{% endtrans %} - {% trans %}inventory_page_title{% endtrans %}</title>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <link rel="stylesheet" href="{{ asset('css/spectre/spectre.min.css') }}"/>
    <link rel="stylesheet" href="{{ asset('css/inventory.css') }}"/>
</head>
<body>
<header class="navbar">
    <section class="navbar-section">
        <a href="{{ path('home') }}" class="navbar-brand mr-2">{% trans %}general_app_name{% endtrans %}</a>
        <span class="text-gray">{% trans %}inventory_page_title{% endtrans %}</span>
    </section>
    <section class="navbar-section">
        <span id="connection" class="label label-rounded"></span>
    </section>
</header>
<main id="inventory"
      data-books-url="{{ path('inventory_books') }}"
      data-sync-url="{{ path('inventory_sync') }}"
      data-sw-url="{{ path('inventory_service_worker') }}"
      data-scope="{{ path('inventory') }}"
      data-book-url="{{ path('book_show', {id: 0})|replace({'/0': '/{id}'}) }}"
      data-locale="{{ app.locale }}">
    <form id="scan-form" class="scan-form" autocomplete="off">
        <div class="input-group">
            <input id="scan-input" class="form-input input-lg" type="text" inputmode="text" autofocus
                   placeholder="{% trans %}inventory_input_placeholder{% endtrans %}"
                   aria-label="{% trans %}inventory_input_placeholder{% endtrans %}"/>
            <button class="btn btn-primary btn-lg input-group-btn">{% trans %}inventory_action_enter{% endtrans %}</button>
        </div>
        <p class="form-input-hint">{% trans %}inventory_input_hint{% endtrans %}</p>
    </form>
    <div class="current-location">
        <span class="text-gray">{% trans %}inventory_current_location{% endtrans %}</span>
        <strong id="location">–</strong>
    </div>
    <div id="message" class="toast message" hidden></div>
    <div class="status columns">
        <div class="column col-6 col-sm-12">
            {% trans %}inventory_books_count{% endtrans %} <strong id="books-count">0</strong>
            <small class="text-gray" id="books-date"></small>
            <button id="refresh" type="button" class="btn btn-sm">{% trans %}inventory_action_refresh{% endtrans %}</button>
        </div>
        <div class="column col-6 col-sm-12">
            {% trans %}inventory_pending_count{% endtrans %} <strong id="pending-count">0</strong>
            <small class="text-gray" id="sync-date"></small>
            <button id="sync" type="button" class="btn btn-sm btn-primary">{% trans %}inventory_action_sync{% endtrans %}</button>
        </div>
    </div>
    <h5>{% trans %}inventory_log_title{% endtrans %}</h5>
    <p id="log-empty" class="text-gray">{% trans %}inventory_log_empty{% endtrans %}</p>
    <table class="table table-striped log">
        <tbody id="log"></tbody>
    </table>
    <button id="clear-log" type="button" class="btn btn-link btn-sm" hidden>{% trans %}inventory_action_clear_log{% endtrans %}</button>
</main>
<script type="application/json" id="messages">{{ {
    location_set: 'inventory_msg_location_set'|trans,
    no_location: 'inventory_msg_no_location'|trans,
    assigned: 'inventory_msg_assigned'|trans,
    moved: 'inventory_msg_moved'|trans,
    unknown_id: 'inventory_msg_unknown_id'|trans,
    unknown_number: 'inventory_msg_unknown_number'|trans,
    ambiguous_number: 'inventory_msg_ambiguous_number'|trans,
    unrecognized: 'inventory_msg_unrecognized'|trans,
    no_books: 'inventory_msg_no_books'|trans,
    books_loaded: 'inventory_msg_books_loaded'|trans,
    books_failed: 'inventory_msg_books_failed'|trans,
    synced: 'inventory_msg_synced'|trans,
    sync_failed: 'inventory_msg_sync_failed'|trans,
    sync_rejected: 'inventory_msg_sync_rejected'|trans,
    online: 'inventory_online'|trans,
    offline: 'inventory_offline'|trans,
    never: 'inventory_never'|trans,
    status_pending: 'inventory_status_pending'|trans,
    status_saved: 'inventory_status_saved'|trans,
    status_returned: 'inventory_status_returned'|trans,
    will_return: 'inventory_msg_will_return'|trans,
    status_unknown_book: 'inventory_status_unknown_book'|trans,
    status_invalid: 'inventory_status_invalid'|trans,
    remove: 'inventory_action_remove'|trans,
    confirm_clear: 'inventory_confirm_clear'|trans,
}|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_HEX_TAG') b-or constant('JSON_UNESCAPED_UNICODE'))|raw }}</script>
<script src="{{ asset('js/inventory.js') }}"></script>
</body>
</html>
templates/inventory/sw.js.twig
New file
@@ -0,0 +1,46 @@
'use strict';
const CACHE = 'inventory-v1';
const SHELL = {{ [
    path('inventory'),
    asset('js/inventory.js'),
    asset('css/inventory.css'),
    asset('css/spectre/spectre.min.css'),
]|json_encode(constant('JSON_UNESCAPED_SLASHES'))|raw }};
const SHELL_URLS = new Set(SHELL.map((url) => new URL(url, self.location).href));
self.addEventListener('install', (event) => {
    event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', (event) => {
    event.waitUntil(
        caches.keys()
            .then((keys) => Promise.all(keys.filter((key) => key.startsWith('inventory-') && key !== CACHE).map((key) => caches.delete(key))))
            .then(() => self.clients.claim()),
    );
});
self.addEventListener('fetch', (event) => {
    const request = event.request;
    const url = new URL(request.url);
    url.search = '';
    // The book list and the uploads are handled by the page itself.
    if (request.method !== 'GET' || !SHELL_URLS.has(url.href)) {
        return;
    }
    event.respondWith(
        fetch(request)
            .then((response) => {
                if (response.ok) {
                    const copy = response.clone();
                    caches.open(CACHE).then((cache) => cache.put(url.href, copy));
                }
                return response;
            })
            .catch(() => caches.match(url.href).then((cached) => cached || Response.error())),
    );
});
templates/ousted/bulk.html.twig
New file
@@ -0,0 +1,57 @@
{% extends 'base.html.twig' %}
{% block title %}{% trans %}bulk_oust_page_title{% endtrans %}{% endblock %}
{% block content %}
    <div class="page_content">
        <h2>{% trans %}bulk_oust_page_title{% endtrans %}</h2>
        <p>{% trans %}bulk_oust_page_desc{% endtrans %}</p>
        {{ form(form) }}
    </div>
    {% if pagination %}
        <div class="page_content">
            {% if pagination.getTotalItemCount() == 0 %}
                <p class="toast toast-success">{{ 'bulk_oust_none'|trans({'%date%': since|date('Y-m-d')}) }}</p>
            {% else %}
                <div class="panel book-panel bulk-oust-confirm">
                    <div class="panel-header">
                        <div class="panel-title h5">{{ 'bulk_oust_found'|trans({'%count%': pagination.getTotalItemCount(), '%date%': since|date('Y-m-d')}) }}</div>
                        <div class="panel-subtitle">{{ 'bulk_oust_confirm_reason'|trans({'%reason%': confirm_form.reason.vars.value}) }}</div>
                        <div class="panel-subtitle text-gray">{% trans %}bulk_oust_confirm_desc{% endtrans %}</div>
                    </div>
                    <div class="panel-footer">
                        {{ form_start(confirm_form) }}
                        {# Repeats what the person previewed; the date and reason are shown above. #}
                        <div hidden>
                            {{ form_row(confirm_form.notSeenSince) }}
                            {{ form_row(confirm_form.reason) }}
                        </div>
                        {{ form_end(confirm_form) }}
                    </div>
                </div>
            {% endif %}
            {% if borrowed > 0 %}
                <p class="text-gray bulk-oust-borrowed">{{ 'bulk_oust_borrowed_skipped'|trans({'%count%': borrowed}) }}</p>
            {% endif %}
        </div>
        {% if pagination.getTotalItemCount() > 0 %}
            <div class="results">
                {% for book in pagination %}
                    {% embed 'book/_tile.html.twig' %}
                        {% block details %}
                            <div class="tile-subtitle text-gray">
                                {% trans %}book_last_seen_header{% endtrans %}:
                                {{ book.lastSeen ? book.lastSeen|date('Y-m-d H:i') : 'book_last_seen_never'|trans }}
                            </div>
                        {% endblock %}
                    {% endembed %}
                {% endfor %}
                {{ knp_pagination_render(pagination) }}
            </div>
        {% endif %}
    {% endif %}
{% endblock %}
templates/ousted/list.html.twig
New file
@@ -0,0 +1,31 @@
{% extends 'base.html.twig' %}
{% block title %}{% trans %}ousted_list_title{% endtrans %}{% endblock %}
{% block content %}
    <div class="page_content">
        <h2>{% trans %}ousted_list_title{% endtrans %}</h2>
        <p><a class="btn" href="{{ path('ousted_bulk') }}">{% trans %}bulk_oust_page_title{% endtrans %}</a></p>
        {% if pagination.getTotalItemCount() == 0 %}
            <p>{% trans %}ousted_list_empty{% endtrans %}</p>
        {% endif %}
    </div>
    {% if pagination.getTotalItemCount() > 0 %}
        <div class="results">
            {% for book in pagination %}
                {% embed 'book/_tile.html.twig' with {show_path: 'ousted_show'} %}
                    {% block status %}
                        <span class="label label-rounded label-error">{% trans %}ousted_status{% endtrans %}: {{ book.reason }}</span>
                    {% endblock %}
                    {% block actions %}
                        <a class="btn btn-link" href="{{ show_url }}">{% trans %}book_action_more{% endtrans %}</a>
                    {% endblock %}
                {% endembed %}
            {% endfor %}
            {{ knp_pagination_render(pagination) }}
        </div>
    {% endif %}
{% endblock %}
templates/ousted/show.html.twig
New file
@@ -0,0 +1,55 @@
{% extends 'base.html.twig' %}
{% block title %}{{ book.title }}{% endblock %}
{% block content %}
    <div class="page_content">
        <h2>{{ book.title }}</h2>
        <div class="columns">
            <div class="column col-7 col-md-12">
                {% embed 'book/_details.html.twig' %}
                    {% block footer %}
                        {% if book.textLabels is not empty %}
                            <div class="panel-footer">
                                {% for label in book.textLabels %}<span
                                        class="label label-rounded">{{ label.label }}</span> {% endfor %}
                            </div>
                        {% endif %}
                    {% endblock %}
                {% endembed %}
                {{ include('book/_history.html.twig', {history: book.borrowHistory}) }}
            </div>
            <div class="column col-5 col-md-12">
                <div class="panel book-panel">
                    <div class="panel-header">
                        <div class="panel-title h5"><span
                                    class="label label-rounded label-warning">{% trans %}ousted_status{% endtrans %}</span>
                        </div>
                    </div>
                    <div class="panel-body">
                        <p>{% trans %}ousted_reason_header{% endtrans %}: {{ book.reason }}</p>
                    </div>
                    <div class="panel-footer">
                        <form action="{{ path('ousted_restore', {id: book.id}) }}" method="post">
                            <input type="hidden" name="_token" value="{{ csrf_token('restore' ~ book.id) }}"/>
                            <button class="btn">{% trans %}ousted_action_restore{% endtrans %}</button>
                        </form>
                    </div>
                </div>
                {% if book.cover %}
                    <div class="panel book-panel">
                        <div class="panel-header">
                            <div class="panel-title h5">{% trans %}cover_section_title{% endtrans %}</div>
                        </div>
                        <div class="panel-body">
                            {{ include('book/_cover.html.twig') }}
                        </div>
                    </div>
                {% endif %}
            </div>
        </div>
    </div>
{% endblock %}
templates/search/index.html.twig
New file
@@ -0,0 +1,39 @@
{% extends 'base.html.twig' %}
{% block title %}{% trans %}search_page_title{% endtrans %}{% endblock %}
{% block content %}
    <div class="search page_content">
        <h2>{% trans %}search_page_title{% endtrans %}</h2>
        {{ form_start(form) }}
        {{ form_widget(form) }}
        <div>
            <div class="form-group">
                <input class="btn btn-primary" type="submit" value="{% trans %}search_action_search{% endtrans %}"/>
                <a class="btn" href="{{ path('search') }}">{% trans %}search_action_reset{% endtrans %}</a>
            </div>
        </div>
        {{ form_end(form) }}
    </div>
    {% if pagination %}
        {% if pagination.getTotalItemCount() == 0 %}
            <div class="results">
                <div class="empty">
                    <div class="empty-icon">
                        <i class="icon icon-bookmark icon-2x"></i>
                    </div>
                    <p class="empty-title h5">{% trans %}search_no_books{% endtrans %}</p>
                    <p class="empty-subtitle">{% trans %}search_no_books_refine{% endtrans %}</p>
                </div>
            </div>
        {% else %}
            <div class="page_content">
                <h3>{% trans %}search_results_page_title{% endtrans %}</h3>
            </div>
            {{ include('book/_results.html.twig') }}
        {% endif %}
    {% endif %}
{% endblock %}
templates/spectre/form.html.twig
New file
@@ -0,0 +1,73 @@
{% use "form_div_layout.html.twig" %}
{% block button_row -%}
    <div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group')|trim})} %}{{ block('attributes') }}{% endwith %}>
        {{- form_widget(form) -}}
    </div>
{%- endblock button_row %}
{% block button_widget -%}
    {%- set attr = attr|merge({class: (attr.class|default('btn-secondary') ~ ' btn')|trim}) -%}
    {{- parent() -}}
{%- endblock button_widget %}
{% block submit_widget -%}
    {%- set attr = attr|merge({class: (attr.class|default('btn-primary'))|trim}) -%}
    {{- parent() -}}
{%- endblock submit_widget %}
{% block form_label -%}
    {% if label is not same as(false) -%}
        {%- if compound is defined and compound -%}
            {%- set element = 'legend' -%}
            {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' form-label')|trim}) -%}
        {%- else -%}
            {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' form-label')|trim}) -%}
            {%- set label_attr = label_attr|merge({for: id}) -%}
        {%- endif -%}
        {% if label is empty -%}
            {%- if label_format is not empty -%}
                {% set label = label_format|replace({
                    '%name%': name,
                    '%id%': id,
                }) %}
            {%- else -%}
                {% set label = name|humanize %}
            {%- endif -%}
        {%- endif -%}
        <{{ element|default('label') }}{% if label_attr %}{% with { attr: label_attr } %}{{ block('attributes') }}{% endwith %}{% endif %}>{{ translation_domain is same as(false) ? label : label|trans(label_translation_parameters, translation_domain) }}{% block form_label_errors %}{{- form_errors(form) -}}{% endblock form_label_errors %}</{{ element|default('label') }}>
    {%- else -%}
        {%- if errors|length > 0 -%}
            <div id="{{ id }}_errors" class="mb-2">
                {{- form_errors(form) -}}
            </div>
        {%- endif -%}
    {%- endif -%}
{%- endblock form_label %}
{% block form_widget_simple -%}
    {% if type is not defined or type != 'hidden' %}
        {%- set attr = attr|merge({class: (attr.class|default('') ~ (type|default('') == 'file' ? ' custom-file-input' : ' form-input'))|trim}) -%}
    {% endif %}
    {%- if type is defined and (type == 'range' or type == 'color') %}
        {# Attribute "required" is not supported #}
        {%- set required = false -%}
    {% endif %}
    {{- parent() -}}
{%- endblock form_widget_simple %}
{% block form_row -%}
    {%- if compound is defined and compound -%}
        {%- set element = 'fieldset' -%}
    {%- endif -%}
    {%- set widget_attr = {} -%}
    {%- if help is not empty -%}
        {%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
    {%- endif -%}
    <{{ element|default('div') }}{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group')|trim})} %}{{ block('attributes') }}{% endwith %}>
    {{- form_label(form) -}}
    {{- form_widget(form, widget_attr) -}}
    {{- form_help(form) -}}
    </{{ element|default('div') }}>
{%- endblock form_row %}
templates/spectre/knp_paginator_pagination.html.twig
New file
@@ -0,0 +1,31 @@
{% if pageCount > 1 %}
    <ul class="pagination">
        {% if first is defined and current != first %}
            <li class="page-item">
                <a href="{{ path(route, query|merge({(pageParameterName): first})) }}">{% trans %}pagination_first{% endtrans %}</a>
            </li>
        {% endif %}
        {% if previous is defined %}
            <li class="page-item">
                <a href="{{ path(route, query|merge({(pageParameterName): previous})) }}">{% trans %}pagination_prev{% endtrans %}</a>
            </li>
        {% endif %}
        {% for page in pagesInRange %}
            <li class="page-item{% if page == current %} active{% endif %}">
                <a href="{{ path(route, query|merge({(pageParameterName): page})) }}">{{ page }}</a>
            </li>
        {% endfor %}
        {% if next is defined %}
            <li class="page-item">
                <a href="{{ path(route, query|merge({(pageParameterName): next})) }}">{% trans %}pagination_next{% endtrans %}</a>
            </li>
        {% endif %}
        {% if last is defined and current != last %}
            <li class="page-item">
                <a href="{{ path(route, query|merge({(pageParameterName): last})) }}">{% trans %}pagination_last{% endtrans %}</a>
            </li>
        {% endif %}
    </ul>
{% endif %}
test.php
File was deleted
tests/DatabaseTestCase.php
New file
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Entity\Book;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
abstract class DatabaseTestCase extends WebTestCase {
    protected KernelBrowser $client;
    protected function setUp(): void {
        $this->client = static::createClient();
        $entityManager = $this->entityManager();
        $metadata = $entityManager->getMetadataFactory()->getAllMetadata();
        $tool = new SchemaTool($entityManager);
        $tool->dropSchema($metadata);
        $tool->createSchema($metadata);
    }
    protected function entityManager(): EntityManagerInterface {
        return static::getContainer()->get(EntityManagerInterface::class);
    }
    protected function createBook(int $id, string $title = 'Title', string $author = 'Author'): Book {
        $book = (new Book())->setId($id)->setTitle($title)->setAuthor($author);
        $this->entityManager()->persist($book);
        $this->entityManager()->flush();
        return $book;
    }
    protected function findBook(int $id): ?Book {
        $this->entityManager()->clear();
        return $this->entityManager()->find(Book::class, $id);
    }
}
tests/Fixtures/rotated-orientation-6.jpg
tests/Functional/BookTest.php
New file
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\Entity\OustedBook;
use App\Enum\LabelType;
use App\Tests\DatabaseTestCase;
final class BookTest extends DatabaseTestCase {
    public function testEmptyListInvitesToAddABook(): void {
        $this->client->request('GET', '/');
        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('.empty-title', 'There are no books');
    }
    public function testListIsPaginated(): void {
        for ($id = 1; $id <= 11; ++$id) {
            $this->createBook($id, 'Book ' . $id);
        }
        $crawler = $this->client->request('GET', '/list');
        self::assertCount(10, $crawler->filter('.tile'));
        $crawler = $this->client->request('GET', '/list?page=2');
        self::assertCount(1, $crawler->filter('.tile'));
    }
    public function testBookCanBeAddedWithPrefilledForm(): void {
        $crawler = $this->client->request('GET', '/add/form?id=7&title=Prefilled');
        self::assertResponseIsSuccessful();
        $form = $crawler->selectButton('Save')->form();
        self::assertSame('7', $form['book[id]']->getValue());
        self::assertSame('Prefilled', $form['book[title]']->getValue());
        $form['book[author]'] = 'Someone';
        $form['book[isbn]'] = '978-83-7384-345-5';
        $this->client->submit($form);
        self::assertResponseRedirects('/book/7');
        self::assertSame('9788373843455', $this->findBook(7)?->getIsbn());
    }
    public function testIsbnIsStoredAsATypedLabelAndCanBeClearedInTheForm(): void {
        $this->createBook(1)->setIsbn('9788373843455');
        $this->entityManager()->flush();
        $book = $this->findBook(1);
        self::assertSame(LabelType::ISBN, $book?->getLabels()->first()->getType());
        $crawler = $this->client->request('GET', '/book/1/edit');
        $form = $crawler->selectButton('Save')->form();
        $form['book[isbn]'] = '';
        $this->client->submit($form);
        self::assertCount(0, $this->findBook(1)?->getLabels() ?? []);
    }
    public function testIsbn10IsSavedAsIsbn13(): void {
        $crawler = $this->client->request('GET', '/add/form');
        $this->client->submit($crawler->selectButton('Save')->form([
            'book[id]' => '1',
            'book[title]' => 'Title',
            'book[author]' => 'Author',
            'book[isbn]' => '0-306-40615-2',
        ]));
        self::assertResponseRedirects('/book/1');
        self::assertSame('9780306406157', $this->findBook(1)?->getIsbn());
    }
    public function testMalformedIsbnCannotReachTheDatabase(): void {
        $book = new \App\Entity\Book()->setId(1)->setTitle('Title')->setAuthor('Author')->setIsbn('123');
        $this->expectException(\DomainException::class);
        $this->entityManager()->persist($book);
    }
    public function testInvalidIsbnIsRejected(): void {
        $crawler = $this->client->request('GET', '/add/form');
        $this->client->submit($crawler->selectButton('Save')->form([
            'book[id]' => '1',
            'book[title]' => 'Title',
            'book[author]' => 'Author',
            'book[isbn]' => 'abc',
        ]));
        self::assertResponseStatusCodeSame(422);
    }
    public function testIdZeroIsAValidSticker(): void {
        $this->createBook(0, 'Zero');
        $this->client->request('GET', '/book/0');
        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('h2', 'Zero');
    }
    public function testIdMustBeFreeInBothCollections(): void {
        $this->createBook(1);
        $ousted = (new OustedBook('Lost'))->setId(2)->setTitle('Gone')->setAuthor('Someone');
        $this->entityManager()->persist($ousted);
        $this->entityManager()->flush();
        foreach ([1, 2] as $id) {
            $crawler = $this->client->request('GET', '/add/form');
            $this->client->submit($crawler->selectButton('Save')->form([
                'book[id]' => (string)$id,
                'book[title]' => 'Duplicate',
                'book[author]' => 'Someone',
            ]));
            self::assertResponseStatusCodeSame(422);
            self::assertSelectorTextContains('body', 'already used by another book');
        }
    }
    public function testEditKeepsTheId(): void {
        $this->createBook(3, 'Before');
        $crawler = $this->client->request('GET', '/book/3/edit');
        $form = $crawler->selectButton('Save')->form();
        $form['book[title]'] = 'After';
        $this->client->submit($form);
        self::assertResponseRedirects('/book/3');
        self::assertSame('After', $this->findBook(3)?->getTitle());
    }
    public function testUnknownBookIsNotFound(): void {
        $this->client->request('GET', '/book/999');
        self::assertResponseStatusCodeSame(404);
    }
    public function testPolishIsServedFromAcceptLanguage(): void {
        $this->client->request('GET', '/list', server: ['HTTP_ACCEPT_LANGUAGE' => 'pl']);
        self::assertSelectorTextContains('.navbar-brand', 'Biblioteczka');
    }
}
tests/Functional/BulkOustTest.php
New file
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\Entity\OustedBook;
use App\Tests\DatabaseTestCase;
use Symfony\Component\DomCrawler\Crawler;
final class BulkOustTest extends DatabaseTestCase {
    protected function setUp(): void {
        parent::setUp();
        $this->createBook(1, 'Never seen');
        $this->createBook(2, 'Seen long ago')->setLastSeen(new \DateTimeImmutable('2026-01-10 12:00'));
        $this->createBook(3, 'Seen on the day')->setLastSeen(new \DateTimeImmutable('2026-11-01 00:00'));
        $this->createBook(4, 'Seen later')->setLastSeen(new \DateTimeImmutable('2026-11-05 18:00'));
        $this->createBook(5, 'Lent out')->lendTo('A Reader', new \DateTimeImmutable('2026-01-01'));
        $this->entityManager()->flush();
    }
    public function testPreviewListsBooksNotSeenSinceTheDate(): void {
        $crawler = $this->preview('2026-11-01');
        self::assertResponseIsSuccessful();
        self::assertSame([1, 2], $crawler->filter('.results .tile .label-primary')->each(static fn($node): int => (int)$node->text()));
        self::assertSelectorTextContains('.bulk-oust-confirm', '2 books were not seen on or after 2026-11-01');
        self::assertSelectorTextContains('.bulk-oust-borrowed', '1 borrowed books');
        self::assertNotNull($this->findBook(1));
    }
    public function testConfirmingOustsTheListedBooks(): void {
        $crawler = $this->preview('2026-11-01');
        $this->client->submit($crawler->selectButton('Take them all out')->form());
        self::assertResponseRedirects('/ousted');
        $this->client->followRedirect();
        self::assertSelectorTextContains('.flash', '2 books have been taken out');
        self::assertNull($this->findBook(1));
        self::assertNull($this->findBook(2));
        self::assertNotNull($this->findBook(3));
        self::assertNotNull($this->findBook(4));
        self::assertNotNull($this->findBook(5));
        self::assertSame('Inventory 2026', $this->entityManager()->find(OustedBook::class, 2)?->getReason());
    }
    public function testNothingIsOustedIfTheSelectionChanged(): void {
        $crawler = $this->preview('2026-11-01');
        $form = $crawler->selectButton('Take them all out')->form();
        // One book found and another added: the count stays the same, the books do not.
        $this->findBook(2)?->setLastSeen(new \DateTimeImmutable('2026-11-02'));
        $this->entityManager()->flush();
        $this->createBook(6, 'Added meanwhile');
        $this->client->submit($form);
        self::assertResponseRedirects();
        $this->client->followRedirect();
        self::assertSelectorTextContains('.flash', 'has changed');
        self::assertNotNull($this->findBook(1));
        self::assertNotNull($this->findBook(6));
    }
    public function testConfirmationRequiresAValidCsrfToken(): void {
        $this->client->request('POST', '/ousted/bulk', ['bulk_oust' => [
            'notSeenSince' => '2026-11-01',
            'reason' => 'Forged',
            'selection' => 'anything',
            '_token' => 'forged',
        ]]);
        self::assertResponseRedirects('/ousted/bulk');
        self::assertNotNull($this->findBook(1));
    }
    public function testPreviewWithNothingToOust(): void {
        $this->findBook(1)?->setLastSeen(new \DateTimeImmutable('2026-03-01'));
        $this->entityManager()->flush();
        $this->preview('2026-01-01');
        self::assertSelectorTextContains('.toast-success', 'Every book on the shelves was seen');
        self::assertSelectorNotExists('.bulk-oust-confirm');
    }
    private function preview(string $date): Crawler {
        $crawler = $this->client->request('GET', '/ousted/bulk');
        return $this->client->submit($crawler->selectButton('Show the books')->form([
            'bulk_oust[notSeenSince]' => $date,
            'bulk_oust[reason]' => 'Inventory 2026',
        ]));
    }
}
tests/Functional/CoverTest.php
New file
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\Entity\OustedBook;
use App\Service\CoverStorage;
use App\Tests\DatabaseTestCase;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
use Symfony\Component\Filesystem\Filesystem;
#[RequiresPhpExtension('gd')]
final class CoverTest extends DatabaseTestCase {
    private string $workDir;
    protected function setUp(): void {
        parent::setUp();
        $this->workDir = sys_get_temp_dir() . '/book-cover-test-' . bin2hex(random_bytes(4));
        $filesystem = new Filesystem();
        $filesystem->mkdir($this->workDir);
        $filesystem->remove(static::getContainer()->getParameter('kernel.project_dir') . '/var/test/covers');
        $this->createBook(1, 'Covered');
    }
    protected function tearDown(): void {
        new Filesystem()->remove($this->workDir);
        parent::tearDown();
    }
    public function testOriginalIsKeptAndThumbnailsAreCreated(): void {
        $file = $this->createImage(1000, 2000, 'cover.png');
        $name = $this->uploadCover($file);
        self::assertMatchesRegularExpression('/^[0-9a-f]{32}\.png$/', $name);
        self::assertFileEquals($file, $this->storage()->originalPath($name));
        self::assertSame([113, 225], $this->imageSize($this->storage()->thumbnailPath($name, 'list')));
        self::assertSame([450, 900], $this->imageSize($this->storage()->thumbnailPath($name, 'details')));
        self::assertSame(\IMAGETYPE_JPEG, exif_imagetype($this->storage()->thumbnailPath($name, 'list')));
    }
    public function testSmallImagesAreNotEnlarged(): void {
        $name = $this->uploadCover($this->createImage(80, 100, 'small.png'));
        self::assertSame([80, 100], $this->imageSize($this->storage()->thumbnailPath($name, 'details')));
    }
    public function testThumbnailsAreShownOnListAndDetails(): void {
        $name = $this->uploadCover($this->createImage(300, 400, 'cover.png'));
        $crawler = $this->client->request('GET', '/list');
        self::assertSame('/covers/' . $name . '.list.jpg', $crawler->filter('.tile-cover')->attr('src'));
        $crawler = $this->client->request('GET', '/book/1');
        self::assertSame('/covers/' . $name . '.details.jpg', $crawler->filter('.book-cover')->attr('src'));
        self::assertSame('/cover/' . $name, $crawler->filter('.book-cover')->ancestors()->first()->attr('href'));
    }
    public function testOriginalCanBeDownloaded(): void {
        $file = $this->createImage(300, 400, 'cover.png');
        $name = $this->uploadCover($file);
        $this->client->request('GET', '/cover/' . $name);
        self::assertResponseIsSuccessful();
        self::assertResponseHeaderSame('Content-Type', 'image/png');
        self::assertResponseHeaderSame('X-Content-Type-Options', 'nosniff');
    }
    public function testNewCoverReplacesTheOldOne(): void {
        $first = $this->uploadCover($this->createImage(300, 400, 'first.png'));
        $second = $this->uploadCover($this->createImage(300, 400, 'second.png'));
        self::assertNotSame($first, $second);
        self::assertFileDoesNotExist($this->storage()->originalPath($first));
        self::assertFileDoesNotExist($this->storage()->thumbnailPath($first, 'list'));
        self::assertFileExists($this->storage()->originalPath($second));
    }
    public function testCoverCanBeRemoved(): void {
        $name = $this->uploadCover($this->createImage(300, 400, 'cover.png'));
        $crawler = $this->client->request('GET', '/book/1');
        $this->client->submit($crawler->selectButton('Remove cover')->form());
        self::assertNull($this->findBook(1)?->getCover());
        self::assertFileDoesNotExist($this->storage()->originalPath($name));
        self::assertFileDoesNotExist($this->storage()->thumbnailPath($name, 'details'));
    }
    public function testExifRotationIsApplied(): void {
        $fixture = \dirname(__DIR__) . '/Fixtures/rotated-orientation-6.jpg';
        $file = $this->workDir . '/photo.jpg';
        copy($fixture, $file);
        $name = $this->uploadCover($file);
        self::assertSame([20, 40], $this->imageSize($this->storage()->thumbnailPath($name, 'details')));
    }
    public function testFilesThatAreNotImagesAreRejected(): void {
        $file = $this->workDir . '/cover.png';
        file_put_contents($file, '<?php echo "not an image";');
        $crawler = $this->client->request('GET', '/book/1');
        $form = $crawler->selectButton('Upload cover')->form();
        $form['cover[cover]']->upload($file);
        $this->client->submit($form);
        $this->client->followRedirect();
        self::assertSelectorExists('.toast-error');
        self::assertNull($this->findBook(1)?->getCover());
    }
    public function testCoverFollowsTheBookWhenOusted(): void {
        $name = $this->uploadCover($this->createImage(300, 400, 'cover.png'));
        $crawler = $this->client->request('GET', '/book/1');
        $this->client->submit($crawler->selectButton('Take out')->form(['oust[reason]' => 'Lost']));
        $this->entityManager()->clear();
        self::assertSame($name, $this->entityManager()->find(OustedBook::class, 1)?->getCover());
        self::assertFileExists($this->storage()->originalPath($name));
        $this->client->request('GET', '/ousted/1');
        self::assertSelectorExists('.book-cover');
    }
    public function testOriginalsCannotBeReachedOutsideTheCoverPattern(): void {
        $this->client->request('GET', '/cover/..%2F..%2F.env');
        self::assertResponseStatusCodeSame(404);
    }
    private function uploadCover(string $file): string {
        $crawler = $this->client->request('GET', '/book/1');
        $form = $crawler->selectButton('Upload cover')->form();
        $form['cover[cover]']->upload($file);
        $this->client->submit($form);
        self::assertResponseRedirects('/book/1');
        $name = $this->findBook(1)?->getCover();
        self::assertNotNull($name);
        return $name;
    }
    private function createImage(int $width, int $height, string $fileName): string {
        $path = $this->workDir . '/' . $fileName;
        $image = imagecreatetruecolor($width, $height);
        // Random pixels make each generated file unique.
        imagesetpixel($image, 0, 0, random_int(1, 0xFFFFFF));
        imagepng($image, $path);
        return $path;
    }
    /**
     * @return array{int, int}
     */
    private function imageSize(string $path): array {
        [$width, $height] = getimagesize($path);
        return [$width, $height];
    }
    private function storage(): CoverStorage {
        return static::getContainer()->get(CoverStorage::class);
    }
}
tests/Functional/ImportLegacyCommandTest.php
New file
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\Entity\Book;
use App\Entity\OustedBook;
use App\Tests\DatabaseTestCase;
use App\Tests\LegacyDatabase;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
final class ImportLegacyCommandTest extends DatabaseTestCase {
    use LegacyDatabase;
    protected function setUp(): void {
        parent::setUp();
        $this->createLegacyTables();
    }
    public function testImportMapsBooks(): void {
        $this->addLegacyBook(['id' => 0, 'tytul' => 'Zerowa', 'autor' => 'A', 'jezyk' => 'polski', 'rok' => 2005, 'miejsce' => 'Wrocław', 'wydawnictwo' => 'W', 'wydanie' => '2', 'ISBN' => '9788373843455', 'regal' => 'HOL', 'polka' => '4', 'rzad' => '1']);
        $this->addLegacyBook(['id' => 1, 'tytul' => 'Bez danych', 'autor' => 'B', 'jezyk' => '', 'rok' => 0]);
        $this->addLegacyBook(['id' => 2, 'tytul' => 'Częściowa lokalizacja', 'autor' => 'C', 'jezyk' => 'eng', 'regal' => 'K', 'ISBN' => '306406152', 'ISSN' => '3161234']);
        $this->addLegacyBook(['id' => 3, 'tytul' => 'Wycofana', 'autor' => 'D', 'jezyk' => 'polski', 'regal' => 'JK', 'polka' => '1', 'ISBN' => '9788303001795', 'wycofana' => '1', 'powod' => 'Zniszczona']);
        $tester = $this->runImport();
        $tester->assertCommandIsSuccessful();
        $this->entityManager()->clear();
        $full = $this->findBook(0);
        self::assertSame('Zerowa', $full?->getTitle());
        self::assertSame('polski', $full?->getLanguage());
        self::assertSame(2005, $full?->getPublicationYear());
        self::assertSame('9788373843455', $full?->getIsbn());
        self::assertSame('HOL/4/1', $full?->getPlacement());
        $sparse = $this->findBook(1);
        self::assertNull($sparse?->getLanguage());
        self::assertNull($sparse?->getPublicationYear());
        self::assertNull($sparse?->getPlacement());
        $partial = $this->findBook(2);
        self::assertSame('K', $partial?->getPlacement());
        self::assertSame('9780306406157', $partial?->getIsbn());
        self::assertSame('03161234', $partial?->getIssn());
        $ousted = $this->entityManager()->find(OustedBook::class, 3);
        self::assertSame('Zniszczona', $ousted?->getReason());
        self::assertSame('JK/1', $ousted?->getPlacement());
        self::assertSame('9788303001795', $ousted?->getIsbn());
        self::assertNull($this->findBook(3));
    }
    public function testInvalidIsbnIsKeptAsATextLabel(): void {
        $this->addLegacyBook(['id' => 1, 'tytul' => 'Zły numer', 'autor' => 'A', 'jezyk' => 'polski', 'ISBN' => '9788373843456']);
        $tester = $this->runImport();
        $tester->assertCommandIsSuccessful();
        self::assertStringContainsString('book 1: 9788373843456', $tester->getDisplay());
        $book = $this->findBook(1);
        self::assertNull($book?->getIsbn());
        self::assertSame('9788373843456', $book?->getTextLabels()[0]->getLabel());
    }
    public function testImportedBooksAreSearchable(): void {
        $this->addLegacyBook(['id' => 1, 'tytul' => 'Łódź', 'autor' => 'A', 'jezyk' => 'polski']);
        $this->runImport();
        $crawler = $this->client->request('GET', '/search', ['q' => 'Łódź']);
        self::assertCount(1, $crawler->filter('.tile'));
    }
    public function testImportMapsLoans(): void {
        $this->addLegacyBook(['id' => 1, 'tytul' => 'Na półce', 'autor' => 'A', 'jezyk' => 'polski']);
        $this->addLegacyBook(['id' => 2, 'tytul' => 'Wycofana', 'autor' => 'B', 'jezyk' => 'polski', 'wycofana' => '1', 'powod' => 'Prezent']);
        $this->legacy->insert('pozycz', ['id' => 1, 'kto' => 'Osoba A', 'od' => 1556909792]);
        $this->legacy->insert('pozycz_historia', ['id' => 1, 'kto' => 'Osoba B', 'od' => 1212076475, '"do"' => 1212176475]);
        $this->legacy->insert('pozycz_historia', ['id' => 2, 'kto' => 'Osoba C', 'od' => 1212076475, '"do"' => 1212176475]);
        $tester = $this->runImport();
        $tester->assertCommandIsSuccessful();
        $book = $this->findBook(1);
        self::assertSame('Osoba A', $book?->getBorrow()?->getWho());
        self::assertSame(1556909792, $book?->getBorrow()?->getAt()->getTimestamp());
        $entry = $book?->getBorrowHistory()->first();
        self::assertSame('Osoba B', $entry->getWho());
        self::assertSame(1212176475, $entry->getTill()->getTimestamp());
        self::assertCount(1, $this->entityManager()->find(OustedBook::class, 2)?->getBorrowHistory() ?? []);
        self::assertStringNotContainsString('left out', $tester->getDisplay());
    }
    public function testRowsWithoutAPlaceInTheNewSchemaAreReported(): void {
        $this->addLegacyBook(['id' => 1, 'tytul' => 'Dwa razy', 'autor' => 'A', 'jezyk' => 'polski']);
        $this->addLegacyBook(['id' => 2, 'tytul' => 'Wycofana', 'autor' => 'B', 'jezyk' => 'polski', 'wycofana' => '1', 'powod' => 'Prezent']);
        $this->legacy->insert('pozycz', ['id' => 1, 'kto' => 'Starsze', 'od' => 1000]);
        $this->legacy->insert('pozycz', ['id' => 1, 'kto' => 'Nowsze', 'od' => 2000]);
        $this->legacy->insert('pozycz', ['id' => 2, 'kto' => 'Osoba', 'od' => 1000]);
        $this->legacy->insert('pozycz', ['id' => 99, 'kto' => 'Osoba', 'od' => 1000]);
        $this->legacy->insert('pozycz_historia', ['id' => 99, 'kto' => 'Osoba', 'od' => 1000, '"do"' => 2000]);
        $this->legacy->insert('pozycz_historia', ['id' => 1, 'kto' => 'Osoba', 'od' => 1000, '"do"' => null]);
        $tester = $this->runImport();
        $tester->assertCommandIsSuccessful();
        self::assertSame('Nowsze', $this->findBook(1)?->getBorrow()?->getWho());
        $display = preg_replace('/\s+/', ' ', $tester->getDisplay());
        self::assertStringContainsString('5 rows were left out', $display);
        self::assertStringContainsString('book 1 has more than one open loan', $display);
        self::assertStringContainsString('book 2 is on loan but ousted', $display);
        self::assertStringContainsString('pozycz: book 99 does not exist', $display);
        self::assertStringContainsString('pozycz_historia: book 99 does not exist', $display);
        self::assertStringContainsString('has no end date', $display);
    }
    public function testDryRunSavesNothing(): void {
        $this->addLegacyBook(['id' => 1, 'tytul' => 'Książka', 'autor' => 'A', 'jezyk' => 'polski']);
        $tester = $this->runImport(['--dry-run' => true]);
        $tester->assertCommandIsSuccessful();
        self::assertStringContainsString('nothing was saved', $tester->getDisplay());
        self::assertSame(0, $this->entityManager()->getRepository(Book::class)->count());
    }
    public function testImportKeepsBooksAlreadyInTheTarget(): void {
        $this->createBook(1, 'Edited title');
        $ousted = new OustedBook('Lost')->setId(2)->setTitle('Ousted since')->setAuthor('B');
        $this->entityManager()->persist($ousted);
        $this->entityManager()->flush();
        $this->addLegacyBook(['id' => 1, 'tytul' => 'Legacy title', 'autor' => 'A', 'jezyk' => 'polski']);
        $this->addLegacyBook(['id' => 2, 'tytul' => 'Na półce', 'autor' => 'B', 'jezyk' => 'polski']);
        $this->addLegacyBook(['id' => 3, 'tytul' => 'Nowa', 'autor' => 'C', 'jezyk' => 'polski']);
        $this->legacy->insert('pozycz', ['id' => 1, 'kto' => 'Osoba A', 'od' => 1000]);
        $this->legacy->insert('pozycz_historia', ['id' => 2, 'kto' => 'Osoba B', 'od' => 1000, '"do"' => 2000]);
        $tester = $this->runImport();
        $tester->assertCommandIsSuccessful();
        $book = $this->findBook(1);
        self::assertSame('Edited title', $book?->getTitle());
        self::assertNull($book?->getBorrow());
        self::assertNull($this->findBook(2));
        self::assertCount(0, $this->entityManager()->find(OustedBook::class, 2)?->getBorrowHistory() ?? [1]);
        self::assertSame('Nowa', $this->findBook(3)?->getTitle());
        self::assertMatchesRegularExpression('/already in target, kept\s+2/', $tester->getDisplay());
        self::assertStringNotContainsString('left out', $tester->getDisplay());
    }
    public function testRunningTheImportTwiceChangesNothing(): void {
        $this->addLegacyBook(['id' => 1, 'tytul' => 'Książka', 'autor' => 'A', 'jezyk' => 'polski', 'ISBN' => '9788373843456']);
        $this->addLegacyBook(['id' => 2, 'tytul' => 'Wycofana', 'autor' => 'B', 'jezyk' => 'polski', 'wycofana' => '1', 'powod' => 'Prezent']);
        $this->legacy->insert('pozycz', ['id' => 1, 'kto' => 'Osoba A', 'od' => 2000]);
        $this->legacy->insert('pozycz_historia', ['id' => 1, 'kto' => 'Osoba B', 'od' => 1000, '"do"' => 1500]);
        $this->legacy->insert('pozycz_historia', ['id' => 2, 'kto' => 'Osoba C', 'od' => 1000, '"do"' => 1500]);
        $this->runImport()->assertCommandIsSuccessful();
        $tester = $this->runImport();
        $tester->assertCommandIsSuccessful();
        self::assertSame(1, $this->entityManager()->getRepository(Book::class)->count());
        self::assertSame(1, $this->entityManager()->getRepository(OustedBook::class)->count());
        $book = $this->findBook(1);
        self::assertSame('Osoba A', $book?->getBorrow()?->getWho());
        self::assertCount(1, $book?->getBorrowHistory() ?? []);
        self::assertCount(1, $book?->getTextLabels() ?? []);
        self::assertCount(1, $this->entityManager()->find(OustedBook::class, 2)?->getBorrowHistory() ?? []);
        self::assertMatchesRegularExpression('/already in target, kept\s+2/', $tester->getDisplay());
    }
    public function testFailureLeavesTheTargetUntouched(): void {
        $this->addLegacyBook(['id' => 1, 'tytul' => 'Książka', 'autor' => 'A', 'jezyk' => 'polski']);
        $this->legacy->executeStatement('ALTER TABLE pozycz RENAME TO pozycz_missing');
        try {
            $this->runImport();
            self::fail('The import should have failed.');
        } catch (\Doctrine\DBAL\Exception) {
            self::assertSame(0, $this->entityManager()->getRepository(Book::class)->count());
        }
    }
    /**
     * @param array<string, mixed> $row
     */
    private function addLegacyBook(array $row): void {
        $this->legacy->insert('ksiazki', $row);
    }
    /**
     * @param array<string, mixed> $input
     */
    private function runImport(array $input = []): CommandTester {
        $application = new Application(static::$kernel);
        $tester = new CommandTester($application->find('app:import-legacy'));
        $tester->execute($input);
        return $tester;
    }
}
tests/Functional/ImportLegacyCoversTest.php
New file
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\Entity\Book;
use App\Entity\OustedBook;
use App\Service\CoverStorage;
use App\Tests\DatabaseTestCase;
use App\Tests\LegacyDatabase;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Filesystem\Filesystem;
#[RequiresPhpExtension('gd')]
final class ImportLegacyCoversTest extends DatabaseTestCase {
    use LegacyDatabase;
    private const string ISBN_A = '9788373843455';
    private const string ISBN_B = '9788303001795';
    private const string ISBN_C = '9780306406157';
    private string $coversDir;
    protected function setUp(): void {
        parent::setUp();
        // The table import runs first and must find nothing to add.
        $this->createLegacyTables();
        $this->coversDir = sys_get_temp_dir() . '/legacy-covers-test-' . bin2hex(random_bytes(4));
        $filesystem = new Filesystem();
        $filesystem->mkdir($this->coversDir . '/own');
        $filesystem->remove(static::getContainer()->getParameter('kernel.project_dir') . '/var/test/covers');
    }
    protected function tearDown(): void {
        new Filesystem()->remove($this->coversDir);
        parent::tearDown();
    }
    public function testCoversArePickedByPreference(): void {
        $this->createBookWithIsbn(1, self::ISBN_A);
        $this->createBookWithIsbn(2, self::ISBN_B);
        $this->createBookWithIsbn(3, self::ISBN_C);
        $this->createBook(4);
        $generic = $this->createImage(self::ISBN_A);
        $this->createImage(self::ISBN_B);
        $this->createImage(self::ISBN_C);
        $ownByIsbn = $this->createImage('own/' . self::ISBN_B);
        $this->createImage('own/' . self::ISBN_C);
        $ownById = $this->createImage('own/00000003');
        $this->createImage('own/00000004');
        $tester = $this->runImport(['--covers' => $this->coversDir]);
        $tester->assertCommandIsSuccessful();
        self::assertStringContainsString('4 covers imported', $tester->getDisplay());
        self::assertFileEquals($generic, $this->originalOf(1));
        self::assertFileEquals($ownByIsbn, $this->originalOf(2));
        self::assertFileEquals($ownById, $this->originalOf(3));
        self::assertNotNull($this->findBook(4)?->getCover());
        self::assertFileExists($generic, 'The source files stay in place.');
    }
    public function testOustedBooksGetCoversToo(): void {
        $book = new OustedBook('Zniszczona')->setId(7)->setTitle('T')->setAuthor('A')->setIsbn(self::ISBN_A);
        $this->entityManager()->persist($book);
        $this->entityManager()->flush();
        $this->createImage(self::ISBN_A);
        $this->runImport(['--covers' => $this->coversDir])->assertCommandIsSuccessful();
        $this->entityManager()->clear();
        self::assertNotNull($this->entityManager()->find(OustedBook::class, 7)?->getCover());
    }
    public function testUnreadableFilesGiveWayAndOddFilesAreReported(): void {
        $this->createBookWithIsbn(1, self::ISBN_A);
        $generic = $this->createImage(self::ISBN_A);
        $this->createImage(self::ISBN_B);
        $this->createImage('own/00000099');
        file_put_contents($this->coversDir . '/notes.txt', 'x');
        file_put_contents($this->coversDir . '/own/00000001', 'not an image');
        $tester = $this->runImport(['--covers' => $this->coversDir]);
        $tester->assertCommandIsSuccessful();
        self::assertFileEquals($generic, $this->originalOf(1));
        $display = preg_replace('/\s+/', ' ', $tester->getDisplay());
        self::assertStringContainsString('1 files could not be imported', $display);
        self::assertStringContainsString('own/00000001: Unsupported image type.', $display);
        self::assertStringContainsString('2 files match no book', $display);
        self::assertStringContainsString(self::ISBN_B, $display);
        self::assertStringContainsString('own/00000099', $display);
        self::assertStringContainsString('1 files are named neither', $display);
        self::assertStringContainsString('notes.txt', $display);
    }
    public function testBooksWithACoverKeepIt(): void {
        $this->createBookWithIsbn(1, self::ISBN_A);
        $this->createImage(self::ISBN_A);
        $this->runImport(['--covers' => $this->coversDir])->assertCommandIsSuccessful();
        $cover = $this->findBook(1)?->getCover();
        $this->createImage('own/00000001');
        $tester = $this->runImport(['--covers' => $this->coversDir]);
        $tester->assertCommandIsSuccessful();
        self::assertSame($cover, $this->findBook(1)?->getCover());
        self::assertStringContainsString('0 covers imported', $tester->getDisplay());
    }
    public function testDryRunStoresNothing(): void {
        $this->createBookWithIsbn(1, self::ISBN_A);
        $this->createImage(self::ISBN_A);
        $tester = $this->runImport(['--covers' => $this->coversDir, '--dry-run' => true]);
        $tester->assertCommandIsSuccessful();
        self::assertNull($this->findBook(1)?->getCover());
        $originals = static::getContainer()->getParameter('app.cover_originals_dir');
        self::assertSame([], is_dir($originals) ? array_diff(scandir($originals), ['.', '..']) : []);
    }
    public function testMissingDirectoryFails(): void {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('is not a readable directory');
        $this->runImport(['--covers' => $this->coversDir . '/missing']);
    }
    private function createBookWithIsbn(int $id, string $isbn): Book {
        $book = $this->createBook($id)->setIsbn($isbn);
        $this->entityManager()->flush();
        return $book;
    }
    private function createImage(string $name): string {
        $path = $this->coversDir . '/' . $name;
        $image = imagecreatetruecolor(20, 30);
        // Random pixels make each generated file unique.
        imagesetpixel($image, 0, 0, random_int(1, 0xFFFFFF));
        imagepng($image, $path);
        return $path;
    }
    private function originalOf(int $id): string {
        $cover = $this->findBook($id)?->getCover();
        self::assertNotNull($cover);
        return static::getContainer()->get(CoverStorage::class)->originalPath($cover);
    }
    /**
     * @param array<string, mixed> $input
     */
    private function runImport(array $input): CommandTester {
        $application = new Application(static::$kernel);
        $tester = new CommandTester($application->find('app:import-legacy'));
        $tester->execute($input);
        return $tester;
    }
}
tests/Functional/InventoryTest.php
New file
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\Entity\OustedBook;
use App\Tests\DatabaseTestCase;
final class InventoryTest extends DatabaseTestCase {
    public function testPageAndServiceWorkerAreServed(): void {
        $this->client->request('GET', '/inventory/');
        self::assertResponseIsSuccessful();
        self::assertSelectorExists('#scan-input');
        $this->client->request('GET', '/inventory/sw.js');
        self::assertResponseIsSuccessful();
        self::assertResponseHeaderSame('Content-Type', 'text/javascript; charset=UTF-8');
        self::assertStringContainsString('"/js/inventory.js"', (string)$this->client->getResponse()->getContent());
    }
    public function testBookListContainsStandardNumbersAndPlacement(): void {
        $this->createBook(2, 'Solaris', 'Stanisław Lem')->setIsbn('9788373843455')->setIssn('9770867041003')->setPlacement('HOL/4/1');
        $this->createBook(1, 'Plain')->lendTo('A Reader', new \DateTimeImmutable());
        $this->entityManager()->flush();
        $this->client->request('GET', '/inventory/books');
        self::assertResponseIsSuccessful();
        self::assertSame(['books' => [
            ['id' => 1, 'title' => 'Plain', 'author' => 'Author', 'placement' => null, 'lastSeen' => null, 'borrowed' => true, 'isbn' => [], 'issn' => []],
            ['id' => 2, 'title' => 'Solaris', 'author' => 'Stanisław Lem', 'placement' => 'HOL/4/1', 'lastSeen' => null, 'borrowed' => false, 'isbn' => ['9788373843455'], 'issn' => ['9770867041003']],
        ]], $this->jsonResponse());
    }
    public function testSyncAssignsLocationsAndMarksBooksAsSeen(): void {
        $this->createBook(1)->setPlacement('OLD/1');
        $this->createBook(2);
        $before = new \DateTimeImmutable('-1 second');
        $this->sync(['1' => 'HOL/4/1', '2' => ' Attic ', '99' => 'X', '3' => '']);
        self::assertResponseIsSuccessful();
        self::assertSame(['results' => [
            '1' => 'saved',
            '2' => 'saved',
            '99' => 'unknown_book',
            '3' => 'invalid',
        ]], $this->jsonResponse());
        $book = $this->findBook(1);
        self::assertSame('HOL/4/1', $book?->getPlacement());
        self::assertGreaterThanOrEqual($before, $book?->getLastSeen());
        self::assertSame('Attic', $this->findBook(2)?->getPlacement());
    }
    public function testScanningABorrowedBookMarksItAsReturned(): void {
        $lentAt = new \DateTimeImmutable('2026-10-01 10:00');
        $this->createBook(1)->lendTo('A Reader', $lentAt);
        $this->entityManager()->flush();
        $this->entityManager()->clear();
        $this->sync(['1' => 'HOL/4/1']);
        self::assertSame(['results' => ['1' => 'returned']], $this->jsonResponse());
        $book = $this->findBook(1);
        self::assertFalse($book?->isBorrowed());
        self::assertSame('HOL/4/1', $book?->getPlacement());
        $history = $book?->getBorrowHistory()->first();
        self::assertSame('A Reader', $history ? $history->getWho() : null);
        self::assertEquals($lentAt, $history ? $history->getAt() : null);
        self::assertEquals($book?->getLastSeen(), $history ? $history->getTill() : null);
        $this->sync(['1' => 'HOL/4/1']);
        self::assertSame(['results' => ['1' => 'saved']], $this->jsonResponse());
        self::assertCount(1, $this->findBook(1)?->getBorrowHistory() ?? []);
    }
    public function testSyncAcceptsBookZero(): void {
        $this->createBook(0);
        $this->sync((object)['0' => 'HOL/1/1']);
        self::assertResponseIsSuccessful();
        self::assertSame('HOL/1/1', $this->findBook(0)?->getPlacement());
    }
    public function testSyncRejectsAnythingButAJsonMap(): void {
        $this->createBook(1);
        $this->client->request('POST', '/inventory/sync', ['1' => 'HOL/4/1']);
        self::assertResponseStatusCodeSame(415);
        $this->sync([['id' => 1, 'location' => 'HOL/4/1']]);
        self::assertResponseStatusCodeSame(400);
        $this->sync(['1' => ['nested']]);
        self::assertResponseStatusCodeSame(400);
        self::assertNull($this->findBook(1)?->getPlacement());
    }
    public function testDetailsSayWhenBookWasNeverSeen(): void {
        $this->createBook(1);
        $this->client->request('GET', '/book/1');
        self::assertSelectorTextContains('.book-last-seen td', 'not seen during an inventory yet');
    }
    public function testOustingKeepsLastSeen(): void {
        $this->createBook(1);
        $this->sync(['1' => 'HOL/4/1']);
        $seen = $this->findBook(1)?->getLastSeen();
        $crawler = $this->client->request('GET', '/book/1');
        self::assertSelectorTextContains('.book-last-seen td', (string)$seen?->format('Y-m-d H:i'));
        $this->client->submit($crawler->selectButton('Take out')->form(['oust[reason]' => 'Lost']));
        $this->entityManager()->clear();
        self::assertEquals($seen, $this->entityManager()->find(OustedBook::class, 1)?->getLastSeen());
        $this->client->request('GET', '/ousted/1');
        self::assertSelectorTextContains('.book-last-seen td', (string)$seen?->format('Y-m-d H:i'));
    }
    /**
     * @param array<mixed>|object $body
     */
    private function sync(array|object $body): void {
        $this->client->request('POST', '/inventory/sync', server: ['CONTENT_TYPE' => 'application/json'], content: json_encode($body, \JSON_THROW_ON_ERROR));
    }
    /**
     * @return array<mixed>
     */
    private function jsonResponse(): array {
        return json_decode((string)$this->client->getResponse()->getContent(), true, flags: \JSON_THROW_ON_ERROR);
    }
}
tests/Functional/LibraryThingCoverTest.php
New file
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\Service\CoverStorage;
use App\Tests\DatabaseTestCase;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
#[RequiresPhpExtension('gd')]
final class LibraryThingCoverTest extends DatabaseTestCase {
    private const string ISBN = '9788373843455';
    /** @var list<string> */
    private array $requestedUrls = [];
    /** @var array{mixed, mixed} the key in $_ENV and $_SERVER before the test */
    private array $previousKey;
    protected function setUp(): void {
        parent::setUp();
        $this->previousKey = [$_ENV['LIBRARYTHING_API_KEY'] ?? null, $_SERVER['LIBRARYTHING_API_KEY'] ?? null];
        new Filesystem()->remove(static::getContainer()->getParameter('kernel.project_dir') . '/var/test/covers');
    }
    protected function tearDown(): void {
        [$_ENV['LIBRARYTHING_API_KEY'], $_SERVER['LIBRARYTHING_API_KEY']] = $this->previousKey;
        parent::tearDown();
    }
    public function testNewBookGetsTheLibraryThingCover(): void {
        $image = self::png(120, 180);
        $this->mockLibraryThing('secret-key', new MockResponse($image, ['response_headers' => ['content-type' => 'image/png']]));
        $this->addBook(self::ISBN);
        self::assertSame(['https://covers.librarything.com/devkey/secret-key/large/isbn/' . self::ISBN], $this->requestedUrls);
        $cover = $this->findBook(5)?->getCover();
        self::assertNotNull($cover);
        self::assertStringEqualsFile(static::getContainer()->get(CoverStorage::class)->originalPath($cover), $image);
        $this->client->followRedirect();
        self::assertAnySelectorTextContains('.flash', 'downloaded from LibraryThing');
    }
    public function testPlaceholderMeansNoCover(): void {
        $this->mockLibraryThing('secret-key', new MockResponse(self::png(1, 1)));
        $this->addBook(self::ISBN);
        self::assertCount(1, $this->requestedUrls);
        self::assertNull($this->findBook(5)?->getCover());
    }
    public function testFailedDownloadStillSavesTheBook(): void {
        $this->mockLibraryThing('secret-key', new MockResponse('', ['http_code' => 503]));
        $this->addBook(self::ISBN);
        self::assertResponseRedirects('/book/5');
        self::assertNull($this->findBook(5)?->getCover());
    }
    public function testNothingIsRequestedWithoutAKey(): void {
        $this->mockLibraryThing('', new MockResponse(self::png(120, 180)));
        $this->addBook(self::ISBN);
        self::assertSame([], $this->requestedUrls);
        self::assertNull($this->findBook(5)?->getCover());
    }
    public function testNothingIsRequestedWithoutAnIsbn(): void {
        $this->mockLibraryThing('secret-key', new MockResponse(self::png(120, 180)));
        $this->addBook('');
        self::assertSame([], $this->requestedUrls);
    }
    private function mockLibraryThing(string $key, MockResponse $response): void {
        $_ENV['LIBRARYTHING_API_KEY'] = $_SERVER['LIBRARYTHING_API_KEY'] = $key;
        // A reboot between the form and its submission would drop the mock.
        $this->client->disableReboot();
        static::getContainer()->set('librarything.client', new MockHttpClient(
            function (string $method, string $url) use ($response): MockResponse {
                $this->requestedUrls[] = $url;
                return $response;
            },
            'https://covers.librarything.com',
        ));
    }
    private function addBook(string $isbn): void {
        $crawler = $this->client->request('GET', '/add/form?id=5');
        $form = $crawler->selectButton('Save')->form();
        $form['book[title]'] = 'Title';
        $form['book[author]'] = 'Author';
        $form['book[isbn]'] = $isbn;
        $this->client->submit($form);
    }
    private static function png(int $width, int $height): string {
        $image = imagecreatetruecolor($width, $height);
        ob_start();
        imagepng($image);
        return (string)ob_get_clean();
    }
}
tests/Functional/LoanTest.php
New file
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\Entity\OustedBook;
use App\Tests\DatabaseTestCase;
final class LoanTest extends DatabaseTestCase {
    public function testBookCanBeLentAndReturned(): void {
        $this->createBook(1);
        $crawler = $this->client->request('GET', '/book/1');
        $this->client->submit($crawler->selectButton('Lend')->form(['borrow[who]' => 'A Reader']));
        self::assertResponseRedirects('/book/1');
        self::assertSame('A Reader', $this->findBook(1)?->getBorrow()?->getWho());
        $crawler = $this->client->request('GET', '/borrowed');
        self::assertSelectorTextContains('.results .tile', 'A Reader');
        self::assertSelectorExists('.results .tile-title a[href="/book/1"]');
        $crawler = $this->client->request('GET', '/book/1');
        self::assertCount(0, $crawler->selectButton('Take out'));
        $this->client->submit($crawler->selectButton('Mark as returned')->form());
        $book = $this->findBook(1);
        self::assertFalse($book?->isBorrowed());
        self::assertCount(1, $book?->getBorrowHistory() ?? []);
    }
    public function testActionsRequireAValidCsrfToken(): void {
        $this->createBook(1)->lendTo('A Reader', new \DateTimeImmutable());
        $this->entityManager()->flush();
        $this->client->request('POST', '/book/1/return', ['_token' => 'forged']);
        self::assertTrue($this->findBook(1)?->isBorrowed());
    }
    public function testOustedBookKeepsHistoryAndCanBeRestored(): void {
        $book = $this->createBook(1, 'Moved');
        $book->addBorrowHistory('A Reader', new \DateTimeImmutable('2020-01-01'), new \DateTimeImmutable('2020-02-01'));
        $this->entityManager()->flush();
        $crawler = $this->client->request('GET', '/book/1');
        $this->client->submit($crawler->selectButton('Take out')->form(['oust[reason]' => 'Lost']));
        self::assertResponseRedirects('/ousted/1');
        self::assertNull($this->findBook(1));
        $ousted = $this->entityManager()->find(OustedBook::class, 1);
        self::assertSame('Lost', $ousted?->getReason());
        self::assertCount(1, $ousted?->getBorrowHistory() ?? []);
        $this->client->request('GET', '/ousted');
        self::assertSelectorTextContains('.results .tile', 'Lost');
        self::assertSelectorExists('.results .tile-title a[href="/ousted/1"]');
        self::assertSelectorNotExists('.results a[href="/book/1/edit"]');
        $crawler = $this->client->request('GET', '/ousted/1');
        $this->client->submit($crawler->selectButton('Return to the collection')->form());
        self::assertResponseRedirects('/book/1');
        self::assertCount(1, $this->findBook(1)?->getBorrowHistory() ?? []);
        self::assertNull($this->entityManager()->find(OustedBook::class, 1));
    }
    public function testBorrowedBookCannotBeOusted(): void {
        $this->createBook(1);
        $crawler = $this->client->request('GET', '/book/1');
        $form = $crawler->selectButton('Take out')->form(['oust[reason]' => 'Lost']);
        $this->findBook(1)?->lendTo('A Reader', new \DateTimeImmutable());
        $this->entityManager()->flush();
        $this->client->submit($form);
        self::assertNotNull($this->findBook(1));
    }
    public function testTypedLabelsCanBeAddedAndAreValidated(): void {
        $this->createBook(1);
        $crawler = $this->client->request('GET', '/book/1');
        $this->client->submit($crawler->selectButton('Add label')->form([
            'add_label[type]' => 'isbn',
            'add_label[label]' => '978-83-7384-345-5',
        ]));
        self::assertSame('9788373843455', $this->findBook(1)?->getIsbn());
        $crawler = $this->client->request('GET', '/book/1');
        $this->client->submit($crawler->selectButton('Add label')->form([
            'add_label[type]' => 'issn',
            'add_label[label]' => 'not a number',
        ]));
        self::assertNull($this->findBook(1)?->getIssn());
    }
    public function testOustingKeepsLabels(): void {
        $book = $this->createBook(1)->setIsbn('9788373843455');
        $book->addLabel('crime');
        $this->entityManager()->flush();
        $crawler = $this->client->request('GET', '/book/1');
        $this->client->submit($crawler->selectButton('Take out')->form(['oust[reason]' => 'Lost']));
        $this->entityManager()->clear();
        $ousted = $this->entityManager()->find(OustedBook::class, 1);
        self::assertSame('9788373843455', $ousted?->getIsbn());
        self::assertCount(2, $ousted?->getLabels() ?? []);
        $crawler = $this->client->request('GET', '/ousted/1');
        $this->client->submit($crawler->selectButton('Return to the collection')->form());
        self::assertSame('9788373843455', $this->findBook(1)?->getIsbn());
        self::assertCount(2, $this->findBook(1)?->getLabels() ?? []);
    }
}
tests/Functional/LookupTest.php
New file
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\Tests\DatabaseTestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
use Symfony\Component\HttpClient\Response\MockResponse;
final class LookupTest extends DatabaseTestCase {
    public function testCandidatePrefillsTheAddForm(): void {
        $this->mockCatalogue(new JsonMockResponse(['bibs' => [[
            'title' => 'Trzynaście zagadek / Thirteen problems, Seria z Gawronem.',
            'author' => 'Christie, Agatha (1890-1976) Weiss, Maria Wydawnictwo Dolnośląskie',
            'publisher' => 'Da Capo Da Capo,',
            'placeOfPublication' => 'Wrocław : Polska',
            'publicationYear' => '1994',
            'language' => 'polski',
            'isbnIssn' => '83-7023-382-1',
        ]]]));
        $crawler = $this->client->request('GET', '/add/lookup', ['id' => '5', 'isn' => '8370233821']);
        self::assertResponseIsSuccessful();
        $crawler = $this->client->click($crawler->selectLink('Use')->link());
        $form = $crawler->selectButton('Save')->form();
        self::assertSame('5', $form['book[id]']->getValue());
        self::assertSame('Trzynaście zagadek / Thirteen problems, Seria z Gawronem', $form['book[title]']->getValue());
        self::assertSame('Agatha Christie', $form['book[author]']->getValue());
        self::assertSame('Da Capo', $form['book[publisher]']->getValue());
        self::assertSame('Wrocław', $form['book[publicationLoc]']->getValue());
        self::assertSame('1994', $form['book[publicationYear]']->getValue());
        self::assertSame('9788370233822', $form['book[isbn]']->getValue());
    }
    public function testNoResultsOffersManualEntry(): void {
        $this->mockCatalogue(new JsonMockResponse(['bibs' => []]));
        $this->client->request('GET', '/add/lookup', ['title' => 'Nothing']);
        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('.page_content', 'no matching books');
    }
    public function testCatalogueFailureFallsBackToTheAddPage(): void {
        $this->mockCatalogue(new MockResponse('', ['http_code' => 503]));
        $this->client->request('GET', '/add/lookup', ['isn' => '8370233821']);
        self::assertResponseRedirects('/add');
        $this->client->followRedirect();
        self::assertSelectorTextContains('.flash', 'catalogue is unavailable');
    }
    private function mockCatalogue(MockResponse $response): void {
        static::getContainer()->set('bn.client', new MockHttpClient($response, 'https://data.bn.org.pl'));
    }
}
tests/Functional/SearchTest.php
New file
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\Tests\DatabaseTestCase;
final class SearchTest extends DatabaseTestCase {
    protected function setUp(): void {
        parent::setUp();
        $this->createBook(1, 'Łódź nocą', 'Agatha Christie');
        $this->createBook(2, 'Rabat 100%', 'Stanisław Lem');
        $this->createBook(3, 'Solaris', 'Stanisław Lem')->setIsbn('9788373843455')->setPublisher('Wydawnictwo Literackie');
        $this->entityManager()->flush();
    }
    /**
     * @return iterable<string, array{string, list<int>}>
     */
    public static function quickQueries(): iterable {
        yield 'non-ASCII title' => ['Łódź', [1]];
        yield 'several books' => ['Stanisław', [2, 3]];
        yield 'words may hit different columns' => ['lem solaris', [3]];
        yield 'publisher' => ['literackie', [3]];
        yield 'ISBN with hyphens' => ['978-83-7384-345-5', [3]];
        yield 'ISBN-10 form of a stored ISBN-13' => ['83-7384-345-0', [3]];
        yield 'ID' => ['2', [2]];
        yield 'percent sign is literal' => ['100%', [2]];
        yield 'underscore is literal' => ['S_laris', []];
        yield 'no match' => ['nothing', []];
    }
    /**
     * @param list<int> $expectedIds
     */
    #[\PHPUnit\Framework\Attributes\DataProvider('quickQueries')]
    public function testQuickSearch(string $query, array $expectedIds): void {
        $crawler = $this->client->request('GET', '/search', ['q' => $query]);
        self::assertResponseIsSuccessful();
        $ids = $crawler->filter('.tile .label-primary')->each(static fn($node): int => (int)$node->text());
        self::assertSame($expectedIds, $ids);
    }
    public function testFieldsAreCombined(): void {
        $crawler = $this->client->request('GET', '/search', ['author' => 'lem', 'title' => 'SOLARIS']);
        self::assertCount(1, $crawler->filter('.tile'));
    }
    public function testFieldsDoNotMatchOtherColumns(): void {
        $crawler = $this->client->request('GET', '/search', ['title' => 'lem']);
        self::assertCount(0, $crawler->filter('.tile'));
        self::assertSelectorExists('.empty');
    }
    public function testSearchPageWithoutCriteriaShowsNoResults(): void {
        $crawler = $this->client->request('GET', '/search');
        self::assertResponseIsSuccessful();
        self::assertCount(0, $crawler->filter('.tile'));
    }
}
tests/LegacyDatabase.php
New file
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use Doctrine\DBAL\Connection;
/**
 * Gives a test empty legacy tables, an SQLite stand-in for orig-books.sql with the same column names and meanings.
 */
trait LegacyDatabase {
    private Connection $legacy;
    private function createLegacyTables(): void {
        $this->legacy = static::getContainer()->get('doctrine.dbal.legacy_connection');
        foreach (['ksiazki', 'pozycz', 'pozycz_historia', 'pozycz_missing'] as $table) {
            $this->legacy->executeStatement('DROP TABLE IF EXISTS ' . $table);
        }
        $this->legacy->executeStatement('CREATE TABLE ksiazki (id INTEGER PRIMARY KEY, tytul TEXT NOT NULL, autor TEXT NOT NULL, jezyk TEXT NOT NULL, rok INTEGER, miejsce TEXT, wydawnictwo TEXT, wydanie TEXT, ISBN TEXT, ISSN TEXT, regal TEXT, polka TEXT, rzad TEXT, wycofana TEXT NOT NULL DEFAULT \'0\', powod TEXT)');
        $this->legacy->executeStatement('CREATE TABLE pozycz (id INTEGER NOT NULL, kto TEXT NOT NULL, od INTEGER NOT NULL, PRIMARY KEY (id, od))');
        $this->legacy->executeStatement('CREATE TABLE pozycz_historia (id INTEGER NOT NULL, kto TEXT NOT NULL, od INTEGER NOT NULL, "do" INTEGER, PRIMARY KEY (id, od))');
    }
}
tests/Unit/IsbnTest.php
New file
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\Enum\LabelType;
use App\Model\Isbn;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class IsbnTest extends TestCase {
    /**
     * @return iterable<string, array{string, ?string}>
     */
    public static function values(): iterable {
        yield 'ISBN-10' => ['0306406152', '9780306406157'];
        yield 'ISBN-10 with hyphens' => ['0-306-40615-2', '9780306406157'];
        yield 'ISBN-10 ending in X' => ['080442957X', '9780804429573'];
        yield 'lower-case X' => ['080442957x', '9780804429573'];
        yield 'ISBN-13' => ['9788373843455', '9788373843455'];
        yield 'ISBN-13 with hyphens' => ['978-83-7384-345-5', '9788373843455'];
        yield '979 prefix' => ['9791034900107', '9791034900107'];
        yield 'bad ISBN-10 check digit' => ['0306406153', null];
        yield 'bad ISBN-13 check digit' => ['9788373843456', null];
        yield 'not a book prefix' => ['9770406063008', null];
        yield 'wrong length' => ['12345', null];
        yield 'text' => ['not a number', null];
    }
    #[DataProvider('values')]
    public function testConversion(string $value, ?string $expected): void {
        self::assertSame($expected, Isbn::toIsbn13($value));
    }
    public function testOnlyIsbn13PassesLabelValidation(): void {
        self::assertTrue(LabelType::ISBN->isValid('9780306406157'));
        self::assertFalse(LabelType::ISBN->isValid('0306406152'));
    }
}
tests/bootstrap.php
New file
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
if (method_exists(Dotenv::class, 'bootEnv')) {
    (new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');
}
if ($_SERVER['APP_DEBUG']) {
    umask(0000);
}
translations/messages.en.yaml
New file
@@ -0,0 +1,176 @@
general_app_name: Book Shelf
general_app_slogan: book management system
general_search_placeholder: ID, ISBN, title, author
general_search_button: Search
menu_list: List
menu_search: Search
menu_add: Add
menu_borrowed: Borrowed
menu_ousted: Ousted
menu_inventory: Inventory
book_id_header: ID
book_title_header: Title
book_author_header: Author
book_edition_header: Edition
book_publisher_header: Publisher
book_language_header: Language
book_publication_loc_header: Place of publication
book_publication_year_header: Publication year
book_isn_header: IS*N
book_isn_description: ISBN or ISSN
book_isbn_header: ISBN
book_issn_header: ISSN
book_placement_header: Location
book_last_seen_header: Last seen
book_last_seen_never: not seen during an inventory yet
book_action_edit: Edit
book_action_more: More
book_action_save: Save
book_list_edition_pre: Edition
book_list_edition_post: ''
list_page_title: List of books
list_no_books: There are no books in the library
list_no_books_add: Use a link below to add your first book
list_no_books_add_button: Add a book
search_page_title: Search
search_action_search: Search
search_action_reset: Reset
search_results_page_title: Search results
search_no_books: Search returned no results
search_no_books_refine: You may try refining your query
search_no_books_refine_button: Search again
add_page_title: Add a book
add_page_description: Stick the label with ID to the book and fill the form under one of the available options.
add_section_isn_title: Use IS*N
add_section_isn_desc: Add a book by searching the databases for given ISBN or ISSN.
add_action_add_isn: Search and add
add_section_search_title: By searching
add_section_search_desc: Add a book by searching the databases for given title and author.
add_action_add_search: Search and add
add_section_manual_title: Manually
add_section_manual_desc: Add a book by entering all the information manually.
add_action_add_manual: Add manually
add_form_title: Adding a book
add_form_description: Fill in the following form
pagination_first: '<<'
pagination_prev: '<'
pagination_next: '>'
pagination_last: '>>'
book_saved: The book has been saved.
book_id_taken: This ID is already used by another book.
action_rejected: The action could not be completed. Reload the page and try again.
borrow_section_title: Loan
borrow_who_header: Borrower
borrow_at_header: Borrowed on
borrow_till_header: Returned on
borrow_status_lent: Borrowed
borrow_lent_to: Borrowed by %who% since %date%
borrow_action_lend: Lend
borrow_action_return: Mark as returned
borrow_lent: The loan has been recorded.
borrow_returned: The book has been returned.
book_details_title: Details
borrow_history_title: Loan history
borrow_history_empty: This book has not been borrowed yet.
borrow_list_title: Borrowed books
borrow_list_empty: No books are currently borrowed.
cover_section_title: Cover
cover_file_header: Image file (JPEG, PNG or WebP)
cover_action_upload: Upload cover
cover_action_remove: Remove cover
cover_show_original: Show the original image
cover_saved: The cover has been saved.
cover_downloaded: The cover has been downloaded from LibraryThing.
cover_failed: The image could not be processed.
cover_too_large: The file is too large.
label_header: Label
label_type_header: Type
label_type_text: Text
label_type_isbn: ISBN
label_type_issn: ISSN
label_invalid_number: This is not a valid number of the selected type.
label_action_add: Add label
label_action_remove: Remove
ousted_section_title: Take out of the collection
ousted_section_desc: Use this for books given away, lost or destroyed.
ousted_reason_header: Reason
ousted_action_oust: Take out
ousted_action_restore: Return to the collection
ousted_status: Ousted
ousted_done: The book has been taken out of the collection.
ousted_restored: The book is back in the collection.
ousted_list_title: Ousted books
ousted_list_empty: No books have been taken out of the collection.
lookup_page_title: Catalogue search
lookup_no_results: The catalogue has no matching books.
lookup_pick: Pick the matching record to fill in the form.
lookup_use: Use
lookup_failed: The catalogue is unavailable. Try again or add the book manually.
inventory_page_title: Inventory
inventory_input_placeholder: Location, ID, ISBN or ISSN
inventory_input_hint: "A location starts with a letter or contains a slash (e.g. HOL/4/1). A number of up to 8 digits is a book ID; anything else is looked up as an ISBN or ISSN (enter an 8-digit ISSN with its hyphen). Books are assigned to the last entered location."
inventory_action_enter: OK
inventory_action_refresh: Refresh list
inventory_action_sync: Send now
inventory_action_remove: Undo
inventory_action_clear_log: Clear history
inventory_current_location: "Current location:"
inventory_books_count: "Books:"
inventory_pending_count: "Not sent:"
inventory_log_title: Recent scans
inventory_log_empty: Nothing scanned yet.
inventory_online: Online
inventory_offline: Offline
inventory_never: never
inventory_status_pending: Not sent
inventory_status_saved: Saved
inventory_status_returned: Saved, loan closed
inventory_msg_will_return: It was on loan and will be marked as returned.
inventory_status_unknown_book: Unknown book
inventory_status_invalid: Rejected
inventory_confirm_clear: Clear the history? Scans not sent yet are kept.
inventory_msg_location_set: "Location set to %location%."
inventory_msg_no_location: Enter a location first.
inventory_msg_assigned: "%id% %title% → %location%."
inventory_msg_moved: "Previously at %previous%."
inventory_msg_unknown_id: "There is no book with ID %id%. Refresh the list if it was added recently."
inventory_msg_unknown_number: "No book has %number%."
inventory_msg_ambiguous_number: "%number% belongs to several books (%ids%). Enter the ID instead."
inventory_msg_unrecognized: "\"%input%\" is not a location, an ID, a valid ISBN or an ISSN."
inventory_msg_no_books: The book list has not been downloaded yet or there are no books in the system.
inventory_msg_books_loaded: "%count% books downloaded."
inventory_msg_books_failed: The book list could not be downloaded; the saved copy is used.
inventory_msg_synced: "%count% scans sent."
inventory_msg_sync_failed: The server cannot be reached. The scans are kept and will be sent later.
inventory_msg_sync_rejected: "The server rejected %count% scans; see the history."
bulk_oust_page_title: Take out books not seen
bulk_oust_page_desc: After an inventory, take out of the collection every book that was not found. Books never seen during an inventory count as not found; borrowed books are left alone.
bulk_oust_not_seen_since_header: Not seen on or after
bulk_oust_action_preview: Show the books
bulk_oust_action_oust: Take them all out
bulk_oust_found: "%count% books were not seen on or after %date%."
bulk_oust_none: "Every book on the shelves was seen on or after %date%."
bulk_oust_confirm_reason: "Reason: %reason%"
bulk_oust_confirm_desc: They are listed below. Each can later be returned to the collection from its page.
bulk_oust_borrowed_skipped: "%count% borrowed books not seen since then are not included."
bulk_oust_changed: The list of books has changed since it was shown. Check it again.
bulk_oust_done: "%count% books have been taken out of the collection."
translations/messages.pl.yaml
New file
@@ -0,0 +1,176 @@
general_app_name: Biblioteczka
general_app_slogan: zarządzanie księgozbiorem
general_search_placeholder: KOD, ISBN, tytuł, autor
general_search_button: Szukaj
menu_list: Książki
menu_search: Szukaj
menu_add: Dodaj
menu_borrowed: Wypożyczone
menu_ousted: Wycofane
menu_inventory: Inwentaryzacja
book_id_header: KOD
book_title_header: Tytuł
book_author_header: Autor
book_edition_header: Wydanie
book_publisher_header: Wydawca
book_language_header: Język
book_publication_loc_header: Miejsce wydania
book_publication_year_header: Rok wydania
book_isn_header: IS*N
book_isn_description: ISBN lub ISSN
book_isbn_header: ISBN
book_issn_header: ISSN
book_placement_header: Lokalizacja
book_last_seen_header: Ostatnio widziana
book_last_seen_never: jeszcze nie widziana podczas inwentaryzacji
book_action_edit: Modyfikuj
book_action_more: Więcej
book_action_save: Zapisz
book_list_edition_pre: Wydanie
book_list_edition_post: ''
list_page_title: Lista książek
list_no_books: Biblioteka jest pusta
list_no_books_add: Użyj poniższego linku, by dodać pierwszą książkę
list_no_books_add_button: Dodaj książkę
search_page_title: Wyszukiwanie
search_action_search: Szukaj
search_action_reset: Wyczyść
search_results_page_title: Wyniki wyszukiwania
search_no_books: Wyszukiwanie nie zwróciło trafień
search_no_books_refine: Spróbuj zmienić kryteria wyszukiwania
search_no_books_refine_button: Wyszukaj ponownie
add_page_title: Dodaj książkę
add_page_description: Przyklej etykietę z KODEM na książkę, a następnie wybierz jedną z opcji poniżej i uzupełnij wymagane pola.
add_section_isn_title: Używając IS*N
add_section_isn_desc: Dodaj książkę, wyszukując w katalogu podany ISBN lub ISSN.
add_action_add_isn: Wyszukaj i dodaj
add_section_search_title: Wyszukując
add_section_search_desc: Dodaj książkę, wyszukując w katalogu tytuł i autora.
add_action_add_search: Wyszukaj i dodaj
add_section_manual_title: Ręcznie
add_section_manual_desc: Dodaj książkę, wpisując wszystkie dane samodzielnie.
add_action_add_manual: Dodaj
add_form_title: Dodawanie książki
add_form_description: Wypełnij pola w poniższym formularzu
pagination_first: '<<'
pagination_prev: '<'
pagination_next: '>'
pagination_last: '>>'
book_saved: Książka została zapisana.
book_id_taken: Ten KOD jest już przypisany do innej książki.
action_rejected: Nie udało się wykonać operacji. Odśwież stronę i spróbuj ponownie.
borrow_section_title: Wypożyczenie
borrow_who_header: Wypożyczający
borrow_at_header: Data wypożyczenia
borrow_till_header: Data zwrotu
borrow_status_lent: Wypożyczona
borrow_lent_to: 'Wypożyczona przez: %who%, od %date%'
borrow_action_lend: Wypożycz
borrow_action_return: Oznacz jako zwróconą
borrow_lent: Wypożyczenie zostało zapisane.
borrow_returned: Książka została zwrócona.
book_details_title: Szczegóły
borrow_history_title: Historia wypożyczeń
borrow_history_empty: Ta książka nie była jeszcze wypożyczana.
borrow_list_title: Wypożyczone książki
borrow_list_empty: Żadna książka nie jest obecnie wypożyczona.
cover_section_title: Okładka
cover_file_header: Plik graficzny (JPEG, PNG lub WebP)
cover_action_upload: Prześlij okładkę
cover_action_remove: Usuń okładkę
cover_show_original: Pokaż oryginalny obraz
cover_saved: Okładka została zapisana.
cover_downloaded: Okładka została pobrana z LibraryThing.
cover_failed: Nie udało się przetworzyć obrazu.
cover_too_large: Plik jest zbyt duży.
label_header: Etykieta
label_type_header: Rodzaj
label_type_text: Tekst
label_type_isbn: ISBN
label_type_issn: ISSN
label_invalid_number: To nie jest poprawny numer wybranego rodzaju.
label_action_add: Dodaj etykietę
label_action_remove: Usuń
ousted_section_title: Wycofanie z księgozbioru
ousted_section_desc: Dotyczy książek oddanych, zagubionych lub zniszczonych.
ousted_reason_header: Powód
ousted_action_oust: Wycofaj
ousted_action_restore: Przywróć do księgozbioru
ousted_status: Wycofana
ousted_done: Książka została wycofana z księgozbioru.
ousted_restored: Książka wróciła do księgozbioru.
ousted_list_title: Wycofane książki
ousted_list_empty: Żadna książka nie została wycofana.
lookup_page_title: Wyszukiwanie w katalogu
lookup_no_results: W katalogu nie ma pasujących książek.
lookup_pick: Wybierz pasujący rekord, aby uzupełnić formularz.
lookup_use: Wybierz
lookup_failed: Katalog jest niedostępny. Spróbuj ponownie lub dodaj książkę ręcznie.
inventory_page_title: Inwentaryzacja
inventory_input_placeholder: Lokalizacja, KOD, ISBN lub ISSN
inventory_input_hint: "Lokalizacja zaczyna się od litery lub zawiera ukośnik (np. HOL/4/1). Liczba do 8 cyfr to KOD książki, pozostałe wartości są wyszukiwane jako ISBN lub ISSN (8-cyfrowy ISSN należy podać z łącznikiem). Książki trafiają do ostatnio podanej lokalizacji."
inventory_action_enter: OK
inventory_action_refresh: Odśwież listę
inventory_action_sync: Wyślij teraz
inventory_action_remove: Cofnij
inventory_action_clear_log: Wyczyść historię
inventory_current_location: "Bieżąca lokalizacja:"
inventory_books_count: "Książki:"
inventory_pending_count: "Niewysłane:"
inventory_log_title: Ostatnie skany
inventory_log_empty: Nic jeszcze nie zeskanowano.
inventory_online: Połączono
inventory_offline: Brak połączenia
inventory_never: nigdy
inventory_status_pending: Niewysłane
inventory_status_saved: Zapisane
inventory_status_returned: Zapisane, zwrócona
inventory_msg_will_return: Była wypożyczona i zostanie oznaczona jako zwrócona.
inventory_status_unknown_book: Nieznana książka
inventory_status_invalid: Odrzucone
inventory_confirm_clear: Wyczyścić historię? Niewysłane skany zostaną zachowane.
inventory_msg_location_set: "Bieżąca lokalizacja: %location%."
inventory_msg_no_location: Najpierw podaj lokalizację.
inventory_msg_assigned: "%id% %title% → %location%."
inventory_msg_moved: "Poprzednio: %previous%."
inventory_msg_unknown_id: "Nie ma książki o KODZIE %id%. Odśwież listę, jeśli została niedawno dodana."
inventory_msg_unknown_number: "Żadna książka nie ma numeru %number%."
inventory_msg_ambiguous_number: "Numer %number% ma kilka książek (%ids%). Podaj KOD."
inventory_msg_unrecognized: "\"%input%\" nie jest lokalizacją, KODEM, poprawnym ISBN ani ISSN."
inventory_msg_no_books: Lista książek nie została jeszcze pobrana lub brak książek w biblioteczce.
inventory_msg_books_loaded: "Pobrano książek: %count%."
inventory_msg_books_failed: Nie udało się pobrać listy książek; używana jest zapisana kopia.
inventory_msg_synced: "Wysłano skanów: %count%."
inventory_msg_sync_failed: Brak połączenia z serwerem. Skany zostały zachowane i zostaną wysłane później.
inventory_msg_sync_rejected: "Serwer odrzucił skanów: %count%; szczegóły w historii."
bulk_oust_page_title: Wycofaj niewidziane książki
bulk_oust_page_desc: Po inwentaryzacji wycofaj z księgozbioru wszystkie książki, których nie znaleziono. Książki nigdy niewidziane podczas inwentaryzacji liczą się jako nieznalezione; wypożyczone są pomijane.
bulk_oust_not_seen_since_header: Niewidziane od dnia
bulk_oust_action_preview: Pokaż książki
bulk_oust_action_oust: Wycofaj wszystkie
bulk_oust_found: "Książki niewidziane od %date%: %count%."
bulk_oust_none: "Wszystkie książki na półkach widziano od %date%."
bulk_oust_confirm_reason: "Powód: %reason%"
bulk_oust_confirm_desc: Lista jest poniżej. Każdą można później przywrócić do księgozbioru z jej strony.
bulk_oust_borrowed_skipped: "Pominięto wypożyczone książki niewidziane od tego dnia: %count%."
bulk_oust_changed: Lista książek zmieniła się od jej wyświetlenia. Sprawdź ją ponownie.
bulk_oust_done: "Wycofano książek: %count%."