mirror of
https://github.com/ajurna/cbwebreader.git
synced 2026-11-02 10:36:55 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be60657933 | ||
|
|
bb47478511 | ||
|
|
80566e657e | ||
|
|
494b64621e | ||
|
|
a9e9bfb480 | ||
|
|
77a80dd322 | ||
|
|
cc557543c5 | ||
|
|
ce809ed1a9 | ||
|
|
4d0ba750ad | ||
|
|
6bcdf89fdc | ||
|
|
79c0647cc4 | ||
|
|
a7e9bd0743 | ||
|
|
1148f4072b | ||
|
|
147a3a54ef | ||
|
|
9b9db755e6 | ||
|
|
1bbe0071a0 | ||
|
|
91729f91dc | ||
|
|
67b2d9756a | ||
|
|
3ac2f8698c | ||
|
|
9aaa0f1c9f | ||
|
|
65dbe8e18d | ||
|
|
08a3c74a31 | ||
|
|
e5086ec653 | ||
|
|
dd5817419b | ||
|
|
b01eb60eeb | ||
|
|
306b237b01 | ||
|
|
a7cb857c00 | ||
|
|
0adfba1275 | ||
|
|
708df71220 | ||
|
|
871f930727 | ||
|
|
f7c698cd64 | ||
|
|
9e514354c8 |
No files matched your search
+2
-4
@@ -3,16 +3,14 @@
|
||||
|
||||
!cbreader
|
||||
!comic
|
||||
!comic_auth
|
||||
!static
|
||||
!manage.py
|
||||
!pyproject.toml
|
||||
!setup.cfg
|
||||
!uv.lock
|
||||
!entrypoint.sh
|
||||
!entrypoint-cron.sh
|
||||
!requirements.txt
|
||||
!package-lock.json
|
||||
!package.json
|
||||
!frontend
|
||||
/frontend/node_modules
|
||||
/frontend/dist
|
||||
/frontend/dist
|
||||
+5
-3
@@ -15,8 +15,10 @@ DB_DATABASE=cbwebreader
|
||||
# https://github.com/jacobian/dj-database-url
|
||||
DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE}
|
||||
|
||||
#Path to your comics.
|
||||
COMIC_BOOK_VOLUME=/media/plex/comics
|
||||
# Path to your comics *inside* the container. Keep this as a container path.
|
||||
COMIC_BOOK_VOLUME=/comics
|
||||
# Host path that is mounted to the path above.
|
||||
COMIC_BOOK_HOST_PATH=/media/plex/comics
|
||||
|
||||
STATIC_ROOT='/static'
|
||||
|
||||
@@ -32,4 +34,4 @@ MEDIA_ROOT='/media'
|
||||
# DJANGO_RECAPTCHA_PUBLIC_KEY = ''
|
||||
|
||||
# Comment the following if not using a reverse proxy.
|
||||
USE_X_FORWARDED_HOST=True
|
||||
USE_X_FORWARDED_HOST=True
|
||||
+5
-3
@@ -15,8 +15,10 @@ DB_DATABASE=cbwebreader
|
||||
# https://github.com/jacobian/dj-database-url
|
||||
DATABASE_URL='postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_DATABASE}'
|
||||
|
||||
# Path to your comics.
|
||||
COMIC_BOOK_VOLUME=/path/to/comic/folder
|
||||
# Path to your comics *inside* the container. Keep this as a container path.
|
||||
COMIC_BOOK_VOLUME=/comics
|
||||
# Host path that is mounted to the path above (this is the one that varies per machine).
|
||||
COMIC_BOOK_HOST_PATH=/path/to/comic/folder
|
||||
|
||||
# Usually fine tp leave as is.
|
||||
STATIC_ROOT=/static
|
||||
@@ -29,4 +31,4 @@ MEDIA_ROOT='/media'
|
||||
# UNRAR_TOOL = 'unrar.exe'
|
||||
|
||||
# Comment the following if not using a reverse proxy.
|
||||
USE_X_FORWARDED_HOST=True
|
||||
USE_X_FORWARDED_HOST=True
|
||||
@@ -12,25 +12,21 @@ jobs:
|
||||
run: curl -fsSL https://get.docker.com | sh
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: install pip
|
||||
run: apt update && apt install -y python3-pip
|
||||
- uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: "1.6.1"
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v3
|
||||
# Extract version from Poetry
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
# Extract version from the project metadata
|
||||
- name: Get version
|
||||
run: echo "POETRY_VERSION=$(poetry version --short)" >> $GITHUB_ENV
|
||||
run: echo "PROJECT_VERSION=$(uv version --short)" >> $GITHUB_ENV
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker build -t ajurna/cbwebreader:latest -t ajurna/cbwebreader:${{ env.POETRY_VERSION }} .
|
||||
docker push ajurna/cbwebreader:${{ env.POETRY_VERSION }}
|
||||
docker build -t ajurna/cbwebreader:latest -t ajurna/cbwebreader:${{ env.PROJECT_VERSION }} .
|
||||
docker push ajurna/cbwebreader:${{ env.PROJECT_VERSION }}
|
||||
docker push ajurna/cbwebreader:latest
|
||||
+17
@@ -96,3 +96,20 @@ node_modules
|
||||
localhost-key.pem
|
||||
localhost.pem
|
||||
webpack-stats.json
|
||||
|
||||
# Test artifacts
|
||||
.pytest_cache/
|
||||
test_comics/
|
||||
test_media/
|
||||
htmlcov/
|
||||
.coverage
|
||||
|
||||
# Virtual environment created by uv
|
||||
.venv/
|
||||
|
||||
# Tool caches
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Local-only test fixtures (kept on disk, not in git)
|
||||
data
|
||||
+5
-13
@@ -2,22 +2,14 @@
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
- repo: https://github.com/python-poetry/poetry
|
||||
rev: '1.6.1' # add version here
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.17.0
|
||||
hooks:
|
||||
- id: poetry-check
|
||||
- id: poetry-export
|
||||
args: ["--without-hashes", "-o", "requirements.txt"]
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: "5.0.4"
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: [
|
||||
'flake8-annotations==2.9.1',
|
||||
]
|
||||
- id: ruff
|
||||
args: [ --fix ]
|
||||
@@ -1,6 +1,6 @@
|
||||
[MAIN]
|
||||
max-line-length=120
|
||||
ignore-paths=.*/migrations
|
||||
ignore-paths=comic/migrations
|
||||
load-plugins = pylint_django
|
||||
disable = missing-class-docstring,missing-function-docstring,abstract-method,missing-module-docstring,imported-auth-user,missing-docstring
|
||||
good-names=pk
|
||||
@@ -0,0 +1 @@
|
||||
3.14
|
||||
@@ -1,8 +0,0 @@
|
||||
# File: .readthedocs.yaml
|
||||
version: 2
|
||||
|
||||
# Explicitly set the version of Python and its requirements
|
||||
python:
|
||||
version: 3.8
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
+44
-23
@@ -1,36 +1,57 @@
|
||||
FROM python:3.14.0a3-slim-bookworm
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frontend build stage
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-slim AS frontend
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime stage
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM python:3.14-slim-trixie
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
ENV PYTHONFAULTHANDLER=1 \
|
||||
PYTHONHASHSEED=random \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_DEFAULT_TIMEOUT=100 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
RUN mkdir /src
|
||||
RUN mkdir /static
|
||||
ENV UV_PROJECT_ENVIRONMENT=/opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# unrar lives in the non-free component; resolve the codename from the base image
|
||||
# instead of hardcoding it.
|
||||
RUN . /etc/os-release \
|
||||
&& echo "deb http://deb.debian.org/debian ${VERSION_CODENAME} non-free non-free-firmware" \
|
||||
> /etc/apt/sources.list.d/non-free.list \
|
||||
&& apt update \
|
||||
&& apt install -y --no-install-recommends cron unrar \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Lockfiles first so dependency installation is cached independently of the source.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
COPY . /src/
|
||||
COPY --from=frontend /src/dist /src/frontend/dist
|
||||
COPY --from=frontend /src/webpack-stats.json /src/frontend/webpack-stats.json
|
||||
|
||||
RUN echo "deb http://ftp.uk.debian.org/debian bookworm non-free non-free-firmware" > /etc/apt/sources.list.d/non-free.list
|
||||
RUN mkdir -p /static /media
|
||||
|
||||
|
||||
RUN apt update \
|
||||
&& apt install -y npm cron unrar libmariadb-dev libpq-dev pkg-config swig \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install -r requirements.txt \
|
||||
&& cd frontend \
|
||||
&& npm install \
|
||||
&& npm run build \
|
||||
&& apt remove -y npm software-properties-common pkg-config swig \
|
||||
&& rm -r node_modules \
|
||||
&& apt -y auto-remove \
|
||||
&& apt clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN cat /src/cbreader/crontab >> /etc/cron.daily/cbreader
|
||||
RUN cat /src/cbreader/crontab >> /etc/crontab
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" || exit 1
|
||||
|
||||
CMD ["sh", "entrypoint.sh"]
|
||||
+2
-3
@@ -1,4 +1,3 @@
|
||||
poetry export --without-hashes -f requirements.txt --output requirements.txt
|
||||
$version=poetry version -s
|
||||
$version=uv version --short
|
||||
docker build . -t ajurna/cbwebreader:beta --no-cache
|
||||
# docker push ajurna/cbwebreader:beta
|
||||
# docker push ajurna/cbwebreader:beta
|
||||
@@ -1,4 +1,3 @@
|
||||
poetry export --without-hashes -f requirements.txt --output requirements.txt
|
||||
$version=poetry version -s
|
||||
$version=uv version --short
|
||||
docker build . -t ajurna/cbwebreader -t ajurna/cbwebreader:$version
|
||||
docker push ajurna/cbwebreader --all-tags
|
||||
+3
-1
@@ -1 +1,3 @@
|
||||
*/15 * * * * python /src/manage.py scan_comics --settings=cbreader.settings.base
|
||||
# Cron captures job output for e-mail, which is discarded without an MTA, so write to
|
||||
# the cron daemon's own stderr instead - that is what `docker logs` shows.
|
||||
*/15 * * * * root /opt/venv/bin/python /src/manage.py scan_comics --settings=cbreader.settings.base >> /proc/1/fd/2 2>&1
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from urllib.parse import quote
|
||||
|
||||
import dj_database_url
|
||||
from dotenv import load_dotenv
|
||||
@@ -37,15 +38,14 @@ INSTALLED_APPS = [
|
||||
"django.contrib.staticfiles",
|
||||
'drf_yasg',
|
||||
'webpack_loader',
|
||||
'bootstrap4',
|
||||
"comic",
|
||||
'django_extensions',
|
||||
'imagekit',
|
||||
'django_boost',
|
||||
'sri',
|
||||
"corsheaders",
|
||||
'django_filters',
|
||||
'rest_framework',
|
||||
'rest_framework_simplejwt.token_blacklist',
|
||||
# 'silk'
|
||||
]
|
||||
|
||||
@@ -73,8 +73,19 @@ WSGI_APPLICATION = "cbreader.wsgi.application"
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
# docker-compose passes DB_* variables, not a URL. Without building one from them the
|
||||
# app silently falls back to sqlite inside the container, and the web and cron
|
||||
# containers end up with two separate databases.
|
||||
if not DATABASE_URL and os.getenv("DB_HOST"):
|
||||
DATABASE_URL = "postgres://{}:{}@{}/{}".format(
|
||||
quote(os.getenv("DB_USER", "postgres"), safe=""),
|
||||
quote(os.getenv("DB_PASS", ""), safe=""),
|
||||
os.getenv("DB_HOST"),
|
||||
os.getenv("DB_DATABASE", "postgres"),
|
||||
)
|
||||
|
||||
if DATABASE_URL:
|
||||
DATABASES = {"default": dj_database_url.config(conn_max_age=500)}
|
||||
DATABASES = {"default": dj_database_url.parse(DATABASE_URL, conn_max_age=500)}
|
||||
else:
|
||||
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, "db.sqlite3")}}
|
||||
|
||||
@@ -197,8 +208,8 @@ CSP_STYLE_SRC = (
|
||||
)
|
||||
CSP_IMG_SRC = ("'self'", "data:")
|
||||
CSP_FONT_SRC = ("'self'",)
|
||||
CSP_SCRIPT_SRC = ("'self'", "'sha256-IYBrMxCTJ62EwagLTIRncEIpWwTmoXcXkqv3KZm/Wik='")
|
||||
CSP_CONNECT_SRC = ("'self'",)
|
||||
CSP_SCRIPT_SRC = ("'self'", "'unsafe-eval'", "'unsafe-inline'", "localhost:8080")
|
||||
CSP_CONNECT_SRC = ("'self'", "ws://localhost:8080/ws")
|
||||
CSP_INCLUDE_NONCE_IN = ['script-src']
|
||||
CSP_SCRIPT_SRC_ATTR = ("'self'",) # "'unsafe-inline'")
|
||||
|
||||
@@ -230,15 +241,46 @@ REST_FRAMEWORK = {
|
||||
],
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
'comic.auth.CookieJWTAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
)
|
||||
}
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
# Cookie based JWT authentication (see comic/auth.py). Tokens are stored in HttpOnly
|
||||
# cookies so that browser initiated requests, such as the page images in the reader,
|
||||
# can be authenticated - an <img> tag cannot send an Authorization header.
|
||||
JWT_ACCESS_COOKIE_NAME = os.getenv('JWT_ACCESS_COOKIE_NAME', 'access_token')
|
||||
JWT_REFRESH_COOKIE_NAME = os.getenv('JWT_REFRESH_COOKIE_NAME', 'refresh_token')
|
||||
JWT_COOKIE_PATH = os.getenv('JWT_COOKIE_PATH', '/')
|
||||
# Only mark the cookies Secure when the site is actually served over HTTPS, otherwise
|
||||
# browsers drop them on plain http.
|
||||
JWT_COOKIE_SECURE = os.getenv('JWT_COOKIE_SECURE', 'False') == 'True'
|
||||
# Lax blocks cross-site POSTs, which is the CSRF protection for cookie authentication.
|
||||
JWT_COOKIE_SAMESITE = os.getenv('JWT_COOKIE_SAMESITE', 'Lax')
|
||||
|
||||
SIMPLE_JWT = {
|
||||
"ROTATE_REFRESH_TOKENS": True,
|
||||
"BLACKLIST_AFTER_ROTATION": True,
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=10),
|
||||
'LEEWAY': timedelta(minutes=5),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||
'LEEWAY': timedelta(seconds=30),
|
||||
'ALGORITHM': 'HS256',
|
||||
'AUDIENCE': 'cbwebreader-users',
|
||||
'ISSUER': 'cbwebreader',
|
||||
}
|
||||
|
||||
# Renderer formats no longer include a leading dot, so the URL pattern in
|
||||
# cbreader/urls.py matches the dot separately. See cbreader/urls.py.
|
||||
SWAGGER_USE_COMPAT_RENDERERS = False
|
||||
|
||||
# Folder scans run through comic.tasks.scan_directory. The immediate backend runs the task
|
||||
# in the caller, which is enough today; swapping to a queue backend later changes only
|
||||
# this block, not the call sites.
|
||||
TASKS = {
|
||||
'default': {
|
||||
'BACKEND': os.getenv('TASK_BACKEND', 'django.tasks.backends.immediate.ImmediateBackend'),
|
||||
},
|
||||
}
|
||||
|
||||
FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend')
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
upstream cbwebreader_django {
|
||||
server cbwebreader:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
|
||||
listen 80;
|
||||
|
||||
location / {
|
||||
proxy_pass http://cbwebreader_django;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_redirect off;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /static/;
|
||||
}
|
||||
location /media/ {
|
||||
alias /media/;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Settings used by the test suite.
|
||||
|
||||
In-memory SQLite so the tests do not need a running Postgres, and a scratch comic
|
||||
volume that the fixtures fill with generated archives.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cbreader.settings.base import * # noqa: F403
|
||||
from cbreader.settings.base import BASE_DIR
|
||||
|
||||
SECRET_KEY = 'test-secret-key'
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': ':memory:',
|
||||
}
|
||||
}
|
||||
|
||||
COMIC_BOOK_VOLUME = Path(os.environ.get('COMIC_TEST_VOLUME', BASE_DIR / 'test_comics'))
|
||||
MEDIA_ROOT = BASE_DIR / 'test_media'
|
||||
+10
-9
@@ -22,10 +22,10 @@ from django.views.generic import TemplateView
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.views import get_schema_view
|
||||
from rest_framework import permissions
|
||||
from rest_framework_extensions.routers import ExtendedDefaultRouter
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from comic import rest, feeds
|
||||
# from rest_framework_extensions.routers import ExtendedDefaultRouter
|
||||
from comic import auth_views, feeds, rest
|
||||
|
||||
schema_view = get_schema_view(
|
||||
openapi.Info(
|
||||
@@ -39,12 +39,12 @@ schema_view = get_schema_view(
|
||||
permission_classes=[permissions.AllowAny]
|
||||
)
|
||||
|
||||
router = ExtendedDefaultRouter()
|
||||
router = DefaultRouter()
|
||||
router.register(r'users', rest.UserViewSet)
|
||||
router.register(r'browse', rest.BrowseViewSet, basename='browse')
|
||||
router.register(r'generate_thumbnail', rest.GenerateThumbnailViewSet, basename='generate_thumbnail')
|
||||
router.register(r'read', rest.ReadViewSet, basename='read')\
|
||||
.register(r'image', rest.ImageViewSet, basename='image', parents_query_lookups=['selector'])
|
||||
router.register(r'read', rest.ReadViewSet, basename='read')
|
||||
router.register(r'read/(?P<selector>[^/.]+)/image', rest.ImageViewSet, basename='image')
|
||||
router.register(r'recent', rest.RecentComicsView, basename="recent")
|
||||
router.register(r'history', rest.HistoryViewSet, basename='history')
|
||||
router.register(r'action', rest.ActionViewSet, basename='action')
|
||||
@@ -56,11 +56,12 @@ router.register(r'initial_setup', rest.InitialSetup, basename='initial_setup')
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path("feed/<user_selector>/", feeds.RecentComicsAPI()),
|
||||
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
|
||||
re_path(r'^swagger\.(?P<format>json|yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
|
||||
re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
|
||||
re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
|
||||
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
path('api/token/', auth_views.CookieTokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
path('api/token/refresh/', auth_views.CookieTokenRefreshView.as_view(), name='token_refresh'),
|
||||
path('api/token/blacklist/', auth_views.CookieTokenBlacklistView.as_view(), name='token_blacklist'),
|
||||
path('api/', include(router.urls)),
|
||||
path("",
|
||||
TemplateView.as_view(template_name="application.html"),
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Directory, ComicBook, ComicStatus, UserMisc
|
||||
from .models import ComicBook, ComicStatus, Directory, UserMisc
|
||||
|
||||
|
||||
@admin.register(Directory)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Shared helpers for listing the pages inside a comic archive."""
|
||||
|
||||
import mimetypes
|
||||
import zipfile
|
||||
from typing import NamedTuple
|
||||
|
||||
import rarfile
|
||||
|
||||
|
||||
class ArchiveFile(NamedTuple):
|
||||
file_name: str
|
||||
mime_type: str
|
||||
|
||||
|
||||
def get_archive_files(archive: zipfile.ZipFile | rarfile.RarFile) -> list[ArchiveFile]:
|
||||
"""Return the image entries of an archive in page order.
|
||||
|
||||
Metadata entries such as ComicInfo.xml are not pages. Counting them would make
|
||||
page_count disagree with the page list the reader uses, so a book could never
|
||||
be marked as finished.
|
||||
"""
|
||||
result = []
|
||||
for name in sorted(archive.namelist()):
|
||||
if name.endswith('/'):
|
||||
continue
|
||||
mime_type = mimetypes.guess_type(name)[0]
|
||||
if not mime_type or name.lower().endswith('.xml'):
|
||||
continue
|
||||
result.append(ArchiveFile(name, mime_type))
|
||||
return result
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Cookie based JWT authentication.
|
||||
|
||||
Tokens are kept in HttpOnly cookies rather than in JavaScript storage. That is
|
||||
what makes browser initiated requests work: an `<img>` tag cannot send an
|
||||
`Authorization` header, so protected page images can only be served through
|
||||
cookie authentication.
|
||||
|
||||
The views live in comic/auth_views.py: importing rest_framework.views here would
|
||||
resolve DEFAULT_AUTHENTICATION_CLASSES while this module is still being imported.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework_simplejwt.authentication import AUTH_HEADER_TYPES, JWTAuthentication
|
||||
|
||||
|
||||
def access_cookie_name() -> str:
|
||||
return settings.JWT_ACCESS_COOKIE_NAME
|
||||
|
||||
|
||||
def refresh_cookie_name() -> str:
|
||||
return settings.JWT_REFRESH_COOKIE_NAME
|
||||
|
||||
|
||||
def _cookie_options() -> dict[str, Any]:
|
||||
return {
|
||||
'httponly': True,
|
||||
'secure': settings.JWT_COOKIE_SECURE,
|
||||
'samesite': settings.JWT_COOKIE_SAMESITE,
|
||||
'path': settings.JWT_COOKIE_PATH,
|
||||
}
|
||||
|
||||
|
||||
def set_auth_cookies(response: Response, tokens: dict[str, Any]) -> None:
|
||||
"""Write the access and refresh tokens into HttpOnly cookies."""
|
||||
simple_jwt = settings.SIMPLE_JWT
|
||||
for name, token, lifetime in (
|
||||
(access_cookie_name(), tokens.get('access'), simple_jwt['ACCESS_TOKEN_LIFETIME']),
|
||||
(refresh_cookie_name(), tokens.get('refresh'), simple_jwt['REFRESH_TOKEN_LIFETIME']),
|
||||
):
|
||||
if token:
|
||||
response.set_cookie(name, token, max_age=int(lifetime.total_seconds()), **_cookie_options())
|
||||
|
||||
|
||||
def clear_auth_cookies(response: Response) -> None:
|
||||
for name in (access_cookie_name(), refresh_cookie_name()):
|
||||
response.delete_cookie(name, path=settings.JWT_COOKIE_PATH)
|
||||
|
||||
|
||||
class CookieJWTAuthentication(JWTAuthentication):
|
||||
"""Authenticate from the Authorization header, or from the access cookie.
|
||||
|
||||
Keeping the header path means API clients that send a bearer token still work.
|
||||
"""
|
||||
|
||||
def get_header(self, request: Request) -> bytes | None:
|
||||
header = super().get_header(request)
|
||||
if header:
|
||||
return header
|
||||
token = request.COOKIES.get(access_cookie_name())
|
||||
if not token:
|
||||
return None
|
||||
return f'{AUTH_HEADER_TYPES[0]} {token}'.encode()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Token views that store JWTs in HttpOnly cookies (see comic/auth.py)."""
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework_simplejwt.exceptions import TokenError
|
||||
from rest_framework_simplejwt.serializers import TokenBlacklistSerializer, TokenRefreshSerializer
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||
|
||||
from comic.auth import clear_auth_cookies, refresh_cookie_name, set_auth_cookies
|
||||
|
||||
|
||||
def _refresh_value(request: Request) -> str | None:
|
||||
"""The refresh token from the request body, or from the refresh cookie."""
|
||||
value = request.data.get('refresh') if isinstance(request.data, dict) else None
|
||||
return value or request.COOKIES.get(refresh_cookie_name())
|
||||
|
||||
|
||||
class CookieTokenObtainPairView(TokenObtainPairView):
|
||||
"""Log in: issue tokens and store them in HttpOnly cookies."""
|
||||
|
||||
def post(self, request: Request, *args: object, **kwargs: object) -> Response:
|
||||
response = super().post(request, *args, **kwargs)
|
||||
if response.status_code == status.HTTP_200_OK and isinstance(response.data, dict):
|
||||
set_auth_cookies(response, response.data)
|
||||
# The tokens live in HttpOnly cookies, so they are not returned to JavaScript.
|
||||
response.data = {}
|
||||
return response
|
||||
|
||||
|
||||
class CookieTokenRefreshView(TokenObtainPairView):
|
||||
"""Refresh using the refresh cookie and update both cookies."""
|
||||
|
||||
serializer_class = TokenRefreshSerializer
|
||||
|
||||
def post(self, request: Request, *args: object, **kwargs: object) -> Response:
|
||||
refresh = _refresh_value(request)
|
||||
if not refresh:
|
||||
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'errors': 'No refresh token.'})
|
||||
|
||||
serializer = self.get_serializer(data={'refresh': refresh})
|
||||
try:
|
||||
serializer.is_valid(raise_exception=True)
|
||||
except TokenError as exc:
|
||||
raise AuthenticationFailed(exc.args[0]) from exc
|
||||
|
||||
response = Response(status=status.HTTP_200_OK)
|
||||
set_auth_cookies(response, serializer.validated_data)
|
||||
# Keep the cookies out of the body so JavaScript never sees the tokens.
|
||||
return response
|
||||
|
||||
|
||||
class CookieTokenBlacklistView(TokenObtainPairView):
|
||||
"""Log out: blacklist the refresh cookie and clear both cookies."""
|
||||
|
||||
serializer_class = TokenBlacklistSerializer
|
||||
|
||||
def post(self, request: Request, *args: object, **kwargs: object) -> Response:
|
||||
refresh = _refresh_value(request)
|
||||
if not refresh:
|
||||
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'errors': 'No refresh token.'})
|
||||
|
||||
serializer = self.get_serializer(data={'refresh': refresh})
|
||||
try:
|
||||
serializer.is_valid(raise_exception=True)
|
||||
except TokenError as exc:
|
||||
raise AuthenticationFailed(exc.args[0]) from exc
|
||||
|
||||
response = Response(status=status.HTTP_204_NO_CONTENT)
|
||||
clear_auth_cookies(response)
|
||||
return response
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.syndication.views import Feed
|
||||
from django.db.models import Case, When, PositiveSmallIntegerField, F, QuerySet
|
||||
from django.db.models import Case, F, PositiveSmallIntegerField, QuerySet, When
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from comic.models import ComicBook, UserMisc, Directory
|
||||
from comic.models import ComicBook, Directory, UserMisc
|
||||
|
||||
|
||||
class RecentComicsAPI(Feed):
|
||||
|
||||
Whitespace-only changes.
@@ -1,12 +1,11 @@
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandParser
|
||||
from loguru import logger
|
||||
|
||||
from comic.models import Directory
|
||||
from comic.processing import generate_directory
|
||||
from comic.models import ComicBook, Directory
|
||||
from comic.processing import reconcile
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -22,16 +21,25 @@ class Command(BaseCommand):
|
||||
action='store_true',
|
||||
help='Output to console',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--if-empty',
|
||||
action='store_true',
|
||||
help='Do nothing when the database already contains books, so it is safe to run on startup.',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
self.OUTPUT = options.get('out', False)
|
||||
if options.get('if_empty') and ComicBook.objects.exists():
|
||||
logger.info('The database already contains books, so the startup scan is skipped.')
|
||||
return
|
||||
self.scan_directory()
|
||||
|
||||
def scan_directory(self, user: Optional[AbstractBaseUser] = None, directory: Optional[Directory] = None) -> None:
|
||||
if not user:
|
||||
user_model = get_user_model()
|
||||
user: AbstractBaseUser = user_model.objects.first()
|
||||
for item in generate_directory(user, directory):
|
||||
if item is Directory:
|
||||
logger.info(item)
|
||||
self.scan_directory(user, item)
|
||||
def scan_directory(self, directory: Directory | None = None) -> None:
|
||||
reconcile(directory)
|
||||
path = Path(settings.COMIC_BOOK_VOLUME, directory.path) if directory else settings.COMIC_BOOK_VOLUME
|
||||
if self.OUTPUT:
|
||||
self.stdout.write(str(path))
|
||||
else:
|
||||
logger.info(path)
|
||||
for child in Directory.objects.filter(parent=directory):
|
||||
self.scan_directory(child)
|
||||
@@ -7,8 +7,7 @@ import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.utils.timezone import utc
|
||||
|
||||
utc = datetime.timezone.utc
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 6.1.2 on 2026-10-13 12:51
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def merge_duplicate_directories(apps, schema_editor) -> None:
|
||||
"""Scans could insert the same directory twice, so fold duplicates together
|
||||
before the unique constraint is added, otherwise the migration fails on data
|
||||
the bug has already created."""
|
||||
Directory = apps.get_model('comic', 'Directory')
|
||||
ComicBook = apps.get_model('comic', 'ComicBook')
|
||||
|
||||
groups = defaultdict(list)
|
||||
for directory in Directory.objects.order_by('id'):
|
||||
groups[(directory.parent_id, directory.name)].append(directory)
|
||||
|
||||
for group in groups.values():
|
||||
keeper = group[0]
|
||||
for duplicate in group[1:]:
|
||||
ComicBook.objects.filter(directory=duplicate).update(directory=keeper)
|
||||
duplicate.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0050_delete_comicpage'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(merge_duplicate_directories, migrations.RunPython.noop),
|
||||
migrations.AddConstraint(
|
||||
model_name='directory',
|
||||
constraint=models.UniqueConstraint(fields=('parent', 'name'), name='one_directory_name_per_parent'),
|
||||
),
|
||||
]
|
||||
+53
-43
@@ -1,16 +1,13 @@
|
||||
import io
|
||||
import mimetypes
|
||||
import uuid
|
||||
import zipfile
|
||||
from functools import reduce
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Union, Tuple, Final, IO
|
||||
from typing import IO, Final
|
||||
|
||||
# noinspection PyPackageRequirements
|
||||
import fitz
|
||||
import pymupdf
|
||||
import rarfile
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL.Image import Image as Image_type
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile
|
||||
@@ -19,7 +16,10 @@ from django.db.models import UniqueConstraint
|
||||
from django_boost.models.fields import AutoOneToOneField
|
||||
from imagekit.models import ProcessedImageField
|
||||
from imagekit.processors import ResizeToFill
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL.Image import Image as Image_type
|
||||
|
||||
from comic.archives import get_archive_files
|
||||
from comic.errors import NotCompatibleArchive
|
||||
|
||||
if settings.UNRAR_TOOL:
|
||||
@@ -50,9 +50,13 @@ class Directory(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['parent', 'name'], name='one_directory_name_per_parent'),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Directory: {self.name}; {self.parent}"
|
||||
|
||||
return f"Directory: {self.name}: {self.parent}"
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
@@ -63,7 +67,7 @@ class Directory(models.Model):
|
||||
return 'Directory'
|
||||
|
||||
def generate_thumbnail(self) -> None:
|
||||
book: ComicBook = ComicBook.objects.filter(directory=self).order_by('file_name').first()
|
||||
book: ComicBook|None = ComicBook.objects.filter(directory=self).order_by('file_name').first()
|
||||
if not book:
|
||||
return
|
||||
if not book.thumbnail:
|
||||
@@ -83,7 +87,7 @@ class Directory(models.Model):
|
||||
return reduce(lambda x, y: Path(x, y), path_items)
|
||||
return Path(path_items[0])
|
||||
|
||||
def get_path_items(self, path_items: Optional[List] = None) -> List[Path]:
|
||||
def get_path_items(self, path_items: list | None = None) -> list[Path]:
|
||||
if path_items is None:
|
||||
path_items = []
|
||||
path_items.append(self.name)
|
||||
@@ -91,7 +95,7 @@ class Directory(models.Model):
|
||||
self.parent.get_path_items(path_items)
|
||||
return path_items
|
||||
|
||||
def get_path_objects(self, path_items: Optional[List] = None) -> List["Directory"]:
|
||||
def get_path_objects(self, path_items: list | None = None) -> list[Directory]:
|
||||
if path_items is None:
|
||||
path_items = []
|
||||
path_items.append(self)
|
||||
@@ -140,29 +144,43 @@ class ComicBook(models.Model):
|
||||
return Path(base_dir, self.directory.get_path(), self.file_name)
|
||||
return Path(base_dir, self.file_name)
|
||||
|
||||
def get_image(self, page: int) -> Union[Tuple[IO[bytes], str], Tuple[bool, bool]]:
|
||||
base_dir = settings.COMIC_BOOK_VOLUME
|
||||
if self.directory:
|
||||
archive_path = Path(base_dir, self.directory.path, self.file_name)
|
||||
def get_image(self, page: int) -> tuple[IO[bytes], str] | tuple[bool, bool]:
|
||||
if self.file_name.lower().endswith('.pdf'):
|
||||
# noinspection PyUnresolvedReferences
|
||||
doc = pymupdf.open(self.get_pdf())
|
||||
page: pymupdf.Page = doc[page]
|
||||
pix = page.get_pixmap()
|
||||
mode: Final = "RGBA" if pix.alpha else "RGB"
|
||||
# noinspection PyTypeChecker
|
||||
pil_data = Image.frombytes(mode, (pix.width, pix.height), pix.samples)
|
||||
img = io.BytesIO()
|
||||
pil_data.save(img, format="PNG")
|
||||
img.seek(0)
|
||||
return img, "Image/PNG"
|
||||
else:
|
||||
archive_path = Path(base_dir, self.file_name)
|
||||
try:
|
||||
archive = rarfile.RarFile(archive_path)
|
||||
except rarfile.NotRarFile:
|
||||
# pylint: disable=consider-using-with
|
||||
archive = zipfile.ZipFile(archive_path)
|
||||
except zipfile.BadZipfile:
|
||||
return False, False
|
||||
base_dir = settings.COMIC_BOOK_VOLUME
|
||||
if self.directory:
|
||||
archive_path = Path(base_dir, self.directory.path, self.file_name)
|
||||
else:
|
||||
archive_path = Path(base_dir, self.file_name)
|
||||
try:
|
||||
archive = rarfile.RarFile(archive_path)
|
||||
except rarfile.NotRarFile:
|
||||
# pylint: disable=consider-using-with
|
||||
archive = zipfile.ZipFile(archive_path)
|
||||
except zipfile.BadZipfile:
|
||||
return False, False
|
||||
|
||||
file_name, file_mime = self.get_archive_files(archive)[page]
|
||||
return archive.open(file_name), file_mime
|
||||
file_name, file_mime = get_archive_files(archive)[page]
|
||||
return archive.open(file_name), file_mime
|
||||
|
||||
def generate_thumbnail_pdf(self, page_index: int = 0) -> Tuple[io.BytesIO, Image_type, str]:
|
||||
def generate_thumbnail_pdf(self, page_index: int = 0) -> tuple[io.BytesIO, Image_type, str]:
|
||||
img, pil_data = self._get_pdf_image(page_index if page_index else 0)
|
||||
return img, pil_data, 'Image/JPEG'
|
||||
|
||||
def generate_thumbnail_archive(self, page_index: int = 0) -> Union[Tuple[io.BytesIO, Image_type, str],
|
||||
Tuple[bool, bool, bool]]:
|
||||
def generate_thumbnail_archive(
|
||||
self, page_index: int = 0
|
||||
) -> tuple[io.BytesIO, Image_type, str] | tuple[bool, bool, bool]:
|
||||
img, content_type, pil_data = False, False, False
|
||||
if page_index:
|
||||
img, content_type = self.get_image(page_index)
|
||||
@@ -195,9 +213,8 @@ class ComicBook(models.Model):
|
||||
)
|
||||
self.save()
|
||||
|
||||
def _get_pdf_image(self, page_index: int) -> Tuple[io.BytesIO, Image_type]:
|
||||
# noinspection PyUnresolvedReferences
|
||||
doc = fitz.open(self.get_pdf())
|
||||
def _get_pdf_image(self, page_index: int) -> tuple[io.BytesIO, Image_type]:
|
||||
doc = pymupdf.open(self.get_pdf())
|
||||
page = doc[page_index]
|
||||
pix = page.get_pixmap()
|
||||
mode: Final = "RGBA" if pix.alpha else "RGB"
|
||||
@@ -209,7 +226,7 @@ class ComicBook(models.Model):
|
||||
return img, pil_data
|
||||
|
||||
@staticmethod
|
||||
def process_comic_book(comic_file_path: Path, directory: "Directory" = False) -> Union["ComicBook", Path]:
|
||||
def process_comic_book(comic_file_path: Path, directory: Directory = False) -> ComicBook | Path:
|
||||
try:
|
||||
book = ComicBook.objects.get(file_name=comic_file_path.name, version=0)
|
||||
book.directory = directory
|
||||
@@ -227,7 +244,7 @@ class ComicBook(models.Model):
|
||||
return comic_file_path
|
||||
|
||||
if archive_type == 'archive':
|
||||
book.page_count = len(book.get_archive_files(archive))
|
||||
book.page_count = len(get_archive_files(archive))
|
||||
elif archive_type == 'pdf':
|
||||
book.page_count = archive.page_count
|
||||
|
||||
@@ -239,7 +256,7 @@ class ComicBook(models.Model):
|
||||
return Path(settings.COMIC_BOOK_VOLUME, self.directory.get_path(), self.file_name)
|
||||
return Path(settings.COMIC_BOOK_VOLUME, self.file_name)
|
||||
|
||||
def get_archive(self) -> Tuple[Union[rarfile.RarFile, zipfile.ZipFile, fitz.Document], str]:
|
||||
def get_archive(self) -> tuple[rarfile.RarFile | zipfile.ZipFile | pymupdf.Document, str]:
|
||||
archive_path = self.get_archive_path
|
||||
try:
|
||||
return rarfile.RarFile(archive_path), 'archive'
|
||||
@@ -252,7 +269,7 @@ class ComicBook(models.Model):
|
||||
|
||||
try:
|
||||
# noinspection PyUnresolvedReferences
|
||||
return fitz.open(str(archive_path)), 'pdf'
|
||||
return pymupdf.open(str(archive_path)), 'pdf'
|
||||
except RuntimeError:
|
||||
pass
|
||||
raise NotCompatibleArchive
|
||||
@@ -260,17 +277,10 @@ class ComicBook(models.Model):
|
||||
def get_page_count(self) -> int:
|
||||
archive, archive_type = self.get_archive()
|
||||
if archive_type == 'archive':
|
||||
return len(self.get_archive_files(archive))
|
||||
return len(get_archive_files(archive))
|
||||
elif archive_type == 'pdf':
|
||||
return archive.page_count
|
||||
|
||||
@staticmethod
|
||||
def get_archive_files(archive: Union[zipfile.ZipFile, rarfile.RarFile]) -> List[Tuple[str, str]]:
|
||||
return [
|
||||
(x, mimetypes.guess_type(x)[0]) for x in sorted(archive.namelist())
|
||||
if not x.endswith('/') and mimetypes.guess_type(x)[0] and not x.endswith('xml')
|
||||
]
|
||||
|
||||
|
||||
class ComicStatus(models.Model):
|
||||
user = models.ForeignKey(User, unique=False, null=False, on_delete=models.CASCADE)
|
||||
@@ -291,8 +301,8 @@ class ComicStatus(models.Model):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<ComicStatus:{self.user.username}:{self.comic.file_name}:{self.last_read_page}:"
|
||||
f"{self.unread}:{self.finished}"
|
||||
f"<ComicStatus: {self.user.username}: {self.comic.file_name}: {self.last_read_page}: "
|
||||
f"{self.unread}: {self.finished}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
+58
-59
@@ -1,49 +1,59 @@
|
||||
import mimetypes
|
||||
import zipfile
|
||||
from itertools import chain
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple, List, Optional, Union
|
||||
|
||||
import rarfile
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.db.models import Count, Q, F, Case, When, PositiveSmallIntegerField, QuerySet, ExpressionWrapper, \
|
||||
IntegerField
|
||||
from django.db.models import (
|
||||
BooleanField,
|
||||
Case,
|
||||
Count,
|
||||
ExpressionWrapper,
|
||||
F,
|
||||
IntegerField,
|
||||
PositiveSmallIntegerField,
|
||||
Q,
|
||||
QuerySet,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from comic import models
|
||||
from comic.archives import get_archive_files
|
||||
from comic.errors import NotCompatibleArchive
|
||||
|
||||
|
||||
def generate_directory(user: AbstractBaseUser, directory: Optional[models.Directory] = None) \
|
||||
-> List[Union[models.Directory, models.ComicBook]]:
|
||||
def reconcile(directory: models.Directory | None = None) -> None:
|
||||
"""Bring the database in line with the filesystem for one directory.
|
||||
|
||||
This is the only place the scan writes, so it runs from the scan task and the
|
||||
management command - never from a browse request.
|
||||
"""
|
||||
dir_path = Path(settings.COMIC_BOOK_VOLUME, directory.path) if directory else settings.COMIC_BOOK_VOLUME
|
||||
files = []
|
||||
|
||||
dir_db_query = models.Directory.objects.filter(parent=directory)
|
||||
clean_directories(dir_db_query, dir_path, directory)
|
||||
clean_directories(models.Directory.objects.filter(parent=directory), dir_path, directory)
|
||||
clean_files(models.ComicBook.objects.filter(directory=directory), dir_path, directory)
|
||||
clean_thumbnails(models.Directory.objects.filter(parent=directory))
|
||||
clean_thumbnails(models.ComicBook.objects.filter(directory=directory))
|
||||
|
||||
file_db_query = models.ComicBook.objects.filter(directory=directory)
|
||||
clean_files(file_db_query, user, dir_path, directory)
|
||||
|
||||
dir_db_query = dir_db_query.annotate(
|
||||
def list_directory(user: AbstractBaseUser, directory: models.Directory | None = None) \
|
||||
-> list[models.Directory | models.ComicBook]:
|
||||
"""Return the contents of one directory for one user, reading only."""
|
||||
dir_db_query = models.Directory.objects.filter(parent=directory).annotate(
|
||||
total=Count('comicbook', distinct=True),
|
||||
progress=Count('comicbook__comicstatus', Q(comicbook__comicstatus__finished=True,
|
||||
comicbook__comicstatus__user=user), distinct=True),
|
||||
finished=ExpressionWrapper(Q(total=F('progress')), output_field=IntegerField()),
|
||||
unread=ExpressionWrapper(Q(total__gt=F('progress')), output_field=IntegerField()),
|
||||
)
|
||||
files.extend(dir_db_query)
|
||||
|
||||
# Create Missing Status
|
||||
new_status = [models.ComicStatus(comic=file, user=user) for file in
|
||||
file_db_query.exclude(comicstatus__in=models.ComicStatus.objects.filter(
|
||||
comic__in=file_db_query, user=user))]
|
||||
models.ComicStatus.objects.bulk_create(new_status)
|
||||
|
||||
file_db_query = file_db_query.annotate(
|
||||
progress=F('comicstatus__last_read_page') + 1,
|
||||
finished=F('comicstatus__finished'),
|
||||
unread=F('comicstatus__unread'),
|
||||
# A book with no status for this user is simply unread, so the annotations have to
|
||||
# stand in for the missing row instead of assuming one exists.
|
||||
file_db_query = models.ComicBook.objects.filter(directory=directory).annotate(
|
||||
progress=Coalesce(F('comicstatus__last_read_page') + 1, Value(0, output_field=IntegerField())),
|
||||
finished=Coalesce(F('comicstatus__finished'), Value(False, output_field=BooleanField())),
|
||||
unread=Coalesce(F('comicstatus__unread'), Value(True, output_field=BooleanField())),
|
||||
user=F('comicstatus__user'),
|
||||
classification=Case(
|
||||
When(directory__isnull=True, then=models.Directory.Classification.C_G),
|
||||
@@ -52,68 +62,57 @@ def generate_directory(user: AbstractBaseUser, directory: Optional[models.Direct
|
||||
)
|
||||
).filter(Q(user__isnull=True) | Q(user=user.id))
|
||||
|
||||
files.extend(file_db_query)
|
||||
|
||||
for file in chain(file_db_query, dir_db_query):
|
||||
if file.thumbnail and not Path(file.thumbnail.path).exists():
|
||||
file.thumbnail.delete()
|
||||
file.save()
|
||||
files = list(dir_db_query) + list(file_db_query)
|
||||
files.sort(key=lambda x: x.title)
|
||||
files.sort(key=lambda x: x.type, reverse=True)
|
||||
return files
|
||||
|
||||
|
||||
def clean_directories(directories: QuerySet, dir_path: Path, directory: Optional[models.Directory] = None) -> None:
|
||||
dir_db_set = set(Path(settings.COMIC_BOOK_VOLUME, x.path) for x in directories)
|
||||
dir_list = set(x for x in sorted(dir_path.glob('*')) if x.is_dir())
|
||||
def clean_directories(directories: QuerySet, dir_path: Path, directory: models.Directory | None = None) -> None:
|
||||
dir_db_set = {Path(settings.COMIC_BOOK_VOLUME, x.path) for x in directories}
|
||||
dir_list = {x for x in sorted(dir_path.glob('*')) if x.is_dir()}
|
||||
# Create new directories db instances
|
||||
for new_directory in dir_list - dir_db_set:
|
||||
models.Directory(name=new_directory.name, parent=directory).save()
|
||||
# get_or_create, not save(): another request may have inserted the same directory
|
||||
# between the query above and this line, which would leave duplicate rows.
|
||||
models.Directory.objects.get_or_create(name=new_directory.name, parent=directory)
|
||||
|
||||
# Remove stale db instances
|
||||
for stale_directory in dir_db_set - dir_list:
|
||||
models.Directory.objects.get(name=stale_directory.name, parent=directory).delete()
|
||||
models.Directory.objects.filter(name=stale_directory.name, parent=directory).delete()
|
||||
|
||||
|
||||
def clean_files(files: QuerySet, user: AbstractBaseUser, dir_path: Path, directory: Optional[models.Directory] = None) \
|
||||
-> None:
|
||||
file_list = set(x for x in sorted(dir_path.glob('*')) if x.is_file())
|
||||
files_db_set = set(Path(dir_path, x.file_name) for x in files)
|
||||
def clean_files(files: QuerySet, dir_path: Path, directory: models.Directory | None = None) -> None:
|
||||
file_list = {x for x in sorted(dir_path.glob('*')) if x.is_file()}
|
||||
files_db_set = {Path(dir_path, x.file_name) for x in files}
|
||||
|
||||
# Parse new comics
|
||||
books_to_add = []
|
||||
for new_comic in file_list - files_db_set:
|
||||
if new_comic.suffix.lower() in settings.SUPPORTED_FILES:
|
||||
new_book = models.ComicBook(file_name=new_comic.name, directory=directory)
|
||||
archive, archive_type = new_book.get_archive()
|
||||
try:
|
||||
archive, archive_type = new_book.get_archive()
|
||||
if archive_type == 'archive':
|
||||
new_book.page_count = len(get_archive_files(archive))
|
||||
elif archive_type == 'pdf':
|
||||
new_book.page_count = archive.page_count
|
||||
except NotCompatibleArchive:
|
||||
# Unreadable/corrupt archive - index the file with no page count
|
||||
pass
|
||||
books_to_add.append(new_book)
|
||||
models.ComicBook.objects.bulk_create(books_to_add)
|
||||
|
||||
status_to_add = []
|
||||
for book in books_to_add:
|
||||
status_to_add.append(models.ComicStatus(user=user, comic=book))
|
||||
|
||||
models.ComicStatus.objects.bulk_create(status_to_add)
|
||||
# ignore_conflicts: a concurrent scan may have indexed the same file, and
|
||||
# (directory, file_name) is unique.
|
||||
models.ComicBook.objects.bulk_create(books_to_add, ignore_conflicts=True)
|
||||
|
||||
# Remove stale comic instances
|
||||
for stale_comic in files_db_set - file_list:
|
||||
models.ComicBook.objects.get(file_name=stale_comic.name, directory=directory).delete()
|
||||
models.ComicBook.objects.filter(file_name=stale_comic.name, directory=directory).delete()
|
||||
|
||||
|
||||
class ArchiveFile(NamedTuple):
|
||||
file_name: str
|
||||
mime_type: str
|
||||
|
||||
|
||||
def get_archive_files(archive: Union[zipfile.ZipFile, rarfile.RarFile]) -> List[ArchiveFile]:
|
||||
return [
|
||||
ArchiveFile(x, mimetypes.guess_type(x)[0]) for x in sorted(archive.namelist())
|
||||
if not x.endswith('/') and mimetypes.guess_type(x)[0]
|
||||
]
|
||||
def clean_thumbnails(items: Iterable[models.Directory | models.ComicBook]) -> None:
|
||||
for item in items:
|
||||
if item.thumbnail and not Path(item.thumbnail.path).exists():
|
||||
item.thumbnail.delete()
|
||||
item.save()
|
||||
+128
-92
@@ -1,26 +1,39 @@
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from http.client import HTTPResponse
|
||||
from pathlib import Path
|
||||
from typing import Union, Optional, Dict, Iterable, List
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Case, When, F, PositiveSmallIntegerField, FileField, QuerySet
|
||||
from django.db.models import Case, F, FileField, PositiveSmallIntegerField, QuerySet, When
|
||||
from django.http import FileResponse
|
||||
from django.utils.crypto import get_random_string
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework import viewsets, serializers, mixins, permissions, status, renderers
|
||||
from rest_framework import mixins, permissions, renderers, serializers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from comic import models
|
||||
from comic.processing import generate_directory
|
||||
from comic import models, processing, tasks
|
||||
from comic.util import generate_breadcrumbs_from_path
|
||||
|
||||
|
||||
def user_can_read(user: models.User, directory: models.Directory | None) -> bool:
|
||||
"""Whether the user's classification allowance covers this book.
|
||||
|
||||
Books that are not inside a directory are treated as the highest rating, which
|
||||
matches how the browse API reports them.
|
||||
"""
|
||||
misc, _ = models.UserMisc.objects.get_or_create(user=user)
|
||||
classification = directory.classification if directory else models.Directory.Classification.C_18
|
||||
return classification <= misc.allowed_to_read
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
classification = serializers.SlugRelatedField(many=False, read_only=True, slug_field='allowed_to_read',
|
||||
source='usermisc')
|
||||
@@ -59,16 +72,15 @@ class UserViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
|
||||
"""
|
||||
target_user = get_object_or_404(User, id=pk)
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
if target_user.username == serializer.data['username']:
|
||||
password = User.objects.make_random_password()
|
||||
target_user.set_password(password)
|
||||
target_user.save()
|
||||
resp_serializer = self.get_serializer({
|
||||
'username': target_user.username,
|
||||
'password': password
|
||||
})
|
||||
return Response(resp_serializer.data)
|
||||
if serializer.is_valid() and target_user.username == serializer.data['username']:
|
||||
password = get_random_string(12)
|
||||
target_user.set_password(password)
|
||||
target_user.save()
|
||||
resp_serializer = self.get_serializer({
|
||||
'username': target_user.username,
|
||||
'password': password
|
||||
})
|
||||
return Response(resp_serializer.data)
|
||||
|
||||
return Response({'errors': ['Invalid request']}, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -87,7 +99,7 @@ class UserViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
|
||||
|
||||
|
||||
class BrowseFileField(serializers.FileField):
|
||||
def to_representation(self, value: Optional[FileField]) -> Optional[str]:
|
||||
def to_representation(self, value: FileField | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
return Path(settings.MEDIA_URL, value.name).as_posix()
|
||||
@@ -111,34 +123,47 @@ class BreadcrumbSerializer(serializers.Serializer):
|
||||
name = serializers.CharField()
|
||||
|
||||
|
||||
class ScanSerializer(serializers.Serializer):
|
||||
selector = serializers.UUIDField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class BrowseViewSet(viewsets.GenericViewSet):
|
||||
serializer_class = BrowseSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
lookup_field = 'selector'
|
||||
|
||||
def get_queryset(self):
|
||||
def get_queryset(self) -> None:
|
||||
return
|
||||
|
||||
def list(self, request: Request) -> Response:
|
||||
serializer = self.get_serializer(generate_directory(request.user), many=True)
|
||||
serializer = self.get_serializer(processing.list_directory(request.user), many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: BrowseSerializer(many=True)})
|
||||
def retrieve(self, request: Request, selector: UUID) -> Response:
|
||||
directory = models.Directory.objects.get(selector=selector)
|
||||
serializer = self.get_serializer(generate_directory(request.user, directory), many=True)
|
||||
directory = get_object_or_404(models.Directory, selector=selector)
|
||||
serializer = self.get_serializer(processing.list_directory(request.user, directory), many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(request_body=ScanSerializer, responses={status.HTTP_200_OK: BrowseSerializer(many=True)})
|
||||
@action(methods=['post'], detail=False, serializer_class=ScanSerializer)
|
||||
def scan(self, request: Request) -> Response:
|
||||
"""Read one directory from disk, then return it. Null selector means the root."""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
selector = serializer.validated_data.get('selector')
|
||||
tasks.scan_directory.enqueue(str(selector) if selector else None)
|
||||
directory = models.Directory.objects.filter(selector=selector).first() if selector else None
|
||||
data = processing.list_directory(request.user, directory)
|
||||
return Response(BrowseSerializer(data, many=True).data)
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: BreadcrumbSerializer(many=True)})
|
||||
@action(methods=['get'], detail=True, serializer_class=BreadcrumbSerializer)
|
||||
def breadcrumbs(self, _request: Request, selector: UUID) -> Response:
|
||||
queryset = []
|
||||
comic = False
|
||||
try:
|
||||
directory = models.Directory.objects.get(selector=selector)
|
||||
except models.Directory.DoesNotExist:
|
||||
comic = models.ComicBook.objects.get(selector=selector)
|
||||
directory = comic.directory
|
||||
comic = models.ComicBook.objects.filter(selector=selector).first()
|
||||
directory = comic.directory if comic else get_object_or_404(models.Directory, selector=selector)
|
||||
|
||||
for index, item in enumerate(generate_breadcrumbs_from_path(directory, comic)):
|
||||
queryset.append({
|
||||
@@ -163,25 +188,17 @@ class GenerateThumbnailViewSet(viewsets.ViewSet):
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: GenerateThumbnailSerializer()})
|
||||
def retrieve(self, _request: Request, selector: UUID) -> Response:
|
||||
try:
|
||||
directory = models.Directory.objects.get(selector=selector)
|
||||
if not directory.thumbnail:
|
||||
directory.generate_thumbnail()
|
||||
return Response(
|
||||
self.serializer_class({
|
||||
"selector": directory.selector,
|
||||
"thumbnail": directory.thumbnail
|
||||
}).data
|
||||
)
|
||||
target = models.Directory.objects.get(selector=selector)
|
||||
except models.Directory.DoesNotExist:
|
||||
comic = models.ComicBook.objects.get(selector=selector)
|
||||
if not comic.thumbnail:
|
||||
comic.generate_thumbnail()
|
||||
return Response(
|
||||
self.serializer_class({
|
||||
"selector": comic.selector,
|
||||
"thumbnail": comic.thumbnail
|
||||
}).data
|
||||
)
|
||||
target = get_object_or_404(models.ComicBook, selector=selector)
|
||||
if not target.thumbnail:
|
||||
target.generate_thumbnail()
|
||||
return Response(
|
||||
self.serializer_class({
|
||||
"selector": target.selector,
|
||||
"thumbnail": target.thumbnail
|
||||
}).data
|
||||
)
|
||||
|
||||
|
||||
class DirectionSerializer(serializers.Serializer):
|
||||
@@ -211,9 +228,12 @@ class ReadViewSet(viewsets.GenericViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
lookup_field = 'selector'
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadSerializer()})
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadSerializer(),
|
||||
status.HTTP_403_FORBIDDEN: 'User below classification allowed'})
|
||||
def retrieve(self, request: Request, selector: UUID) -> Response:
|
||||
comic = get_object_or_404(models.ComicBook, selector=selector)
|
||||
if not user_can_read(request.user, comic.directory):
|
||||
return Response(status=status.HTTP_403_FORBIDDEN, data={'errors': 'Not allowed to read.'})
|
||||
_, _ = models.UserMisc.objects.get_or_create(user=request.user)
|
||||
comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
|
||||
comic_list = list(models.ComicBook.objects.filter(directory=comic.directory).order_by('file_name'))
|
||||
@@ -245,22 +265,22 @@ class ReadViewSet(viewsets.GenericViewSet):
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: 'PDF Binary Data',
|
||||
status.HTTP_400_BAD_REQUEST: 'User below classification allowed'})
|
||||
status.HTTP_403_FORBIDDEN: 'User below classification allowed',
|
||||
status.HTTP_404_NOT_FOUND: 'Comic file not found'})
|
||||
@action(methods=['get'], detail=True)
|
||||
def pdf(self, request: Request, selector: UUID) -> Union[FileResponse, Response]:
|
||||
book = models.ComicBook.objects.get(selector=selector)
|
||||
misc, _ = models.UserMisc.objects.get_or_create(user=request.user)
|
||||
try:
|
||||
if book.directory.classification > misc.allowed_to_read:
|
||||
return Response(status=400, data={'errors': 'Not allowed to read.'})
|
||||
except AttributeError:
|
||||
pass
|
||||
return FileResponse(open(book.get_pdf(), 'rb'), content_type='application/pdf')
|
||||
def pdf(self, request: Request, selector: UUID) -> FileResponse | Response | HTTPResponse:
|
||||
book = get_object_or_404(models.ComicBook, selector=selector)
|
||||
if not user_can_read(request.user, book.directory):
|
||||
return Response(status=status.HTTP_403_FORBIDDEN, data={'errors': 'Not allowed to read.'})
|
||||
pdf_path = book.get_pdf()
|
||||
if not pdf_path.is_file():
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={'errors': 'Comic file not found.'})
|
||||
return FileResponse(open(pdf_path, 'rb'), content_type='application/pdf')
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: TypeSerializer()})
|
||||
@action(methods=['get'], detail=True)
|
||||
def type(self, _request: Request, selector: UUID) -> Response:
|
||||
book = models.ComicBook.objects.get(selector=selector)
|
||||
book = get_object_or_404(models.ComicBook, selector=selector)
|
||||
return Response({'type': book.file_name.split('.')[-1].lower()})
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadPageSerializer()}, request_body=ReadPageSerializer)
|
||||
@@ -270,15 +290,11 @@ class ReadViewSet(viewsets.GenericViewSet):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
|
||||
if serializer.is_valid():
|
||||
comic_status, _ = models.ComicStatus.objects.annotate(page_count=F('comic__page_count')) \
|
||||
.get_or_create(comic_id=selector, user=request.user)
|
||||
comic = get_object_or_404(models.ComicBook, selector=selector)
|
||||
comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
|
||||
comic_status.last_read_page = serializer.data['page']
|
||||
comic_status.unread = False
|
||||
if comic_status.page_count - 1 == comic_status.last_read_page:
|
||||
comic_status.finished = True
|
||||
else:
|
||||
comic_status.finished = False
|
||||
|
||||
comic_status.finished = comic.page_count > 0 and comic_status.last_read_page >= comic.page_count - 1
|
||||
comic_status.save()
|
||||
return Response(self.get_serializer(comic_status).data)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -291,20 +307,30 @@ class PassthroughRenderer(renderers.BaseRenderer): # pylint: disable=too-few-pu
|
||||
media_type = '*/*'
|
||||
format = ''
|
||||
|
||||
def render(self, data: bytes, accepted_media_type: Optional[str] = None, renderer_context: Optional[str] = None) \
|
||||
def render(self, data: bytes, accepted_media_type: str | None = None, renderer_context: str | None = None) \
|
||||
-> bytes:
|
||||
return data
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
return data
|
||||
# Error responses still need to be serialised.
|
||||
return json.dumps(data).encode()
|
||||
|
||||
|
||||
class ImageViewSet(viewsets.ViewSet):
|
||||
queryset = models.ComicBook.objects.all()
|
||||
lookup_field = 'page'
|
||||
renderer_classes = [PassthroughRenderer]
|
||||
renderer_classes = [PassthroughRenderer, renderers.JSONRenderer]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: "A Binary Image response"})
|
||||
def retrieve(self, _request: Request, parent_lookup_selector: UUID, page: int) -> FileResponse:
|
||||
book = models.ComicBook.objects.get(selector=parent_lookup_selector)
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: "A Binary Image response",
|
||||
status.HTTP_403_FORBIDDEN: 'User below classification allowed',
|
||||
status.HTTP_404_NOT_FOUND: 'Comic or page not found'})
|
||||
def retrieve(self, _request: Request, selector: UUID, page: int) -> Response:
|
||||
book = get_object_or_404(models.ComicBook, selector=selector)
|
||||
if not user_can_read(_request.user, book.directory):
|
||||
return Response(status=status.HTTP_403_FORBIDDEN, data={'errors': 'Not allowed to read.'})
|
||||
img, content = book.get_image(int(page) - 1)
|
||||
if not img:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={'errors': 'Page could not be read.'})
|
||||
self.renderer_classes[0].media_type = content
|
||||
return FileResponse(img, content_type=content)
|
||||
|
||||
@@ -432,7 +458,7 @@ class ActionViewSet(viewsets.GenericViewSet):
|
||||
obj, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
|
||||
obj.unread = False
|
||||
obj.finished = True
|
||||
obj.last_read_page = comic.total_pages
|
||||
obj.last_read_page = max(comic.page_count - 1, 0)
|
||||
status_to_update.append(obj)
|
||||
models.ComicStatus.objects.bulk_update(status_to_update, ['unread', 'finished', 'last_read_page'])
|
||||
return Response({'status': 'marked_read'})
|
||||
@@ -464,7 +490,7 @@ class ActionViewSet(viewsets.GenericViewSet):
|
||||
return Response(serializer.errors,
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def get_comics(self, selectors: Iterable[Union[str, UUID]]) -> List[str]:
|
||||
def get_comics(self, selectors: Iterable[str | UUID]) -> list[str]:
|
||||
data = set()
|
||||
data = data.union(
|
||||
set(models.ComicBook.objects.filter(selector__in=selectors).values_list('selector', flat=True)))
|
||||
@@ -506,10 +532,10 @@ class PasswordResetSerializer(serializers.Serializer):
|
||||
try:
|
||||
validate_password(data)
|
||||
except ValidationError as err:
|
||||
raise serializers.ValidationError(err)
|
||||
raise serializers.ValidationError(err) from err
|
||||
return data
|
||||
|
||||
def validate(self, attrs: Dict[str, str]) -> Dict[str, str]:
|
||||
def validate(self, attrs: dict[str, str]) -> dict[str, str]:
|
||||
super().validate(attrs)
|
||||
if attrs['new_password'] != attrs['new_password_confirm']:
|
||||
raise serializers.ValidationError('New passwords do not match')
|
||||
@@ -580,37 +606,47 @@ class DirectoryViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
lookup_field = 'selector'
|
||||
|
||||
@staticmethod
|
||||
def _descendants(directory: models.Directory) -> list[models.Directory]:
|
||||
"""The directory itself plus every directory below it."""
|
||||
to_update = [directory]
|
||||
to_visit = [directory]
|
||||
while to_visit:
|
||||
parent = to_visit.pop()
|
||||
for child in models.Directory.objects.filter(parent=parent):
|
||||
to_visit.append(child)
|
||||
to_update.append(child)
|
||||
return to_update
|
||||
|
||||
@swagger_auto_schema(responses={200: DirectorySerializer(many=True)})
|
||||
def update(self, request: Request, selector: UUID) -> Response: # pylint: disable=arguments-differ
|
||||
"""
|
||||
This will set the classification of a directory and all it's children.
|
||||
This will set the classification of a directory and all of its children.
|
||||
"""
|
||||
main_parent = get_object_or_404(models.Directory, selector=selector)
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if serializer.is_valid():
|
||||
main_parent.classification = serializer.data['classification']
|
||||
to_update = {main_parent}
|
||||
to_visit = {main_parent}
|
||||
|
||||
while to_visit:
|
||||
parent = to_visit.pop()
|
||||
for child in models.Directory.objects.filter(parent=parent):
|
||||
child.classification = serializer.data['classification']
|
||||
to_visit.add(child)
|
||||
to_update.add(child)
|
||||
models.Directory.objects.bulk_update(to_update, fields=['classification'])
|
||||
data = models.Directory.objects.filter(directory__in=to_update)
|
||||
response = self.get_serializer(data, many=True)
|
||||
return Response(response.data)
|
||||
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
|
||||
to_update = self._descendants(main_parent)
|
||||
for directory in to_update:
|
||||
directory.classification = serializer.data['classification']
|
||||
models.Directory.objects.bulk_update(to_update, fields=['classification'])
|
||||
return Response(self.get_serializer(to_update, many=True).data)
|
||||
|
||||
@swagger_auto_schema(responses={200: DirectorySerializer()})
|
||||
def partial_update(self, request: Request, *args, **kwargs) -> Response:
|
||||
"""
|
||||
This will set the classification of a directory and none of its children.
|
||||
"""
|
||||
return super().update(request, *args, **kwargs)
|
||||
directory = self.get_object()
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
directory.classification = serializer.data['classification']
|
||||
directory.save()
|
||||
return Response(self.get_serializer(directory).data)
|
||||
|
||||
|
||||
class InitialSetupSerializer(serializers.Serializer):
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""The folder scan as a task.
|
||||
|
||||
The default backend runs tasks immediately, so this behaves like a function call today.
|
||||
Writing it as a task is what lets the scan move to a worker later without changing any
|
||||
of the call sites in comic/rest.py or the management command.
|
||||
"""
|
||||
|
||||
from django.tasks import task
|
||||
|
||||
from comic import models
|
||||
from comic.processing import reconcile
|
||||
|
||||
|
||||
@task
|
||||
def scan_directory(selector: str | None = None) -> None:
|
||||
"""Reconcile one directory, or the root of the comic volume when no selector is given."""
|
||||
directory = models.Directory.objects.filter(selector=selector).first() if selector else None
|
||||
reconcile(directory)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Shared fixtures for the test suite."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from comic import models
|
||||
from comic.tests.factories import make_cbz
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def comic_volume(settings, tmp_path) -> Path:
|
||||
"""Point COMIC_BOOK_VOLUME at an empty temporary directory."""
|
||||
settings.COMIC_BOOK_VOLUME = tmp_path
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_user(db) -> models.User:
|
||||
return models.User.objects.create_superuser('admin', 'admin@example.com', 'password12345')
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def reader_user(db) -> models.User:
|
||||
"""A normal user, whose reading allowance defaults to the highest rating."""
|
||||
return models.User.objects.create_user('reader', 'reader@example.com', 'password12345')
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def restricted_user(db) -> models.User:
|
||||
"""A user whose allowance is G, so anything rated above G is blocked."""
|
||||
user = models.User.objects.create_user('restricted', 'restricted@example.com', 'password12345')
|
||||
models.UserMisc.objects.create(user=user, allowed_to_read=models.Directory.Classification.C_G)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def rated_directory(db) -> models.Directory:
|
||||
return models.Directory.objects.create(name='Rated', classification=models.Directory.Classification.C_18)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def book(db, comic_volume: Path, rated_directory: models.Directory) -> models.ComicBook:
|
||||
"""A book with a three page archive inside an 18 rated directory."""
|
||||
directory_path = comic_volume / rated_directory.name
|
||||
directory_path.mkdir(parents=True, exist_ok=True)
|
||||
make_cbz(directory_path / 'issue.cbz', pages=3)
|
||||
return models.ComicBook.objects.create(file_name='issue.cbz', directory=rated_directory, page_count=3)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Helpers for creating comic files on disk."""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pymupdf
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def make_cbz(path: Path, pages: int = 3, metadata: bool = True) -> Path:
|
||||
"""Write a .cbz containing PNG pages and, optionally, a ComicInfo.xml."""
|
||||
with zipfile.ZipFile(path, 'w') as archive:
|
||||
for page in range(pages):
|
||||
image = Image.new('RGB', (10, 10))
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format='PNG')
|
||||
archive.writestr(f'page{page:02}.png', buffer.getvalue())
|
||||
if metadata:
|
||||
archive.writestr('ComicInfo.xml', '<ComicInfo><Title>Test</Title></ComicInfo>')
|
||||
return path
|
||||
|
||||
|
||||
def make_pdf(path: Path, pages: int = 2) -> Path:
|
||||
"""Write a PDF with the given number of pages."""
|
||||
document = pymupdf.open()
|
||||
for _ in range(pages):
|
||||
document.new_page(width=10, height=10)
|
||||
document.save(path)
|
||||
document.close()
|
||||
return path
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Tests for the API behaviour of the comic viewsets."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from comic import models
|
||||
from comic.tests.factories import make_cbz
|
||||
|
||||
UNKNOWN = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unknown_directory_returns_404(client, reader_user) -> None:
|
||||
client.force_login(reader_user)
|
||||
assert client.get(f'/api/browse/{UNKNOWN}/').status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unknown_selector_returns_404_in_breadcrumbs(client, reader_user) -> None:
|
||||
client.force_login(reader_user)
|
||||
assert client.get(f'/api/browse/{UNKNOWN}/breadcrumbs/').status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unknown_book_returns_404_for_an_image(client, reader_user) -> None:
|
||||
client.force_login(reader_user)
|
||||
assert client.get(f'/api/read/{UNKNOWN}/image/1/').status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unknown_book_returns_404_for_the_type_action(client, reader_user) -> None:
|
||||
client.force_login(reader_user)
|
||||
assert client.get(f'/api/read/{UNKNOWN}/type/').status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_image_requires_authentication(client, book) -> None:
|
||||
assert client.get(f'/api/read/{book.selector}/image/1/').status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_image_is_blocked_for_a_user_below_the_rating(client, book, restricted_user) -> None:
|
||||
client.force_login(restricted_user)
|
||||
assert client.get(f'/api/read/{book.selector}/image/1/').status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_image_returns_the_page_for_an_allowed_user(client, book, reader_user) -> None:
|
||||
client.force_login(reader_user)
|
||||
response = client.get(f'/api/read/{book.selector}/image/1/')
|
||||
assert response.status_code == 200
|
||||
assert response['Content-Type'] == 'image/png'
|
||||
assert b''.join(response.streaming_content)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_read_view_is_blocked_for_a_user_below_the_rating(client, book, restricted_user) -> None:
|
||||
client.force_login(restricted_user)
|
||||
assert client.get(f'/api/read/{book.selector}/').status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_read_view_returns_progress_for_an_allowed_user(client, book, reader_user) -> None:
|
||||
client.force_login(reader_user)
|
||||
response = client.get(f'/api/read/{book.selector}/')
|
||||
assert response.status_code == 200
|
||||
assert response.json()['pages'] == 3
|
||||
assert response.json()['title'] == 'issue.cbz'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_set_page_creates_the_status_and_marks_the_book_finished(client, book, reader_user) -> None:
|
||||
client.force_login(reader_user)
|
||||
response = client.put(
|
||||
f'/api/read/{book.selector}/set_page/',
|
||||
data={'page': book.page_count - 1},
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 200
|
||||
status = models.ComicStatus.objects.get(user=reader_user, comic=book)
|
||||
assert status.last_read_page == 2
|
||||
assert status.finished
|
||||
assert not status.unread
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_set_page_leaves_the_book_unfinished_before_the_last_page(client, book, reader_user) -> None:
|
||||
client.force_login(reader_user)
|
||||
response = client.put(
|
||||
f'/api/read/{book.selector}/set_page/',
|
||||
data={'page': 1},
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 200
|
||||
status = models.ComicStatus.objects.get(user=reader_user, comic=book)
|
||||
assert not status.finished
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mark_read_records_the_last_page(client, book, reader_user) -> None:
|
||||
client.force_login(reader_user)
|
||||
response = client.put(
|
||||
'/api/action/mark_read/',
|
||||
data={'selectors': [str(book.selector)]},
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 200
|
||||
status = models.ComicStatus.objects.get(user=reader_user, comic=book)
|
||||
assert status.last_read_page == book.page_count - 1
|
||||
assert status.finished
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_directory_update_cascades_to_children(client, admin_user) -> None:
|
||||
client.force_login(admin_user)
|
||||
parent = models.Directory.objects.create(name='Parent', classification=models.Directory.Classification.C_18)
|
||||
child = models.Directory.objects.create(name='Child', parent=parent)
|
||||
|
||||
response = client.put(
|
||||
f'/api/directory/{parent.selector}/',
|
||||
data={'classification': models.Directory.Classification.C_PG},
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert sorted(item['classification'] for item in response.json()) == [1, 1]
|
||||
child.refresh_from_db()
|
||||
assert child.classification == models.Directory.Classification.C_PG
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_directory_partial_update_does_not_cascade(client, admin_user) -> None:
|
||||
client.force_login(admin_user)
|
||||
parent = models.Directory.objects.create(name='Parent', classification=models.Directory.Classification.C_18)
|
||||
child = models.Directory.objects.create(name='Child', parent=parent)
|
||||
|
||||
response = client.patch(
|
||||
f'/api/directory/{child.selector}/',
|
||||
data={'classification': models.Directory.Classification.C_15},
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {'selector': str(child.selector), 'classification': 3}
|
||||
parent.refresh_from_db()
|
||||
assert parent.classification == models.Directory.Classification.C_18
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_directory_partial_update_rejects_an_unknown_classification(client, admin_user) -> None:
|
||||
client.force_login(admin_user)
|
||||
directory = models.Directory.objects.create(name='Parent')
|
||||
response = client.patch(
|
||||
f'/api/directory/{directory.selector}/',
|
||||
data={'classification': 9},
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_browse_listing_does_not_write_to_the_database(client, reader_user, comic_volume) -> None:
|
||||
"""Listing is read only, so a browse request cannot race with the scan."""
|
||||
client.force_login(reader_user)
|
||||
target = comic_volume / 'Series'
|
||||
target.mkdir()
|
||||
make_cbz(target / 'issue.cbz', pages=2)
|
||||
|
||||
assert client.get('/api/browse/').status_code == 200
|
||||
assert models.ComicBook.objects.count() == 0
|
||||
assert models.ComicStatus.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scan_endpoint_indexes_files_that_are_only_on_disk(client, reader_user, comic_volume) -> None:
|
||||
client.force_login(reader_user)
|
||||
target = comic_volume / 'Series'
|
||||
target.mkdir()
|
||||
make_cbz(target / 'issue.cbz', pages=2)
|
||||
|
||||
assert [entry['title'] for entry in client.get('/api/browse/').json()] == []
|
||||
|
||||
response = client.post('/api/browse/scan/', data='{}', content_type='application/json')
|
||||
assert response.status_code == 200
|
||||
assert [entry['title'] for entry in response.json()] == ['Series']
|
||||
|
||||
directory = models.Directory.objects.get(name='Series')
|
||||
response = client.post('/api/browse/scan/',
|
||||
data=json.dumps({'selector': str(directory.selector)}),
|
||||
content_type='application/json')
|
||||
assert response.status_code == 200
|
||||
entries = response.json()
|
||||
assert [entry['title'] for entry in entries] == ['issue.cbz']
|
||||
assert entries[0]['type'] == 'ComicBook'
|
||||
assert entries[0]['total'] == 2
|
||||
assert entries[0]['progress'] == 0
|
||||
assert entries[0]['unread'] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scan_endpoint_requires_authentication(client) -> None:
|
||||
assert client.post('/api/browse/scan/', data='{}', content_type='application/json').status_code == 401
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Tests for the shared archive page listing in comic.archives."""
|
||||
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from comic.archives import get_archive_files
|
||||
from comic.tests.factories import make_cbz
|
||||
|
||||
|
||||
def names(archive: zipfile.ZipFile) -> list[str]:
|
||||
return [entry.file_name for entry in get_archive_files(archive)]
|
||||
|
||||
|
||||
def test_metadata_entries_are_not_pages(tmp_path: Path) -> None:
|
||||
path = make_cbz(tmp_path / 'issue.cbz', pages=2, metadata=True)
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
assert names(archive) == ['page00.png', 'page01.png']
|
||||
|
||||
|
||||
def test_entries_are_returned_in_page_order(tmp_path: Path) -> None:
|
||||
path = tmp_path / 'issue.cbz'
|
||||
with zipfile.ZipFile(path, 'w') as archive:
|
||||
archive.writestr('page10.png', b'x')
|
||||
archive.writestr('page02.png', b'x')
|
||||
archive.writestr('page01.png', b'x')
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
assert names(archive) == ['page01.png', 'page02.png', 'page10.png']
|
||||
|
||||
|
||||
def test_directory_entries_are_ignored(tmp_path: Path) -> None:
|
||||
path = tmp_path / 'issue.cbz'
|
||||
with zipfile.ZipFile(path, 'w') as archive:
|
||||
archive.writestr('cover.png', b'x')
|
||||
archive.writestr('images/', b'')
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
assert names(archive) == ['cover.png']
|
||||
|
||||
|
||||
def test_mime_type_is_guessed_from_the_file_name(tmp_path: Path) -> None:
|
||||
path = make_cbz(tmp_path / 'issue.cbz', pages=1, metadata=False)
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
assert get_archive_files(archive)[0].mime_type == 'image/png'
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests for cookie based JWT authentication."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.test import Client
|
||||
|
||||
from comic import auth
|
||||
|
||||
CREDENTIALS = {'username': 'reader', 'password': 'password12345'}
|
||||
|
||||
|
||||
def _store_cookies(client: Client, response) -> None:
|
||||
"""Copy Set-Cookie headers into the test client, which does not do it itself."""
|
||||
for name, morsel in response.cookies.items():
|
||||
if morsel.value:
|
||||
client.cookies[name] = morsel.value
|
||||
elif name in client.cookies:
|
||||
del client.cookies[name]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_login_sets_http_only_cookies(client: Client, reader_user) -> None:
|
||||
response = client.post('/api/token/', CREDENTIALS)
|
||||
assert response.status_code == 200
|
||||
# The tokens are not returned to JavaScript, only stored as cookies.
|
||||
assert json.loads(response.content) == {}
|
||||
for name in (auth.access_cookie_name(), auth.refresh_cookie_name()):
|
||||
assert response.cookies[name].value
|
||||
assert response.cookies[name]['httponly']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_a_cookie_authenticates_a_request(client: Client, reader_user) -> None:
|
||||
_store_cookies(client, client.post('/api/token/', CREDENTIALS))
|
||||
response = client.get('/api/account/')
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.content)['username'] == reader_user.username
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous_request_without_a_cookie_is_rejected(client: Client) -> None:
|
||||
assert client.get('/api/account/').status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_the_authorization_header_still_works(client: Client, reader_user) -> None:
|
||||
response = client.post('/api/token/', CREDENTIALS)
|
||||
token = response.cookies[auth.access_cookie_name()].value
|
||||
response = client.get('/api/account/', headers={'Authorization': f'Bearer {token}'})
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refresh_rotates_the_access_cookie(client: Client, reader_user) -> None:
|
||||
_store_cookies(client, client.post('/api/token/', CREDENTIALS))
|
||||
old_access = client.cookies[auth.access_cookie_name()].value
|
||||
old_refresh = client.cookies[auth.refresh_cookie_name()].value
|
||||
|
||||
response = client.post('/api/token/refresh/', {})
|
||||
assert response.status_code == 200
|
||||
_store_cookies(client, response)
|
||||
assert client.cookies[auth.access_cookie_name()].value != old_access
|
||||
assert client.cookies[auth.refresh_cookie_name()].value != old_refresh
|
||||
|
||||
# The rotated refresh token is blacklisted, so the original one no longer works.
|
||||
assert client.post('/api/token/refresh/', {'refresh': old_refresh}).status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_blacklisting_clears_the_cookies(client: Client, reader_user) -> None:
|
||||
_store_cookies(client, client.post('/api/token/', CREDENTIALS))
|
||||
response = client.post('/api/token/blacklist/', {})
|
||||
assert response.status_code == 204
|
||||
_store_cookies(client, response)
|
||||
|
||||
for name in (auth.access_cookie_name(), auth.refresh_cookie_name()):
|
||||
assert name not in client.cookies
|
||||
|
||||
assert client.get('/api/account/').status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_page_images_can_be_loaded_with_a_cookie(client: Client, book, reader_user) -> None:
|
||||
"""Browser initiated requests cannot send a header, so they rely on the cookie."""
|
||||
_store_cookies(client, client.post('/api/token/', CREDENTIALS))
|
||||
assert client.get(f'/api/read/{book.selector}/image/1/').status_code == 200
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests for the comic models."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.db import IntegrityError
|
||||
|
||||
from comic import models
|
||||
from comic.tests.factories import make_cbz, make_pdf
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_page_count_excludes_metadata(comic_volume: Path) -> None:
|
||||
make_cbz(comic_volume / 'issue.cbz', pages=3, metadata=True)
|
||||
book = models.ComicBook.objects.create(file_name='issue.cbz')
|
||||
assert book.get_page_count() == 3
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_pdf_page_count(comic_volume: Path) -> None:
|
||||
make_pdf(comic_volume / 'issue.pdf', pages=2)
|
||||
book = models.ComicBook.objects.create(file_name='issue.pdf')
|
||||
assert book.get_page_count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unreadable_archive_is_indexed_without_pages(comic_volume: Path) -> None:
|
||||
path = comic_volume / 'broken.cbz'
|
||||
path.write_bytes(b'this is not an archive')
|
||||
result = models.ComicBook.process_comic_book(path)
|
||||
assert isinstance(result, Path)
|
||||
assert models.ComicBook.objects.get(file_name='broken.cbz').page_count == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_archive_path_uses_the_directory_tree(comic_volume: Path) -> None:
|
||||
parent = models.Directory.objects.create(name='Series')
|
||||
child = models.Directory.objects.create(name='Volume 1', parent=parent)
|
||||
book = models.ComicBook.objects.create(file_name='issue.cbz', directory=child)
|
||||
assert book.get_archive_path == comic_volume / 'Series' / 'Volume 1' / 'issue.cbz'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_book_without_a_directory_uses_the_volume_root(comic_volume: Path) -> None:
|
||||
book = models.ComicBook.objects.create(file_name='issue.cbz')
|
||||
assert book.get_archive_path == comic_volume / 'issue.cbz'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_directory_path_is_built_from_its_parents() -> None:
|
||||
parent = models.Directory.objects.create(name='Series')
|
||||
child = models.Directory.objects.create(name='Volume 1', parent=parent)
|
||||
assert child.get_path() == Path('Series') / 'Volume 1'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_image_returns_the_requested_page(comic_volume: Path) -> None:
|
||||
make_cbz(comic_volume / 'issue.cbz', pages=2, metadata=False)
|
||||
book = models.ComicBook.objects.create(file_name='issue.cbz')
|
||||
stream, mime_type = book.get_image(1)
|
||||
assert mime_type == 'image/png'
|
||||
assert stream.read()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_classification_comparison() -> None:
|
||||
directory = models.Directory.objects.create(name='Rated', classification=models.Directory.Classification.C_18)
|
||||
misc = models.UserMisc.objects.create(
|
||||
user=models.User.objects.create_user('pg', 'pg@example.com', 'password12345'),
|
||||
allowed_to_read=models.Directory.Classification.C_PG,
|
||||
)
|
||||
assert directory.classification > misc.allowed_to_read
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_duplicate_directory_names_are_rejected() -> None:
|
||||
"""The scan depends on this constraint: without it two concurrent requests can create
|
||||
the same directory twice and the browse listing shows it twice."""
|
||||
parent = models.Directory.objects.create(name='Series')
|
||||
models.Directory.objects.create(name='Volume 1', parent=parent)
|
||||
with pytest.raises(IntegrityError):
|
||||
models.Directory.objects.create(name='Volume 1', parent=parent)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Tests for the filesystem reconciliation in comic.processing."""
|
||||
|
||||
import pytest
|
||||
|
||||
from comic import models
|
||||
from comic.processing import clean_directories, clean_files, list_directory, reconcile
|
||||
from comic.tests.factories import make_cbz
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_clean_directories_adds_new_and_removes_stale(comic_volume, reader_user) -> None:
|
||||
parent = models.Directory.objects.create(name='Series')
|
||||
models.Directory.objects.create(name='Gone', parent=parent)
|
||||
(comic_volume / 'Series' / 'New').mkdir(parents=True)
|
||||
|
||||
clean_directories(models.Directory.objects.filter(parent=parent), comic_volume / 'Series', parent)
|
||||
|
||||
names = set(models.Directory.objects.filter(parent=parent).values_list('name', flat=True))
|
||||
assert names == {'New'}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_clean_files_ignores_unsupported_extensions(comic_volume, reader_user) -> None:
|
||||
directory = models.Directory.objects.create(name='Series')
|
||||
target = comic_volume / 'Series'
|
||||
target.mkdir(parents=True)
|
||||
make_cbz(target / 'issue.cbz', pages=2)
|
||||
(target / 'notes.txt').write_text('not a comic')
|
||||
|
||||
clean_files(models.ComicBook.objects.filter(directory=directory), target, directory)
|
||||
|
||||
books = list(models.ComicBook.objects.filter(directory=directory))
|
||||
assert [book.file_name for book in books] == ['issue.cbz']
|
||||
assert books[0].page_count == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reconcile_indexes_the_directory_without_touching_the_database_on_a_second_run(
|
||||
comic_volume, reader_user) -> None:
|
||||
directory = models.Directory.objects.create(name='Series')
|
||||
target = comic_volume / 'Series'
|
||||
target.mkdir(parents=True)
|
||||
make_cbz(target / 'issue.cbz', pages=2)
|
||||
|
||||
reconcile(directory)
|
||||
first = list(models.ComicBook.objects.filter(directory=directory))
|
||||
assert [book.file_name for book in first] == ['issue.cbz']
|
||||
|
||||
reconcile(directory)
|
||||
assert list(models.ComicBook.objects.filter(directory=directory)) == first
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_directory_reports_an_unread_book_without_creating_a_status(comic_volume,
|
||||
reader_user) -> None:
|
||||
"""Listing is read only: a book nobody has opened has no ComicStatus row at all."""
|
||||
directory = models.Directory.objects.create(name='Series')
|
||||
target = comic_volume / 'Series'
|
||||
target.mkdir(parents=True)
|
||||
make_cbz(target / 'issue.cbz', pages=3)
|
||||
reconcile(directory)
|
||||
|
||||
entries = list_directory(reader_user, directory)
|
||||
|
||||
assert [entry.title for entry in entries] == ['issue.cbz']
|
||||
assert entries[0].progress == 0
|
||||
assert entries[0].unread is True
|
||||
assert entries[0].finished is False
|
||||
assert models.ComicStatus.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_directory_shows_progress_for_a_book_that_has_been_read(comic_volume,
|
||||
reader_user) -> None:
|
||||
directory = models.Directory.objects.create(name='Series')
|
||||
target = comic_volume / 'Series'
|
||||
target.mkdir(parents=True)
|
||||
make_cbz(target / 'issue.cbz', pages=3)
|
||||
reconcile(directory)
|
||||
book = models.ComicBook.objects.get(directory=directory)
|
||||
models.ComicStatus.objects.create(user=reader_user, comic=book, last_read_page=1, unread=False)
|
||||
|
||||
entries = list_directory(reader_user, directory)
|
||||
|
||||
assert entries[0].progress == 2
|
||||
assert entries[0].unread is False
|
||||
assert entries[0].finished is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_clean_files_survives_a_concurrent_scan_of_the_same_directory(comic_volume, reader_user,
|
||||
rated_directory) -> None:
|
||||
"""Two requests can build the same 'new file' list; the second insert must not clash."""
|
||||
target = comic_volume / rated_directory.name
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
make_cbz(target / 'issue.cbz', pages=2)
|
||||
|
||||
stale = models.ComicBook.objects.filter(directory=rated_directory)
|
||||
list(stale) # force evaluation so the cached snapshot is empty
|
||||
models.ComicBook.objects.create(file_name='issue.cbz', directory=rated_directory, page_count=2)
|
||||
|
||||
clean_files(stale, target, rated_directory)
|
||||
|
||||
assert models.ComicBook.objects.filter(directory=rated_directory).count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_clean_files_ignores_rows_another_request_already_removed(comic_volume, reader_user,
|
||||
rated_directory) -> None:
|
||||
target = comic_volume / rated_directory.name
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
stale_book = models.ComicBook.objects.create(file_name='gone.cbz', directory=rated_directory)
|
||||
|
||||
stale = models.ComicBook.objects.filter(directory=rated_directory)
|
||||
list(stale) # cache the row, then let another request delete it
|
||||
stale_book.delete()
|
||||
|
||||
clean_files(stale, target, rated_directory)
|
||||
|
||||
assert models.ComicBook.objects.filter(directory=rated_directory).count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_clean_directories_does_not_duplicate_rows_when_another_request_inserts_first(comic_volume,
|
||||
reader_user) -> None:
|
||||
parent = models.Directory.objects.create(name='Series')
|
||||
(comic_volume / 'Series' / 'New').mkdir(parents=True)
|
||||
|
||||
stale = models.Directory.objects.filter(parent=parent)
|
||||
list(stale) # cached snapshot is empty
|
||||
models.Directory.objects.create(name='New', parent=parent)
|
||||
|
||||
clean_directories(stale, comic_volume / 'Series', parent)
|
||||
|
||||
assert models.Directory.objects.filter(parent=parent).count() == 1
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests for the scan_comics management command."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
from comic import models
|
||||
from comic.tests.factories import make_cbz
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scan_indexes_nested_directories(comic_volume: Path, reader_user) -> None:
|
||||
nested = comic_volume / 'Series' / 'Volume 1'
|
||||
nested.mkdir(parents=True)
|
||||
make_cbz(nested / 'issue.cbz', pages=2)
|
||||
|
||||
call_command('scan_comics')
|
||||
|
||||
directory = models.Directory.objects.get(name='Volume 1', parent__name='Series')
|
||||
book = models.ComicBook.objects.get(file_name='issue.cbz', directory=directory)
|
||||
assert book.page_count == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scan_if_empty_scans_an_empty_database(comic_volume: Path) -> None:
|
||||
make_cbz(comic_volume / 'issue.cbz', pages=1)
|
||||
|
||||
call_command('scan_comics', if_empty=True)
|
||||
|
||||
assert models.ComicBook.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scan_if_empty_skips_when_books_already_exist(comic_volume: Path) -> None:
|
||||
"""Files added while the container was down are left to the cron scan."""
|
||||
make_cbz(comic_volume / 'issue.cbz', pages=1)
|
||||
call_command('scan_comics')
|
||||
|
||||
make_cbz(comic_volume / 'later.cbz', pages=1)
|
||||
call_command('scan_comics', if_empty=True)
|
||||
assert models.ComicBook.objects.count() == 1
|
||||
|
||||
call_command('scan_comics')
|
||||
assert models.ComicBook.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scan_runs_without_any_user(comic_volume: Path) -> None:
|
||||
"""The command no longer needs a user, because statuses are created when a book is read."""
|
||||
make_cbz(comic_volume / 'issue.cbz', pages=1)
|
||||
|
||||
call_command('scan_comics')
|
||||
|
||||
assert models.ComicBook.objects.count() == 1
|
||||
assert models.ComicStatus.objects.count() == 0
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Tests for the scan task."""
|
||||
|
||||
import pytest
|
||||
|
||||
from comic import models, tasks
|
||||
from comic.tests.factories import make_cbz
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scan_task_runs_through_the_task_backend(comic_volume) -> None:
|
||||
"""The immediate backend executes the task in the caller, so this is the whole path."""
|
||||
make_cbz(comic_volume / 'issue.cbz', pages=2)
|
||||
|
||||
tasks.scan_directory.enqueue()
|
||||
|
||||
assert models.ComicBook.objects.count() == 1
|
||||
assert models.ComicBook.objects.get().page_count == 2
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Tests for URL resolution that depends on settings."""
|
||||
|
||||
import pytest
|
||||
from django.test import Client
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', ['/swagger.json', '/swagger.yaml', '/swagger/', '/redoc/'])
|
||||
def test_schema_urls_resolve(client: Client, url: str) -> None:
|
||||
"""The schema URLs must match the renderer formats, which no longer include a dot.
|
||||
|
||||
cbreader/urls.py captures the format after the dot because
|
||||
SWAGGER_USE_COMPAT_RENDERERS is False.
|
||||
"""
|
||||
assert client.get(url).status_code == 200
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Tests for breadcrumb generation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from comic import models
|
||||
from comic.util import generate_breadcrumbs_from_path
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_breadcrumbs_run_from_home_to_the_book() -> None:
|
||||
parent = models.Directory.objects.create(name='Series')
|
||||
child = models.Directory.objects.create(name='Volume 1', parent=parent)
|
||||
book = models.ComicBook.objects.create(file_name='issue.cbz', directory=child)
|
||||
assert [crumb.name for crumb in generate_breadcrumbs_from_path(child, book)] == [
|
||||
'Home', 'Series', 'Volume 1', 'issue.cbz',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_breadcrumbs_without_a_book_stop_at_the_directory() -> None:
|
||||
directory = models.Directory.objects.create(name='Series')
|
||||
assert [crumb.name for crumb in generate_breadcrumbs_from_path(directory)] == ['Home', 'Series']
|
||||
Whitespace-only changes.
+5
-6
@@ -1,5 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from comic.models import ComicBook, Directory
|
||||
|
||||
@@ -10,12 +9,12 @@ class Breadcrumb:
|
||||
selector: str = ''
|
||||
|
||||
|
||||
def generate_breadcrumbs_from_path(directory: Optional[Directory] = None, book: Optional[ComicBook] = None):
|
||||
def generate_breadcrumbs_from_path(
|
||||
directory: Directory | None = None,
|
||||
book: ComicBook | None = None,
|
||||
) -> list[Breadcrumb]:
|
||||
output = [Breadcrumb()]
|
||||
if directory:
|
||||
folders = directory.get_path_objects()
|
||||
else:
|
||||
folders = []
|
||||
folders = directory.get_path_objects() if directory else []
|
||||
for item in folders[::-1]:
|
||||
output.append(
|
||||
Breadcrumb(
|
||||
|
||||
Whitespace-only changes.
+28
-20
@@ -1,64 +1,72 @@
|
||||
version: "2.4"
|
||||
|
||||
services:
|
||||
|
||||
cbwebreader:
|
||||
build: ../
|
||||
image: ajurna/cbwebreader
|
||||
env_file: .env
|
||||
links:
|
||||
- database
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- 8000
|
||||
volumes:
|
||||
- ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
|
||||
# COMIC_BOOK_VOLUME is the path *inside* the container; the host path is
|
||||
# a separate variable so a Windows host path is not passed to Django.
|
||||
- ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME}
|
||||
- static_files:/static
|
||||
- media_files:/media
|
||||
- .env:/src/.env
|
||||
command: /bin/bash entrypoint.sh
|
||||
command: sh entrypoint.sh
|
||||
restart: unless-stopped
|
||||
|
||||
cbwebreader-cron:
|
||||
build: ../
|
||||
image: ajurna/cbwebreader
|
||||
env_file: .env
|
||||
links:
|
||||
- database
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
|
||||
- ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME}
|
||||
- media_files:/media
|
||||
- .env:/src/.env
|
||||
command: /bin/bash entrypoint-cron.sh
|
||||
# The image HEALTHCHECK probes the web port, which this service never serves.
|
||||
healthcheck:
|
||||
disable: true
|
||||
command: sh entrypoint-cron.sh
|
||||
restart: unless-stopped
|
||||
|
||||
database:
|
||||
image: postgres:11.4-alpine
|
||||
image: postgres:16-alpine
|
||||
expose:
|
||||
- 5432
|
||||
volumes:
|
||||
- ./data:/var/lib/postgresql/data
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $DB_USER -d $DB_DATABASE"]
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASS}
|
||||
- POSTGRES_DB=${DB_DATABASE}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASS}
|
||||
POSTGRES_DB: ${DB_DATABASE}
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx
|
||||
image: nginx:1-alpine
|
||||
volumes:
|
||||
- static_files:/static
|
||||
- media_files:/media
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
ports:
|
||||
- 1337:80
|
||||
- "${WEB_PORT:-8337}:80"
|
||||
depends_on:
|
||||
- cbwebreader
|
||||
cbwebreader:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
static_files:
|
||||
media_files:
|
||||
media_files:
|
||||
postgres_data:
|
||||
+8
-1
@@ -6,6 +6,13 @@ server {
|
||||
|
||||
listen 80;
|
||||
|
||||
# nginx compresses only text/html by default, so the bundle and CSS need explicit types.
|
||||
gzip on;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml application/wasm;
|
||||
gzip_vary on;
|
||||
|
||||
location / {
|
||||
proxy_pass http://cbwebreader_django;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -20,4 +27,4 @@ server {
|
||||
alias /media/;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+36
-29
@@ -1,65 +1,72 @@
|
||||
version: "2.4"
|
||||
|
||||
services:
|
||||
|
||||
cbwebreader:
|
||||
build: .
|
||||
image: ajurna/cbwebreader
|
||||
env_file: .env
|
||||
links:
|
||||
- database
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- 8000
|
||||
volumes:
|
||||
- ${COMIC_BOOK_VOLUME}:/comics
|
||||
# - c:/comics:/comics
|
||||
# COMIC_BOOK_VOLUME is the path *inside* the container; the host path is
|
||||
# a separate variable so a Windows host path is not passed to Django.
|
||||
- ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME}
|
||||
- static_files:/static
|
||||
- media_files:/media
|
||||
- .env:/src/.env
|
||||
command: /bin/bash /src/entrypoint.sh
|
||||
command: sh entrypoint.sh
|
||||
restart: unless-stopped
|
||||
|
||||
# cbwebreader-cron:
|
||||
# build: .
|
||||
# env_file: .env
|
||||
# links:
|
||||
# - database
|
||||
# depends_on:
|
||||
# database:
|
||||
# condition: service_healthy
|
||||
# volumes:
|
||||
# - ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
|
||||
# - media_files:/media
|
||||
# - .env:/src/.env
|
||||
# command: /bin/bash entrypoint-cron.sh
|
||||
cbwebreader-cron:
|
||||
build: .
|
||||
image: ajurna/cbwebreader
|
||||
env_file: .env
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ${COMIC_BOOK_HOST_PATH}:${COMIC_BOOK_VOLUME}
|
||||
- media_files:/media
|
||||
- .env:/src/.env
|
||||
# The image HEALTHCHECK probes the web port, which this service never serves.
|
||||
healthcheck:
|
||||
disable: true
|
||||
command: sh entrypoint-cron.sh
|
||||
restart: unless-stopped
|
||||
|
||||
database:
|
||||
image: postgres:16-alpine
|
||||
expose:
|
||||
- 5432
|
||||
volumes:
|
||||
- /var/lib/postgresql/data
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $DB_USER -d $DB_DATABASE"]
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASS}
|
||||
- POSTGRES_DB=${DB_DATABASE}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASS}
|
||||
POSTGRES_DB: ${DB_DATABASE}
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx
|
||||
image: nginx:1-alpine
|
||||
volumes:
|
||||
- static_files:/static
|
||||
- media_files:/media
|
||||
- ./cbreader/settings/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./compose/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
ports:
|
||||
- 8337:80
|
||||
- "${WEB_PORT:-8337}:80"
|
||||
depends_on:
|
||||
- cbwebreader
|
||||
cbwebreader:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
static_files:
|
||||
media_files:
|
||||
postgres_data:
|
||||
+9
-2
@@ -1,5 +1,12 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
python manage.py migrate --settings=cbreader.settings.base
|
||||
|
||||
python manage.py collectstatic --settings=cbreader.settings.base --noinput --clear
|
||||
# Index the library on the first run so the UI is not empty until cron gets to it.
|
||||
# --if-empty makes this a no-op on every later start.
|
||||
python manage.py scan_comics --if-empty --settings=cbreader.settings.base
|
||||
|
||||
gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application
|
||||
python manage.py collectstatic --settings=cbreader.settings.base --noinput --clear
|
||||
|
||||
exec gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application
|
||||
@@ -0,0 +1,61 @@
|
||||
import js from '@eslint/js'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist/**', 'node_modules/**', 'public/**'] },
|
||||
js.configs.recommended,
|
||||
...pluginVue.configs['flat/essential'],
|
||||
|
||||
// Build scripts (webpack.*.js) run in Node, so they use CommonJS globals.
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
require: 'readonly',
|
||||
module: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
process: 'readonly',
|
||||
console: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Tests run under Vitest with a jsdom environment.
|
||||
{
|
||||
files: ['test/**'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
document: 'readonly',
|
||||
localStorage: 'readonly',
|
||||
console: 'readonly',
|
||||
window: 'readonly',
|
||||
HTMLElement: 'readonly',
|
||||
KeyboardEvent: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Application code is browser code bundled by webpack.
|
||||
{
|
||||
files: ['src/**'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
localStorage: 'readonly',
|
||||
console: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
IntersectionObserver: 'readonly',
|
||||
process: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {},
|
||||
},
|
||||
]
|
||||
Generated
+5469
-9851
File diff suppressed because it is too large.
Load diff
+30
-46
@@ -1,62 +1,46 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "webpack-dev-server --config webpack.dev.js",
|
||||
"build": "webpack --config webpack.prod.js",
|
||||
"lint": "vue-cli-service lint"
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.2",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.1",
|
||||
"axios": "^0.27.2",
|
||||
"bootstrap": "^5.2.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.3.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.3.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.3.3",
|
||||
"axios": "^1.20.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"hammerjs": "^2.0.8",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"pdfvuer": "^2.0.1",
|
||||
"reveal.js": "^4.3.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"reveal.js": "^6.0.2",
|
||||
"timeago.js": "^4.0.2",
|
||||
"vue": "^3.2.26",
|
||||
"vue-router": "^4.0.3",
|
||||
"vue-toast-notification": "3.0",
|
||||
"vue": "^3.5.44",
|
||||
"vue-router": "^5.4.0",
|
||||
"vue-toast-notification": "^3.1.3",
|
||||
"vuejs-paginate-next": "^1.0.2",
|
||||
"vuex": "^4.0.0",
|
||||
"webpack": "^5.76.0"
|
||||
"vuex": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.16",
|
||||
"@babel/eslint-parser": "^7.12.16",
|
||||
"@vue/cli-plugin-babel": "~5.0.0",
|
||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||
"@vue/cli-plugin-router": "~5.0.0",
|
||||
"@vue/cli-plugin-vuex": "~5.0.0",
|
||||
"@vue/cli-service": "~5.0.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-vue": "^8.0.3",
|
||||
"jshint": "^2.13.5",
|
||||
"mini-css-extract-plugin": "^2.6.1",
|
||||
"style-loader": "^3.3.1",
|
||||
"terser-webpack-plugin": "^5.3.6",
|
||||
"vue-loader": "^17.0.0",
|
||||
"webpack-bundle-analyzer": "^4.6.1",
|
||||
"webpack-bundle-tracker": "^1.6.0",
|
||||
"webpack-cli": "^4.10.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/vue3-essential",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "@babel/eslint-parser"
|
||||
},
|
||||
"rules": {}
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"css-loader": "^7.1.5",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-plugin-vue": "^10.11.1",
|
||||
"jsdom": "^30.1.2",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"style-loader": "^4.0.0",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"vitest": "^5.0.4",
|
||||
"vue-loader": "^17.4.2",
|
||||
"webpack": "^5.111.1",
|
||||
"webpack-bundle-tracker": "^3.2.4",
|
||||
"webpack-cli": "^7.2.3",
|
||||
"webpack-dev-server": "^6.0.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"defaults"
|
||||
|
||||
+41
-28
@@ -1,40 +1,53 @@
|
||||
import axios from "axios";
|
||||
import router from "@/router";
|
||||
import store from "@/store";
|
||||
import jwtDecode from "jwt-decode";
|
||||
|
||||
async function get_access_token() {
|
||||
let access = jwtDecode(store.state.jwt.access)
|
||||
let refresh = jwtDecode(store.state.jwt.refresh)
|
||||
if (access.exp - Date.now()/1000 < 5) {
|
||||
if (refresh.exp - Date.now()/1000 < 5) {
|
||||
await router.push({name: 'login'})
|
||||
return null
|
||||
} else {
|
||||
return store.dispatch('refreshToken').then(() => {return store.state.jwt.access})
|
||||
}
|
||||
}
|
||||
return store.state.jwt.access
|
||||
// Authentication is cookie based: the browser sends the HttpOnly access token
|
||||
// automatically, so there is no Authorization header to set here.
|
||||
const axios_jwt = axios.create({ withCredentials: true });
|
||||
|
||||
// Add the CSRF header if a csrftoken cookie exists. Cookie authentication is
|
||||
// protected against cross-site form posts by SameSite=Lax, so this is only used
|
||||
// when the backend does set the cookie.
|
||||
axios_jwt.interceptors.request.use(function(config) {
|
||||
const csrfToken = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrftoken='))
|
||||
?.split('=')[1];
|
||||
|
||||
if (csrfToken) {
|
||||
config.headers['X-CSRFToken'] = csrfToken;
|
||||
}
|
||||
|
||||
const axios_jwt = axios.create();
|
||||
return config;
|
||||
});
|
||||
|
||||
axios_jwt.interceptors.request.use(async function (config) {
|
||||
let access_token = await get_access_token().catch(() => {
|
||||
if (router.currentRoute.value.fullPath.includes('login')){
|
||||
router.push({name: 'login'})
|
||||
}else {
|
||||
router.push({name: 'login', query: { next: router.currentRoute.value.fullPath }})
|
||||
// A 401 means the access cookie is no longer valid. Try to refresh it once; if
|
||||
// that fails the session is gone, so log out and send the user to the login page.
|
||||
axios_jwt.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const config = error.config;
|
||||
|
||||
if (error.response?.status === 401 && config && !config._retried && !config.url.includes('/token/')) {
|
||||
config._retried = true;
|
||||
try {
|
||||
await store.dispatch('refreshToken');
|
||||
return await axios_jwt.request(config);
|
||||
} catch {
|
||||
store.commit('logOut');
|
||||
router.push({
|
||||
name: 'login',
|
||||
query: {
|
||||
next: config.url,
|
||||
error: 'Your session has expired. Please log in again.'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
config.headers = {
|
||||
Authorization: "Bearer " + access_token
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return config
|
||||
}, function (error) {
|
||||
// Do something with request error
|
||||
return Promise.reject(error);
|
||||
});
|
||||
);
|
||||
|
||||
export default axios_jwt
|
||||
@@ -23,9 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<caption>
|
||||
<h2>Reading History</h2>
|
||||
</caption>
|
||||
<h2>Reading History</h2>
|
||||
</div>
|
||||
<div class="row">
|
||||
<table class="table table-striped table-bordered">
|
||||
@@ -138,6 +136,9 @@ export default {
|
||||
mounted() {
|
||||
this.updateComicList()
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearTimeout(this.timeout)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<li><a class="dropdown-item" @click="markAll('mark_unread')"><font-awesome-icon icon='book' />Mark Un-read</a></li>
|
||||
<li><a class="dropdown-item" @click="markAll('mark_read')"><font-awesome-icon icon='book-open' />Mark read</a></li>
|
||||
</ul>
|
||||
<button type="button" class="btn btn-outline-secondary" :disabled="scanning" title="Read this folder from disk again" @click="scanFolder">
|
||||
<font-awesome-icon icon='refresh' />{{ scanning ? 'Scanning…' : 'Scan' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-6 row-cols-xl-auto mt-1" >
|
||||
@@ -49,7 +52,8 @@ export default {
|
||||
filter_read: false,
|
||||
filter_unread: false
|
||||
},
|
||||
loading: true
|
||||
loading: true,
|
||||
scanning: false
|
||||
}},
|
||||
props: {
|
||||
selector: String
|
||||
@@ -68,6 +72,18 @@ export default {
|
||||
})
|
||||
.catch((error) => {console.log(error)})
|
||||
},
|
||||
scanFolder () {
|
||||
this.scanning = true
|
||||
api.post('/api/browse/scan/', { selector: this.selector || null })
|
||||
.then(response => {
|
||||
this.comics = response.data
|
||||
this.scanning = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error)
|
||||
this.scanning = false
|
||||
})
|
||||
},
|
||||
markPreviousRead (selector) {
|
||||
let selectors = []
|
||||
this.comics.every((item) => {
|
||||
|
||||
@@ -1,19 +1,74 @@
|
||||
<template>
|
||||
<div class="reveal" id="comic_box" ref="comic_box" >
|
||||
<div id="slides_div" class="slides" ref="slides">
|
||||
<section class="" v-for="page in pages" :key="page" :data-menu-title="page" hidden>
|
||||
<img :data-src="'/api/read/' + selector + '/image/' + page + '/'" class="w-100" :alt="page">
|
||||
<div class="reveal" :class="{ continuous: mode === 'scroll' }" id="comic_box" ref="comic_box">
|
||||
<div id="slides_div" class="slides" ref="slides">
|
||||
<section
|
||||
v-for="page in visible_pages"
|
||||
:key="page"
|
||||
:data-page="page"
|
||||
:data-menu-title="page"
|
||||
:hidden="mode === 'paged'"
|
||||
>
|
||||
<img
|
||||
:class="{ shown: mode === 'scroll' && inview.has(page) }"
|
||||
:src="mode === 'scroll' ? image_url(page) : null"
|
||||
:data-src="mode === 'paged' ? image_url(page) : null"
|
||||
:loading="mode === 'scroll' ? 'lazy' : null"
|
||||
class="w-100"
|
||||
:alt="page"
|
||||
>
|
||||
</section>
|
||||
</div>
|
||||
<div class="sentinel" ref="sentinel" v-if="mode === 'scroll' && rendered < pages"></div>
|
||||
</div>
|
||||
<div class="row navButtons pb-2">
|
||||
<comic-paginate
|
||||
v-model="paginate_page"
|
||||
:page_count="pages"
|
||||
@setPage="this.setPage"
|
||||
@prevComic="prevComic"
|
||||
@nextComic="nextComic"
|
||||
/>
|
||||
<div class="col-auto">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Reading mode">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
:class="{ active: mode === 'paged' }"
|
||||
@click="setMode('paged')"
|
||||
>One page</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
:class="{ active: mode === 'scroll' }"
|
||||
@click="setMode('scroll')"
|
||||
>Continuous</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<comic-paginate
|
||||
v-model="paginate_page"
|
||||
:page_count="pages"
|
||||
@setPage="this.setPage"
|
||||
@prevComic="prevComic"
|
||||
@nextComic="nextComic"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
:class="{ active: show_shortcuts }"
|
||||
:aria-pressed="show_shortcuts ? 'true' : 'false'"
|
||||
@click="show_shortcuts = !show_shortcuts"
|
||||
>?</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shortcuts" v-if="show_shortcuts" role="dialog" aria-label="Keyboard shortcuts">
|
||||
<div class="shortcuts-head">
|
||||
<strong>Keyboard shortcuts</strong>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @click="show_shortcuts = false">Close</button>
|
||||
</div>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr v-for="shortcut in shortcuts" :key="shortcut.keys">
|
||||
<td><kbd>{{ shortcut.keys }}</kbd></td>
|
||||
<td>{{ shortcut.action }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -21,9 +76,17 @@
|
||||
<script>
|
||||
import Reveal from "reveal.js";
|
||||
import api from "@/api";
|
||||
import * as Hammer from 'hammerjs'
|
||||
import Hammer from 'hammerjs'
|
||||
import ComicPaginate from "@/components/ComicPaginate";
|
||||
|
||||
// How many pages are rendered at a time in continuous mode. The window grows as the
|
||||
// reader approaches the end of what has already been rendered.
|
||||
const WINDOW_SIZE = 20;
|
||||
|
||||
// Held keys repeat, so a key press is handled immediately and then ignored for this
|
||||
// long. The page write is already debounced by queue_save, so nothing is sent per key.
|
||||
const KEY_THROTTLE_MS = 100;
|
||||
|
||||
export default {
|
||||
name: "TheComicReader",
|
||||
components: {ComicPaginate},
|
||||
@@ -31,23 +94,79 @@ export default {
|
||||
return {
|
||||
current_page: 0,
|
||||
paginate_page: 1,
|
||||
inview: new Set(),
|
||||
deck: null,
|
||||
title: '',
|
||||
prev_comic: {},
|
||||
next_comic: {},
|
||||
pages: 1,
|
||||
rendered: 0,
|
||||
visible: new Map(),
|
||||
page_observer: null,
|
||||
growth_observer: null,
|
||||
save_timeout: null,
|
||||
key_locked: false,
|
||||
key_timeout: null,
|
||||
show_shortcuts: false,
|
||||
shortcuts: [
|
||||
{ keys: '← / →', action: 'Previous / next page' },
|
||||
{ keys: '↑ / ↓', action: 'Scroll within the page' },
|
||||
{ keys: 'Space', action: 'Next page' },
|
||||
{ keys: 'Shift + Space', action: 'Previous page' },
|
||||
{ keys: 'n / p', action: 'Next / previous page' },
|
||||
{ keys: 'Home / End', action: 'First / last page' },
|
||||
{ keys: 'm', action: 'Switch reading mode' },
|
||||
{ keys: '?', action: 'Show or hide this list' },
|
||||
{ keys: 'Esc', action: 'Hide this list' },
|
||||
],
|
||||
}
|
||||
},
|
||||
props: {
|
||||
selector: String
|
||||
},
|
||||
computed: {
|
||||
mode () {
|
||||
return this.$store.state.reader_mode
|
||||
},
|
||||
set_read_url () {
|
||||
return '/api/read/' + this.selector + '/set_page/'
|
||||
},
|
||||
visible_pages () {
|
||||
if (this.mode === 'paged') {
|
||||
return Array.from({length: this.pages}, (_item, index) => index + 1)
|
||||
}
|
||||
return Array.from({length: Math.min(this.rendered, this.pages)}, (_item, index) => index + 1)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
prevPage(){
|
||||
image_url (page) {
|
||||
return '/api/read/' + this.selector + '/image/' + page + '/'
|
||||
},
|
||||
prevPage(behavior = 'smooth'){
|
||||
if (this.mode === 'scroll') {
|
||||
if (this.current_page <= 0) {
|
||||
this.prevComic()
|
||||
} else {
|
||||
this.current_page -= 1
|
||||
this.scroll_to_page(this.current_page + 1, behavior)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// reveal initializes asynchronously, so a key pressed before the promise resolves
|
||||
// has no deck to move.
|
||||
if (!this.deck) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.deck.isFirstSlide()){
|
||||
this.prevComic()
|
||||
} else {
|
||||
this.current_page -= 1
|
||||
this.deck.slide(this.current_page)
|
||||
// Pages can be taller than the viewport, so show the top of the new page instead
|
||||
// of keeping the scroll offset left over from the previous one.
|
||||
this.scroll_to_page(this.current_page + 1, behavior)
|
||||
}
|
||||
},
|
||||
prevComic(){
|
||||
@@ -62,59 +181,179 @@ export default {
|
||||
params: {selector: this.next_comic.selector}
|
||||
})
|
||||
},
|
||||
nextPage(){
|
||||
nextPage(behavior = 'smooth'){
|
||||
if (this.mode === 'scroll') {
|
||||
if (this.current_page + 1 >= this.pages) {
|
||||
this.nextComic()
|
||||
} else {
|
||||
this.current_page += 1
|
||||
this.scroll_to_page(this.current_page + 1, behavior)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.deck) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.deck.isLastSlide()){
|
||||
this.nextComic()
|
||||
} else {
|
||||
this.current_page += 1
|
||||
this.deck.slide(this.current_page)
|
||||
this.scroll_to_page(this.current_page + 1, behavior)
|
||||
}
|
||||
},
|
||||
setPage(pageNum){
|
||||
if (this.mode === 'scroll') {
|
||||
const page = Math.min(pageNum, this.pages)
|
||||
this.rendered = Math.max(this.rendered, page)
|
||||
this.current_page = page - 1
|
||||
this.$nextTick(() => {
|
||||
this.observe_pages()
|
||||
this.scroll_to_page(page)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.deck) {
|
||||
return
|
||||
}
|
||||
|
||||
this.current_page = pageNum-1
|
||||
this.deck.slide(this.current_page)
|
||||
},
|
||||
keyPressDebounce(e){
|
||||
clearTimeout(this.key_timeout)
|
||||
this.key_timeout = setTimeout(() => {this.keyPress(e)}, 50)
|
||||
scroll_to_page (page, behavior = 'smooth') {
|
||||
const section = this.$refs.slides.querySelector('section[data-page="' + page + '"]')
|
||||
if (section) {
|
||||
section.scrollIntoView({behavior: behavior, block: 'start'})
|
||||
}
|
||||
},
|
||||
keyPress(e) {
|
||||
if (e.key === 'ArrowRight') {
|
||||
this.nextPage()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
this.prevPage()
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
window.scrollTo({
|
||||
top: window.scrollY-window.innerHeight*.7,
|
||||
left: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
window.scrollTo({
|
||||
top: window.scrollY+window.innerHeight*.7,
|
||||
left: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
keyPress (e) {
|
||||
// Alt+Left is the browser's back button and Ctrl/Cmd+Arrow moves a caret in a
|
||||
// form control, so only a bare arrow key pages.
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) {
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'current_page' (new_page) {
|
||||
this.paginate_page = new_page + 1
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
const set_read_url = '/api/read/' + this.selector + '/set_page/'
|
||||
let comic_data_url = '/api/read/' + this.selector + '/'
|
||||
window.addEventListener('keyup', this.keyPressDebounce)
|
||||
api.get(comic_data_url)
|
||||
.then(response => {
|
||||
this.title = response.data.title
|
||||
this.current_page = response.data.last_read_page
|
||||
this.prev_comic = response.data.prev_comic
|
||||
this.next_comic = response.data.next_comic
|
||||
this.pages = response.data.pages
|
||||
|
||||
// These only show or hide the shortcut list, so they still work when a control
|
||||
// has focus.
|
||||
if (e.key === 'Escape') {
|
||||
this.show_shortcuts = false
|
||||
return
|
||||
}
|
||||
if (e.key === '?') {
|
||||
this.show_shortcuts = !this.show_shortcuts
|
||||
return
|
||||
}
|
||||
|
||||
// The reader listens on window, so a key pressed while a control has focus would
|
||||
// otherwise page as well as activate that control.
|
||||
if (e.target && e.target.closest && e.target.closest('input, select, textarea, button')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.key_locked) {
|
||||
return
|
||||
}
|
||||
this.key_locked = true
|
||||
this.key_timeout = setTimeout(() => { this.key_locked = false }, KEY_THROTTLE_MS)
|
||||
|
||||
// Left and Right change pages. Up and Down are left to the browser's default
|
||||
// scrolling: the page is taller than the viewport, and intercepting them here would
|
||||
// double up with the native scroll. In continuous mode the observer records whichever
|
||||
// page is topmost, so scrolling inside a page does not change the saved page.
|
||||
const key = e.key.toLowerCase()
|
||||
switch (key) {
|
||||
case 'arrowright':
|
||||
case 'n':
|
||||
this.nextPage('auto')
|
||||
return
|
||||
case 'arrowleft':
|
||||
case 'p':
|
||||
this.prevPage('auto')
|
||||
return
|
||||
case ' ':
|
||||
// Space would otherwise scroll the page as well as turning it.
|
||||
e.preventDefault?.()
|
||||
if (e.shiftKey) {
|
||||
this.prevPage('auto')
|
||||
} else {
|
||||
this.nextPage('auto')
|
||||
}
|
||||
return
|
||||
case 'home':
|
||||
case 'end':
|
||||
// Same for Home/End, which the browser would scroll to the page edges.
|
||||
e.preventDefault?.()
|
||||
this.setPage(key === 'home' ? 1 : this.pages)
|
||||
return
|
||||
case 'm':
|
||||
this.setMode(this.mode === 'paged' ? 'scroll' : 'paged')
|
||||
return
|
||||
}
|
||||
},
|
||||
// Continuous mode: reveal is not used for visibility here, so the page in view is
|
||||
// worked out from what is actually intersecting the viewport.
|
||||
start_scroll () {
|
||||
this.visible.clear()
|
||||
this.inview.clear()
|
||||
|
||||
this.page_observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
this.visible.set(entry.target, entry.boundingClientRect.top)
|
||||
this.inview.add(Number(entry.target.dataset.page))
|
||||
} else {
|
||||
this.visible.delete(entry.target)
|
||||
this.inview.delete(Number(entry.target.dataset.page))
|
||||
}
|
||||
}
|
||||
|
||||
if (this.visible.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const top = [...this.visible.entries()].sort((a, b) => a[1] - b[1])[0][0]
|
||||
const page = Number(top.dataset.page)
|
||||
if (page && page !== this.current_page + 1) {
|
||||
this.current_page = page - 1
|
||||
this.queue_save(page - 1)
|
||||
}
|
||||
}, { threshold: 0 })
|
||||
|
||||
this.growth_observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
this.extend_window()
|
||||
}
|
||||
}, { rootMargin: '1200px 0px' })
|
||||
|
||||
this.observe_pages()
|
||||
if (this.$refs.sentinel) {
|
||||
this.growth_observer.observe(this.$refs.sentinel)
|
||||
}
|
||||
this.scroll_to_page(this.current_page + 1)
|
||||
},
|
||||
observe_pages () {
|
||||
this.$refs.slides.querySelectorAll('section[data-page]').forEach((section) => {
|
||||
this.page_observer.observe(section)
|
||||
})
|
||||
},
|
||||
extend_window () {
|
||||
if (this.rendered >= this.pages) {
|
||||
return
|
||||
}
|
||||
this.rendered = Math.min(this.pages, this.rendered + WINDOW_SIZE)
|
||||
this.$nextTick(() => this.observe_pages())
|
||||
},
|
||||
// One write per burst of scrolling rather than one per intersection.
|
||||
queue_save (page) {
|
||||
clearTimeout(this.save_timeout)
|
||||
this.save_timeout = setTimeout(() => {
|
||||
api.put(this.set_read_url, {page: page})
|
||||
}, 500)
|
||||
},
|
||||
start_paged () {
|
||||
this.deck = Reveal(this.$refs.comic_box)
|
||||
this.deck.initialize({
|
||||
controls: false,
|
||||
@@ -130,29 +369,99 @@ export default {
|
||||
plugins: [ ]
|
||||
}).then(() => {
|
||||
this.deck.slide(this.current_page)
|
||||
api.put(set_read_url, {page: this.current_page})
|
||||
this.deck.on( 'slidechanged', (event) => {
|
||||
this.slideChanged = (event) => {
|
||||
this.$refs.comic_box.scrollIntoView({behavior: 'smooth'})
|
||||
api.put(set_read_url, {page: event.indexh})
|
||||
});
|
||||
api.put(this.set_read_url, {page: event.indexh})
|
||||
}
|
||||
this.deck.on('slidechanged', this.slideChanged);
|
||||
})
|
||||
},
|
||||
async setMode (mode) {
|
||||
if (mode === this.mode) {
|
||||
return
|
||||
}
|
||||
|
||||
this.hammertime = new Hammer(this.$refs.comic_box, {})
|
||||
this.hammertime.on('swipeleft', (_e, self=this) => {
|
||||
self.nextPage()
|
||||
})
|
||||
this.hammertime.on('swiperight', (_e, self=this) => {
|
||||
self.prevPage()
|
||||
})
|
||||
this.hammertime.on('tap', (_e, self=this) => {
|
||||
self.nextPage()
|
||||
})
|
||||
this.$store.commit('setReaderMode', mode)
|
||||
this.teardown()
|
||||
await this.$nextTick()
|
||||
|
||||
if (mode === 'scroll') {
|
||||
this.rendered = Math.min(this.pages, Math.max(WINDOW_SIZE, this.current_page + 1))
|
||||
await this.$nextTick()
|
||||
this.start_scroll()
|
||||
} else {
|
||||
this.start_paged()
|
||||
}
|
||||
},
|
||||
teardown () {
|
||||
clearTimeout(this.save_timeout)
|
||||
this.inview.clear()
|
||||
|
||||
if (this.page_observer) {
|
||||
this.page_observer.disconnect()
|
||||
this.page_observer = null
|
||||
}
|
||||
if (this.growth_observer) {
|
||||
this.growth_observer.disconnect()
|
||||
this.growth_observer = null
|
||||
}
|
||||
this.visible.clear()
|
||||
|
||||
if (this.deck) {
|
||||
if (this.slideChanged) {
|
||||
this.deck.off('slidechanged', this.slideChanged)
|
||||
}
|
||||
try {
|
||||
this.deck.destroy()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
this.deck = null
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'current_page' (new_page) {
|
||||
this.paginate_page = new_page + 1
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
let comic_data_url = '/api/read/' + this.selector + '/'
|
||||
window.addEventListener('keydown', this.keyPress)
|
||||
api.get(comic_data_url)
|
||||
.then(response => {
|
||||
this.title = response.data.title
|
||||
this.current_page = response.data.last_read_page
|
||||
this.prev_comic = response.data.prev_comic
|
||||
this.next_comic = response.data.next_comic
|
||||
this.pages = response.data.pages
|
||||
|
||||
if (this.mode === 'scroll') {
|
||||
this.rendered = Math.min(this.pages, Math.max(WINDOW_SIZE, this.current_page + 1))
|
||||
return this.$nextTick().then(() => this.start_scroll())
|
||||
}
|
||||
return this.$nextTick().then(() => this.start_paged())
|
||||
})
|
||||
.then(() => {
|
||||
api.put(this.set_read_url, {page: this.current_page})
|
||||
})
|
||||
.catch((error) => {console.log(error)})
|
||||
|
||||
this.hammertime = new Hammer(this.$refs.comic_box, {})
|
||||
this.hammertime.on('swipeleft', (_e, self=this) => {
|
||||
self.nextPage()
|
||||
})
|
||||
this.hammertime.on('swiperight', (_e, self=this) => {
|
||||
self.prevPage()
|
||||
})
|
||||
this.hammertime.on('tap', (_e, self=this) => {
|
||||
self.nextPage()
|
||||
})
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('keyup', this.keyPressDebounce)
|
||||
window.removeEventListener('keydown', this.keyPress)
|
||||
clearTimeout(this.key_timeout)
|
||||
this.teardown()
|
||||
try {
|
||||
this.hammertime.off('swipeleft')
|
||||
this.hammertime.off('swiperight')
|
||||
@@ -179,4 +488,64 @@ export default {
|
||||
section {
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
bottom: 64px;
|
||||
max-width: 340px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #adb5bd;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
font-size: 0.85rem;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.shortcuts-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.shortcuts td {
|
||||
padding: 2px 8px 2px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.shortcuts kbd {
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* reveal.css is deliberately not loaded, so visibility is the `hidden` attribute and
|
||||
reveal's own transition classes do nothing. A transition cannot run across
|
||||
display:none -> block, so paged mode uses an animation on the slide that becomes
|
||||
present; continuous mode uses a transition driven by the IntersectionObserver. */
|
||||
.reveal section.present img {
|
||||
animation: page-fade 0.3s ease;
|
||||
}
|
||||
|
||||
.continuous img {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.continuous img.shown {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes page-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal section.present img,
|
||||
.continuous img {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,8 @@
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<!-- Show these links only when user is authenticated -->
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0" v-if="isAuthenticated">
|
||||
<li class="nav-item">
|
||||
<router-link :to="{name: 'browse'}" class="nav-link" >Browse</router-link>
|
||||
</li>
|
||||
@@ -26,6 +27,12 @@
|
||||
<a class="nav-link" @click="logout">Log Out</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Show login link when user is not authenticated -->
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0" v-else>
|
||||
<li class="nav-item">
|
||||
<router-link :to="{name: 'login'}" class="nav-link">Log In</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -42,9 +49,14 @@ export default {
|
||||
visible: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAuthenticated() {
|
||||
return this.$store.getters.isAuthenticated;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
logout () {
|
||||
store.commit('logOut')
|
||||
store.dispatch('logout')
|
||||
router.push({name: 'login'})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
<template>
|
||||
<div class="container" ref="pdfContainer">
|
||||
<div class="row w-100 pb-5 mb-5" v-if="loaded">
|
||||
<pdf :src="pdfdata" :page="page" ref="pdfWindow" :resize="true">
|
||||
<template v-slot:loading>
|
||||
loading content here...
|
||||
</template>
|
||||
</pdf>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row navButtons pb-2">
|
||||
<comic-paginate
|
||||
v-model="page"
|
||||
:page_count="numPages"
|
||||
@setPage="setPage"
|
||||
@prevComic="prevComic"
|
||||
@nextComic="nextComic"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import pdfvuer from 'pdfvuer'
|
||||
import api from "@/api";
|
||||
import * as Hammer from 'hammerjs'
|
||||
import ComicPaginate from "@/components/ComicPaginate";
|
||||
|
||||
export default {
|
||||
name: "ThePdfReader",
|
||||
components: {
|
||||
ComicPaginate,
|
||||
pdf: pdfvuer
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
page: 1,
|
||||
numPages: 0,
|
||||
pdfdata: undefined,
|
||||
errors: [],
|
||||
scale: 'page-width',
|
||||
loaded: false,
|
||||
key_timeout: null,
|
||||
hammertime: null,
|
||||
next_comic: {},
|
||||
prev_comic: {}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
selector: String
|
||||
},
|
||||
computed: {
|
||||
},
|
||||
mounted () {
|
||||
this.getPdf()
|
||||
window.addEventListener('keyup', this.keyPressDebounce)
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('keyup', this.keyPressDebounce)
|
||||
},
|
||||
watch: {
|
||||
},
|
||||
methods: {
|
||||
getPdf () {
|
||||
let comic_data_url = '/api/read/' + this.selector + '/'
|
||||
api.get(comic_data_url)
|
||||
.then(response => {
|
||||
let parameter = {
|
||||
url: '/api/read/' + this.selector + '/pdf/',
|
||||
httpHeaders: { Authorization: 'Bearer ' + this.$store.state.jwt.access },
|
||||
withCredentials: true,
|
||||
}
|
||||
this.pdfdata = pdfvuer.createLoadingTask(parameter);
|
||||
this.pdfdata.then(pdf => {
|
||||
this.numPages = pdf.numPages;
|
||||
this.loaded = true
|
||||
this.page = response.data.last_read_page+1
|
||||
this.setReadPage(this.page)
|
||||
this.next_comic = response.data.next_comic
|
||||
this.prev_comic = response.data.prev_comic
|
||||
this.hammertime = new Hammer(this.$refs.pdfContainer, {})
|
||||
this.hammertime.on('swipeleft', (_e, self=this) => {
|
||||
self.nextPage()
|
||||
})
|
||||
this.hammertime.on('swiperight', (_e, self=this) => {
|
||||
self.prevPage()
|
||||
})
|
||||
this.hammertime.on('tap', (_e, self=this) => {
|
||||
self.nextPage()
|
||||
})
|
||||
}).catch(e => {console.log(e)});
|
||||
})
|
||||
|
||||
},
|
||||
prevComic(){
|
||||
this.$router.push({
|
||||
name: this.prev_comic.route,
|
||||
params: {selector: this.prev_comic.selector}
|
||||
})
|
||||
},
|
||||
nextComic(){
|
||||
this.$router.push({
|
||||
name: this.next_comic.route,
|
||||
params: {selector: this.next_comic.selector}
|
||||
})
|
||||
},
|
||||
nextPage () {
|
||||
if (this.page < this.numPages){
|
||||
this.page += 1
|
||||
this.setReadPage(this.page)
|
||||
} else {
|
||||
this.nextComic()
|
||||
}
|
||||
},
|
||||
prevPage() {
|
||||
if (this.page > 1){
|
||||
this.page -= 1
|
||||
this.setReadPage(this.page)
|
||||
} else {
|
||||
this.prevComic()
|
||||
}
|
||||
},
|
||||
setPage(num) {
|
||||
this.page = num
|
||||
this.setReadPage(this.page)
|
||||
},
|
||||
setReadPage(num){
|
||||
this.$refs.pdfContainer.scrollIntoView()
|
||||
let payload = {
|
||||
page: num-1
|
||||
}
|
||||
api.put('/api/read/'+ this.selector +'/set_page/', payload)
|
||||
},
|
||||
keyPressDebounce(e){
|
||||
clearTimeout(this.key_timeout)
|
||||
this.key_timeout = setTimeout(() => {this.keyPress(e)}, 50)
|
||||
},
|
||||
keyPress(e) {
|
||||
if (e.key === 'ArrowRight') {
|
||||
this.nextPage()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
this.prevPage()
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
window.scrollTo({
|
||||
top: window.scrollY-window.innerHeight*.7,
|
||||
left: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
window.scrollTo({
|
||||
top: window.scrollY+window.innerHeight*.7,
|
||||
left: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navButtons {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 0;
|
||||
z-index: 1030;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -23,7 +23,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<caption>
|
||||
<h2>Recent Comics - <a :href="'/feed/' + this.feed_id + '/'">Feed</a></h2>
|
||||
Mark selected issues as:
|
||||
<select class="form-select-sm" name="func" id="func_selector" @change="this.performFunction()" v-model="func_selected">
|
||||
@@ -31,7 +30,6 @@
|
||||
<option value="mark_read">Read</option>
|
||||
<option value="mark_unread">Un-Read</option>
|
||||
</select>
|
||||
</caption>
|
||||
</div>
|
||||
<div class="row">
|
||||
<table class="table table-striped table-bordered">
|
||||
@@ -177,6 +175,9 @@ export default {
|
||||
this.feed_id = response.data.feed_id
|
||||
})
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearTimeout(this.timeout)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -15,15 +15,20 @@ import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
/* import specific icons */
|
||||
import {faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck} from '@fortawesome/free-solid-svg-icons'
|
||||
library.add(faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck)
|
||||
import {faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck, faRefresh} from '@fortawesome/free-solid-svg-icons'
|
||||
library.add(faBook, faBookOpen, faEdit, faTurnUp, faTimes, faCheck, faRefresh)
|
||||
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
|
||||
Vue.createApp(App)
|
||||
const app = Vue.createApp(App)
|
||||
.use(ToastPlugin)
|
||||
.use(store)
|
||||
.use(router)
|
||||
.component('font-awesome-icon', FontAwesomeIcon)
|
||||
.mount('#app')
|
||||
|
||||
// Validate the cookie before the first navigation, so the router guards see the
|
||||
// real session instead of whatever is left in localStorage.
|
||||
store.dispatch('restoreSession').finally(() => {
|
||||
app.mount('#app')
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import store from '@/store'
|
||||
|
||||
const ReadView = () => import('@/views/ReadView')
|
||||
const RecentView = () => import('@/views/RecentView')
|
||||
@@ -8,6 +9,26 @@ const UserView = () => import('@/views/UserView')
|
||||
const LoginView = () => import('@/views/LoginView')
|
||||
const HistoryView = () => import('@/views/HistoryView')
|
||||
|
||||
// Navigation guard to check if user is authenticated
|
||||
function requireAuth(to) {
|
||||
if (!store.getters.isAuthenticated) {
|
||||
return {
|
||||
name: 'login',
|
||||
query: { next: to.fullPath, error: 'Please log in to access this page' }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation guard to check if user is admin
|
||||
function requireAdmin(to) {
|
||||
if (!store.getters.isAuthenticated || !store.getters.is_superuser) {
|
||||
return {
|
||||
name: 'login',
|
||||
query: { next: to.fullPath, error: 'Admin access required' }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
@@ -20,13 +41,15 @@ const routes = [
|
||||
path: '/browse/:selector?',
|
||||
name: 'browse',
|
||||
component: BrowseView,
|
||||
props: true
|
||||
props: true,
|
||||
beforeEnter: requireAuth
|
||||
},
|
||||
{
|
||||
path: '/read/:selector',
|
||||
name: 'read',
|
||||
component: ReadView,
|
||||
props: true
|
||||
props: true,
|
||||
beforeEnter: requireAuth
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
@@ -36,23 +59,27 @@ const routes = [
|
||||
{
|
||||
path: '/recent',
|
||||
name: 'recent',
|
||||
component: RecentView
|
||||
component: RecentView,
|
||||
beforeEnter: requireAuth
|
||||
},
|
||||
{
|
||||
path: '/history',
|
||||
name: 'history',
|
||||
component: HistoryView
|
||||
component: HistoryView,
|
||||
beforeEnter: requireAuth
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
name: 'account',
|
||||
component: AccountView
|
||||
component: AccountView,
|
||||
beforeEnter: requireAuth
|
||||
},
|
||||
{
|
||||
path: '/user/:userid?',
|
||||
name: 'user',
|
||||
component: UserView,
|
||||
props: true
|
||||
props: true,
|
||||
beforeEnter: requireAdmin
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
|
||||
+96
-65
@@ -1,17 +1,11 @@
|
||||
import { createStore } from 'vuex'
|
||||
import axios from 'axios'
|
||||
import jwtDecode from 'jwt-decode'
|
||||
import {useToast} from "vue-toast-notification";
|
||||
import router from "@/router";
|
||||
import api from "@/api";
|
||||
|
||||
function get_jwt_from_storage(){
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('t'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// Tokens live in HttpOnly cookies set by the backend, so JavaScript cannot read
|
||||
// them. Only the user profile is kept in localStorage, to avoid a blank page
|
||||
// before the first request has been made.
|
||||
function get_user_from_storage(){
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('u'))
|
||||
@@ -20,11 +14,27 @@ function get_user_from_storage(){
|
||||
}
|
||||
}
|
||||
|
||||
// Reading mode is a preference rather than part of the session, so it is kept on its own.
|
||||
function get_reader_mode_from_storage(){
|
||||
const mode = localStorage.getItem('reader_mode')
|
||||
return mode === 'scroll' ? 'scroll' : 'paged'
|
||||
}
|
||||
|
||||
function session_expired_query(next){
|
||||
return {
|
||||
name: 'login',
|
||||
query: {
|
||||
next: next,
|
||||
error: 'Your session has expired. Please log in again.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default createStore({
|
||||
state: {
|
||||
jwt: get_jwt_from_storage(),
|
||||
filters: {},
|
||||
user: get_user_from_storage(),
|
||||
reader_mode: get_reader_mode_from_storage(),
|
||||
classifications: [
|
||||
{label: 'G', value: '0'},
|
||||
{label: 'PG', value: '1'},
|
||||
@@ -40,23 +50,27 @@ export default createStore({
|
||||
} else {
|
||||
return state.user.is_superuser
|
||||
}
|
||||
},
|
||||
isAuthenticated (state) {
|
||||
return state.user !== null
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
updateToken(state, newToken){
|
||||
localStorage.setItem('t', JSON.stringify(newToken));
|
||||
state.jwt = newToken;
|
||||
},
|
||||
logOut(state){
|
||||
localStorage.removeItem('t');
|
||||
localStorage.removeItem('u')
|
||||
state.jwt = null;
|
||||
state.user = null
|
||||
},
|
||||
updateUser(state, userData){
|
||||
localStorage.setItem('u', JSON.stringify(userData));
|
||||
state.user = userData
|
||||
},
|
||||
setReaderMode(state, mode){
|
||||
if (mode !== 'paged' && mode !== 'scroll') {
|
||||
return
|
||||
}
|
||||
localStorage.setItem('reader_mode', mode);
|
||||
state.reader_mode = mode
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
obtainToken(context, {username, password}){
|
||||
@@ -64,60 +78,77 @@ export default createStore({
|
||||
username: username,
|
||||
password: password
|
||||
}
|
||||
axios.post('/api/token/', payload)
|
||||
.then((response)=>{
|
||||
context.commit('updateToken', response.data);
|
||||
api.get('/api/account').then(response => {
|
||||
context.commit('updateUser', response.data)
|
||||
})
|
||||
if ('next' in router.currentRoute.value.query) {
|
||||
router.push(router.currentRoute.value.query.next)
|
||||
} else {
|
||||
router.push('browse')
|
||||
}
|
||||
|
||||
})
|
||||
.catch((error)=>{
|
||||
const $toast = useToast();
|
||||
if (error.response.data.detail) {
|
||||
$toast.error(error.response.data.detail, {position:'top'});
|
||||
}
|
||||
if (error.response.data.username) {
|
||||
$toast.error("Username: " + error.response.data.username, {position:'top'});
|
||||
}
|
||||
if (error.response.data.password) {
|
||||
$toast.error("Password: " + error.response.data.password, {position:'top'});
|
||||
}
|
||||
|
||||
})
|
||||
// The response body is empty: the backend stores the tokens as HttpOnly cookies.
|
||||
api.post('/api/token/', payload)
|
||||
.then(() => api.get('/api/account'))
|
||||
.then((response) => {
|
||||
context.commit('updateUser', response.data)
|
||||
if ('next' in router.currentRoute.value.query) {
|
||||
router.push(router.currentRoute.value.query.next)
|
||||
} else {
|
||||
router.push('browse')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const $toast = useToast();
|
||||
const data = error.response?.data ?? {};
|
||||
if (data.detail) {
|
||||
$toast.error(data.detail, {position:'top'});
|
||||
}
|
||||
if (data.username) {
|
||||
$toast.error("Username: " + data.username, {position:'top'});
|
||||
}
|
||||
if (data.password) {
|
||||
$toast.error("Password: " + data.password, {position:'top'});
|
||||
}
|
||||
})
|
||||
},
|
||||
refreshToken(){
|
||||
const payload = {
|
||||
refresh: this.state.jwt.refresh
|
||||
if (!this.state.user) {
|
||||
return Promise.reject(new Error('Not logged in'));
|
||||
}
|
||||
return axios.post('/api/token/refresh/', payload)
|
||||
.then((response)=>{
|
||||
this.commit('updateToken', response.data)
|
||||
})
|
||||
.catch((error)=>{
|
||||
console.log(error)
|
||||
// router.push({name: 'login', query: {area: 'store'}})
|
||||
})
|
||||
|
||||
// The refresh token is read from the cookie by the backend.
|
||||
return api.post('/api/token/refresh/', {})
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
console.error('Token refresh failed:', error);
|
||||
this.commit('logOut');
|
||||
router.push(session_expired_query(router.currentRoute.value.fullPath));
|
||||
return Promise.reject(error);
|
||||
});
|
||||
},
|
||||
inspectToken(){
|
||||
const token = this.state.jwt;
|
||||
if(token){
|
||||
const decoded = jwtDecode(token);
|
||||
const exp = decoded.exp
|
||||
const orig_iat = decoded.iat
|
||||
if(exp - (Date.now()/1000) < 1800 && (Date.now()/1000) - orig_iat < 628200){
|
||||
this.dispatch('refreshToken')
|
||||
} else if (exp -(Date.now()/1000) < 1800){
|
||||
// DO NOTHING, DO NOT REFRESH
|
||||
} else {
|
||||
// PROMPT USER TO RE-LOGIN, THIS ELSE CLAUSE COVERS THE CONDITION WHERE A TOKEN IS EXPIRED AS WELL
|
||||
}
|
||||
// Check that the cookie still identifies the user stored in localStorage.
|
||||
restoreSession(){
|
||||
if (!this.state.user) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return api.get('/api/account')
|
||||
.then((response) => {
|
||||
this.commit('updateUser', response.data);
|
||||
return response.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.status === 401) {
|
||||
this.commit('logOut');
|
||||
router.push({ name: 'login' });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
},
|
||||
logout(){
|
||||
const wasLoggedIn = this.state.user !== null;
|
||||
this.commit('logOut');
|
||||
|
||||
if (!wasLoggedIn) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return api.post('/api/token/blacklist/', {})
|
||||
.catch((error) => {
|
||||
console.error('Error blacklisting token:', error);
|
||||
});
|
||||
}
|
||||
},
|
||||
modules: {
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
<div class="row" v-if="!initialSetupRequired">
|
||||
<div class="col col-lg-4" />
|
||||
<div class="col col-lg-4" id="login-col">
|
||||
<!-- Display error message if present -->
|
||||
<div class="alert alert-danger" v-if="errorMessage">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<form @submit="login" v-on:submit.prevent="onSubmit">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input id="username" placeholder="username" aria-describedby="loginFormControlInputHelpInline" class="form-control" type="text" v-model="username" />
|
||||
@@ -34,7 +39,8 @@ export default {
|
||||
username: '',
|
||||
password: '',
|
||||
password_alert: false,
|
||||
initialSetupRequired: false
|
||||
initialSetupRequired: false,
|
||||
errorMessage: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -43,11 +49,23 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Check for error message in route query params
|
||||
if (this.$route.query.error) {
|
||||
this.errorMessage = this.$route.query.error;
|
||||
}
|
||||
|
||||
// Check if initial setup is required
|
||||
axios.get('/api/initial_setup/required/').then(response => {
|
||||
if (response.data.required){
|
||||
this.initialSetupRequired = true
|
||||
}
|
||||
})
|
||||
},
|
||||
// Clear error message when route changes
|
||||
watch: {
|
||||
'$route'(to) {
|
||||
this.errorMessage = to.query.error || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,52 +1,38 @@
|
||||
<template>
|
||||
<the-breadcrumbs :selector="selector" />
|
||||
<the-comic-reader :selector="selector" v-if="comic_loaded" :key="selector" />
|
||||
<the-pdf-reader :selector="selector" v-if="pdf_loaded" :key="selector" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TheBreadcrumbs from "@/components/TheBreadcrumbs";
|
||||
import TheComicReader from "@/components/TheComicReader";
|
||||
import api from "@/api";
|
||||
import ThePdfReader from "@/components/ThePdfReader";
|
||||
export default {
|
||||
name: "ReadView",
|
||||
components: {ThePdfReader, TheComicReader, TheBreadcrumbs},
|
||||
components: {TheComicReader, TheBreadcrumbs},
|
||||
props: {
|
||||
selector: String
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
comic_data: {},
|
||||
comic_loaded: false,
|
||||
pdf_loaded: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateType() {
|
||||
let comic_data_url = '/api/read/' + this.selector + '/type/'
|
||||
api.get(comic_data_url)
|
||||
.then(response => {
|
||||
if (response.data.type === 'pdf'){
|
||||
this.pdf_loaded = true
|
||||
this.comic_loaded = false
|
||||
} else {
|
||||
checkComicExists() {
|
||||
api.get('/api/read/' + this.selector + '/type/')
|
||||
.then(() => {
|
||||
this.comic_loaded = true
|
||||
this.pdf_loaded = false
|
||||
}
|
||||
})
|
||||
.catch((error) => {console.log(error)})
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.updateType()
|
||||
},
|
||||
beforeUpdate() {
|
||||
this.updateType()
|
||||
this.checkComicExists()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -16,7 +16,6 @@ import UserEdit from "@/components/UserEdit";
|
||||
import alertMessages from "@/components/AlertMessages";
|
||||
import AddUser from "@/components/AddUser";
|
||||
import router from "@/router";
|
||||
import store from "@/store";
|
||||
|
||||
const default_crumbs = [
|
||||
{id: 0, selector: '', name: 'Home'},
|
||||
@@ -68,7 +67,7 @@ export default {
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
$route() {
|
||||
this.updateUsers()
|
||||
this.crumbs = [...default_crumbs]
|
||||
if (this.userid){
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { api, store, router, interceptors } = vi.hoisted(() => {
|
||||
const interceptors = { request: [], response: [] }
|
||||
const api = {
|
||||
request: vi.fn(),
|
||||
interceptors: {
|
||||
request: { use: (fn) => interceptors.request.push(fn) },
|
||||
response: { use: (ok, err) => interceptors.response.push({ ok, err }) },
|
||||
},
|
||||
}
|
||||
return {
|
||||
api,
|
||||
store: { dispatch: vi.fn(), commit: vi.fn() },
|
||||
router: { push: vi.fn() },
|
||||
interceptors,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('axios', () => ({ default: { create: () => api } }))
|
||||
vi.mock('@/store', () => ({ default: store }))
|
||||
vi.mock('@/router', () => ({ default: router }))
|
||||
|
||||
import axiosApi from '@/api'
|
||||
|
||||
const requestInterceptor = interceptors.request[0]
|
||||
const { ok: successHandler, err: errorHandler } = interceptors.response[0]
|
||||
|
||||
function error401(url) {
|
||||
return { response: { status: 401 }, config: { url } }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.cookie = 'csrftoken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
api.request.mockReset()
|
||||
store.dispatch.mockReset()
|
||||
store.commit.mockReset()
|
||||
router.push.mockReset()
|
||||
})
|
||||
|
||||
describe('request interceptor', () => {
|
||||
it('uses the cookie session, so it sends no Authorization header', () => {
|
||||
expect(axiosApi).toBe(api)
|
||||
expect(requestInterceptor({ headers: {} }).headers).not.toHaveProperty('Authorization')
|
||||
})
|
||||
|
||||
it('adds the CSRF header when a csrftoken cookie exists', () => {
|
||||
document.cookie = 'csrftoken=abc123; path=/'
|
||||
|
||||
expect(requestInterceptor({ headers: {} }).headers['X-CSRFToken']).toBe('abc123')
|
||||
})
|
||||
|
||||
it('leaves the headers untouched when there is no CSRF cookie', () => {
|
||||
expect(requestInterceptor({ headers: {} }).headers).not.toHaveProperty('X-CSRFToken')
|
||||
})
|
||||
})
|
||||
|
||||
describe('response interceptor', () => {
|
||||
it('passes successful responses through', async () => {
|
||||
const response = { data: [1, 2] }
|
||||
|
||||
expect(await successHandler(response)).toBe(response)
|
||||
expect(store.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes the session and retries once after a 401', async () => {
|
||||
store.dispatch.mockResolvedValue(undefined)
|
||||
api.request.mockResolvedValue({ data: 'retried' })
|
||||
const error = error401('/api/browse/')
|
||||
|
||||
expect(await errorHandler(error)).toEqual({ data: 'retried' })
|
||||
expect(store.dispatch).toHaveBeenCalledWith('refreshToken')
|
||||
expect(api.request).toHaveBeenCalledWith(error.config)
|
||||
expect(error.config._retried).toBe(true)
|
||||
})
|
||||
|
||||
it('does not retry a request that has already been retried', async () => {
|
||||
const error = error401('/api/browse/')
|
||||
error.config._retried = true
|
||||
|
||||
await expect(errorHandler(error)).rejects.toBe(error)
|
||||
expect(store.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not retry the token endpoints, which would loop', async () => {
|
||||
const error = error401('/api/token/refresh/')
|
||||
|
||||
await expect(errorHandler(error)).rejects.toBe(error)
|
||||
expect(store.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs out and goes to the login page when the refresh fails', async () => {
|
||||
store.dispatch.mockRejectedValue(new Error('expired'))
|
||||
const error = error401('/api/browse/')
|
||||
|
||||
await expect(errorHandler(error)).rejects.toBe(error)
|
||||
|
||||
expect(store.commit).toHaveBeenCalledWith('logOut')
|
||||
expect(router.push).toHaveBeenCalledWith({
|
||||
name: 'login',
|
||||
query: {
|
||||
next: '/api/browse/',
|
||||
error: 'Your session has expired. Please log in again.',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves other error statuses alone', async () => {
|
||||
const error = { response: { status: 403 }, config: { url: '/api/browse/' } }
|
||||
|
||||
await expect(errorHandler(error)).rejects.toBe(error)
|
||||
expect(store.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), put: vi.fn() }))
|
||||
const store = vi.hoisted(() => ({ state: { filters: {} }, getters: { isAuthenticated: true, is_superuser: false } }))
|
||||
|
||||
vi.mock('@/api', () => ({ default: api }))
|
||||
vi.mock('@/store', () => ({ default: store }))
|
||||
|
||||
import TheComicList from '@/components/TheComicList.vue'
|
||||
|
||||
const BOOKS = [
|
||||
{ selector: 'a1', title: 'Alpha', type: 'ComicBook', finished: true, unread: false, thumbnail: '' },
|
||||
{ selector: 'b2', title: 'Beta', type: 'ComicBook', finished: false, unread: true, thumbnail: '' },
|
||||
{ selector: 'c3', title: 'Gamma Subfolder', type: 'Directory', thumbnail: '' },
|
||||
]
|
||||
|
||||
function mountList(props = {}) {
|
||||
return mount(TheComicList, {
|
||||
props: { selector: props.selector ?? '' },
|
||||
global: {
|
||||
stubs: {
|
||||
'font-awesome-icon': true,
|
||||
ComicCard: {
|
||||
name: 'ComicCard',
|
||||
props: ['data'],
|
||||
template: '<div class="card-stub" :data-selector="data.title">{{ data.title }}</div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
api.get.mockReset()
|
||||
api.post.mockReset()
|
||||
api.put.mockReset()
|
||||
api.get.mockResolvedValue({ data: [...BOOKS] })
|
||||
store.state.filters = {}
|
||||
})
|
||||
|
||||
describe('TheComicList', () => {
|
||||
it('loads the home listing when it is mounted', async () => {
|
||||
const wrapper = mountList()
|
||||
await flushPromises()
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith('/api/browse/')
|
||||
expect(wrapper.text()).toContain('Alpha')
|
||||
})
|
||||
|
||||
it('loads the listing for the folder it was given', async () => {
|
||||
const wrapper = mountList({ selector: 'c3' })
|
||||
await flushPromises()
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith('/api/browse/c3/')
|
||||
expect(wrapper.vm.comics).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('filters by name without changing the loaded data', async () => {
|
||||
const wrapper = mountList()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.filters.search_string = 'beta'
|
||||
expect(wrapper.vm.filteredComics.map((c) => c.title)).toEqual(['Beta'])
|
||||
|
||||
wrapper.vm.filters.search_string = 'ga'
|
||||
expect(wrapper.vm.filteredComics.map((c) => c.title)).toEqual(['Gamma Subfolder'])
|
||||
})
|
||||
|
||||
it('filters read and unread entries separately', async () => {
|
||||
const wrapper = mountList()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.filters.filter_read = true
|
||||
expect(wrapper.vm.filteredComics.map((c) => c.title)).toEqual(['Alpha'])
|
||||
|
||||
wrapper.vm.filters.filter_read = false
|
||||
wrapper.vm.filters.filter_unread = true
|
||||
expect(wrapper.vm.filteredComics.map((c) => c.title)).toEqual(['Beta'])
|
||||
})
|
||||
|
||||
it('keeps the filters in the store so they survive a folder change', async () => {
|
||||
const wrapper = mountList({ selector: 'c3' })
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.filters.search_string = 'alpha'
|
||||
expect(store.state.filters.c3.search_string).toBe('alpha')
|
||||
})
|
||||
|
||||
it('scans the folder on disk and uses the returned listing', async () => {
|
||||
let resolvePost
|
||||
api.post.mockImplementation(() => new Promise((resolve) => { resolvePost = resolve }))
|
||||
|
||||
const wrapper = mountList({ selector: 'c3' })
|
||||
await flushPromises()
|
||||
|
||||
const button = wrapper.find('button[title="Read this folder from disk again"]')
|
||||
button.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith('/api/browse/scan/', { selector: 'c3' })
|
||||
expect(wrapper.vm.scanning).toBe(true)
|
||||
expect(button.element.disabled).toBe(true)
|
||||
|
||||
resolvePost({ data: [{ selector: 'd4', title: 'Delta', type: 'ComicBook' }] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.comics).toHaveLength(1)
|
||||
expect(wrapper.vm.scanning).toBe(false)
|
||||
})
|
||||
|
||||
it('marks every book in the folder, ignoring subfolders', async () => {
|
||||
const wrapper = mountList()
|
||||
await flushPromises()
|
||||
|
||||
api.put.mockResolvedValue({ data: [] })
|
||||
wrapper.vm.markAll('mark_read')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.put).toHaveBeenCalledWith('/api/action/mark_read/', { selectors: ['a1', 'b2'] })
|
||||
})
|
||||
|
||||
it('marks everything before the card that asked for it', async () => {
|
||||
const wrapper = mountList()
|
||||
await flushPromises()
|
||||
|
||||
api.put.mockResolvedValue({ data: [] })
|
||||
wrapper.vm.markPreviousRead('b2')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.put).toHaveBeenCalledWith('/api/action/mark_read/', { selectors: ['a1', 'b2'] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,520 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const api = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }))
|
||||
const router = vi.hoisted(() => ({ push: vi.fn() }))
|
||||
const deck = vi.hoisted(() => ({
|
||||
initialize: vi.fn(() => Promise.resolve()),
|
||||
slide: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
isFirstSlide: vi.fn(() => false),
|
||||
isLastSlide: vi.fn(() => false),
|
||||
}))
|
||||
const hammertime = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn() }))
|
||||
const reveal = vi.hoisted(() => vi.fn(() => deck))
|
||||
const store = vi.hoisted(() => ({
|
||||
state: { reader_mode: 'paged' },
|
||||
commit: vi.fn(),
|
||||
}))
|
||||
|
||||
const observers = []
|
||||
|
||||
// jsdom has no IntersectionObserver, so the tests drive the callbacks by hand.
|
||||
class FakeIntersectionObserver {
|
||||
constructor(callback, options) {
|
||||
this.callback = callback
|
||||
this.options = options
|
||||
this.targets = []
|
||||
this.disconnected = false
|
||||
observers.push(this)
|
||||
}
|
||||
|
||||
observe(target) {
|
||||
if (!this.targets.includes(target)) this.targets.push(target)
|
||||
}
|
||||
|
||||
unobserve(target) { this.targets = this.targets.filter((item) => item !== target) }
|
||||
disconnect() { this.disconnected = true; this.targets = [] }
|
||||
fire(entries) { this.callback(entries, this) }
|
||||
}
|
||||
|
||||
vi.mock('@/api', () => ({ default: api }))
|
||||
vi.mock('reveal.js', () => ({ default: reveal }))
|
||||
vi.mock('hammerjs', () => ({ default: vi.fn(function () { return hammertime }) }))
|
||||
|
||||
import TheComicReader from '@/components/TheComicReader.vue'
|
||||
|
||||
const COMIC = {
|
||||
title: 'Alpha',
|
||||
last_read_page: 2,
|
||||
pages: 5,
|
||||
prev_comic: { route: 'browse', selector: 'p1' },
|
||||
next_comic: { route: 'browse', selector: 'n1' },
|
||||
}
|
||||
|
||||
const mounted = []
|
||||
|
||||
function mountReader() {
|
||||
const wrapper = mount(TheComicReader, {
|
||||
props: { selector: 'a1' },
|
||||
global: {
|
||||
mocks: { $router: router, $store: store },
|
||||
stubs: { 'comic-paginate': true },
|
||||
},
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn()
|
||||
api.get.mockReset()
|
||||
api.put.mockReset()
|
||||
api.get.mockResolvedValue({ data: { ...COMIC } })
|
||||
api.put.mockResolvedValue({ data: {} })
|
||||
router.push.mockReset()
|
||||
store.state = reactive({ reader_mode: 'paged' })
|
||||
store.commit.mockReset()
|
||||
store.commit.mockImplementation((name, value) => {
|
||||
if (name === 'setReaderMode' && (value === 'paged' || value === 'scroll')) {
|
||||
store.state.reader_mode = value
|
||||
}
|
||||
})
|
||||
observers.length = 0
|
||||
globalThis.IntersectionObserver = FakeIntersectionObserver
|
||||
reveal.mockClear()
|
||||
for (const fn of Object.values(deck)) {
|
||||
if (typeof fn === 'function') fn.mockClear?.()
|
||||
}
|
||||
for (const fn of Object.values(hammertime)) fn.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mounted.forEach((wrapper) => {
|
||||
if (!wrapper.vm?.isUnmounted) wrapper.unmount()
|
||||
})
|
||||
mounted.length = 0
|
||||
})
|
||||
|
||||
describe('TheComicReader', () => {
|
||||
it('loads the book, starts the deck and records the page it resumed on', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith('/api/read/a1/')
|
||||
expect(deck.initialize).toHaveBeenCalled()
|
||||
expect(wrapper.vm.current_page).toBe(2)
|
||||
expect(deck.slide).toHaveBeenCalledWith(2)
|
||||
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 2 })
|
||||
})
|
||||
|
||||
it('keeps the page indicator in step with the deck', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.setPage(4)
|
||||
expect(deck.slide).toHaveBeenCalledWith(3)
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.paginate_page).toBe(4)
|
||||
})
|
||||
|
||||
it('acts on the first arrow key and ignores held keys until the throttle expires', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
|
||||
expect(wrapper.vm.current_page).toBe(3)
|
||||
expect(deck.slide).toHaveBeenCalledTimes(2)
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
|
||||
expect(wrapper.vm.current_page).toBe(4)
|
||||
expect(deck.slide).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('moves to the next book when the last slide is reached', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
|
||||
deck.isLastSlide.mockReturnValueOnce(true)
|
||||
wrapper.vm.keyPress({ key: 'ArrowRight' })
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith({ name: 'browse', params: { selector: 'n1' } })
|
||||
})
|
||||
|
||||
it('moves to the previous book when the first slide is reached', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
|
||||
deck.isFirstSlide.mockReturnValueOnce(true)
|
||||
wrapper.vm.keyPress({ key: 'ArrowLeft' })
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith({ name: 'browse', params: { selector: 'p1' } })
|
||||
})
|
||||
|
||||
it('records the page again whenever the visible slide changes', async () => {
|
||||
mountReader()
|
||||
await flushPromises()
|
||||
|
||||
const handler = deck.on.mock.calls[0][1]
|
||||
api.put.mockClear()
|
||||
|
||||
handler({ indexh: 4 })
|
||||
|
||||
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
|
||||
})
|
||||
|
||||
it('removes every listener and the deck when it is unmounted', async () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith('keydown', wrapper.vm.keyPress)
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('keydown', wrapper.vm.keyPress)
|
||||
expect(deck.off).toHaveBeenCalledWith('slidechanged', wrapper.vm.slideChanged)
|
||||
expect(deck.destroy).toHaveBeenCalled()
|
||||
expect(hammertime.off).toHaveBeenCalledWith('swipeleft')
|
||||
expect(hammertime.off).toHaveBeenCalledWith('swiperight')
|
||||
expect(hammertime.off).toHaveBeenCalledWith('tap')
|
||||
|
||||
addSpy.mockRestore()
|
||||
removeSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('leaves arrow keys with a modifier to the browser', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
deck.slide.mockClear()
|
||||
|
||||
wrapper.vm.keyPress({ key: 'ArrowLeft', altKey: true })
|
||||
expect(deck.slide).not.toHaveBeenCalled()
|
||||
|
||||
wrapper.vm.keyPress({ key: 'ArrowRight' })
|
||||
expect(wrapper.vm.current_page).toBe(3)
|
||||
})
|
||||
|
||||
it('ignores arrow keys pressed while a form control has focus', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
deck.slide.mockClear()
|
||||
|
||||
wrapper.vm.keyPress({ key: 'ArrowRight', target: document.createElement('input') })
|
||||
expect(deck.slide).not.toHaveBeenCalled()
|
||||
|
||||
wrapper.vm.keyPress({ key: 'ArrowRight', target: document.createElement('button') })
|
||||
expect(deck.slide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves Up and Down to the browser so they scroll within the page', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
deck.slide.mockClear()
|
||||
api.put.mockClear()
|
||||
|
||||
wrapper.vm.keyPress({ key: 'ArrowDown' })
|
||||
wrapper.vm.keyPress({ key: 'ArrowUp' })
|
||||
|
||||
// Nothing is intercepted, so the native scroll is not doubled up and the recorded
|
||||
// page stays where the reader actually is.
|
||||
expect(wrapper.vm.current_page).toBe(2)
|
||||
expect(deck.slide).not.toHaveBeenCalled()
|
||||
expect(api.put).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the shortcut list with ? and hides it again', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.shortcuts').exists()).toBe(false)
|
||||
|
||||
wrapper.vm.keyPress({ key: '?' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.shortcuts').exists()).toBe(true)
|
||||
|
||||
wrapper.vm.keyPress({ key: '?' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.shortcuts').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the shortcut list with Escape even when a control has focus', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.keyPress({ key: '?' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.shortcuts').exists()).toBe(true)
|
||||
|
||||
wrapper.vm.keyPress({ key: 'Escape', target: document.createElement('input') })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.shortcuts').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles the shortcut list from the ? button', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
|
||||
const button = wrapper.find('button[aria-pressed]')
|
||||
await button.trigger('click')
|
||||
expect(wrapper.find('.shortcuts').exists()).toBe(true)
|
||||
expect(button.attributes('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('pages with Space and Shift+Space', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
deck.slide.mockClear()
|
||||
|
||||
const preventDefault = vi.fn()
|
||||
wrapper.vm.keyPress({ key: ' ', preventDefault })
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(deck.slide).toHaveBeenCalledWith(3)
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
wrapper.vm.keyPress({ key: ' ', shiftKey: true, preventDefault })
|
||||
expect(deck.slide).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('pages with n and p whatever the case', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
deck.slide.mockClear()
|
||||
|
||||
wrapper.vm.keyPress({ key: 'n' })
|
||||
expect(deck.slide).toHaveBeenCalledWith(3)
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
wrapper.vm.keyPress({ key: 'P' })
|
||||
expect(deck.slide).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('jumps to the first and last page with Home and End', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
deck.slide.mockClear()
|
||||
|
||||
const preventDefault = vi.fn()
|
||||
wrapper.vm.keyPress({ key: 'Home', preventDefault })
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(deck.slide).toHaveBeenCalledWith(0)
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
wrapper.vm.keyPress({ key: 'End', preventDefault })
|
||||
expect(deck.slide).toHaveBeenCalledWith(4)
|
||||
})
|
||||
|
||||
it('switches reading mode with m', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.keyPress({ key: 'm' })
|
||||
await flushPromises()
|
||||
|
||||
expect(store.commit).toHaveBeenCalledWith('setReaderMode', 'scroll')
|
||||
expect(wrapper.vm.mode).toBe('scroll')
|
||||
})
|
||||
|
||||
it('shows the top of the page when changing pages in paged mode', async () => {
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
const scrollIntoView = HTMLElement.prototype.scrollIntoView
|
||||
|
||||
wrapper.vm.keyPress({ key: 'ArrowRight' })
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' })
|
||||
})
|
||||
|
||||
it('does not move the deck when a key arrives before reveal has initialized', async () => {
|
||||
const wrapper = mountReader()
|
||||
deck.slide.mockClear()
|
||||
|
||||
wrapper.vm.keyPress({ key: 'ArrowRight' })
|
||||
expect(deck.slide).not.toHaveBeenCalled()
|
||||
|
||||
await flushPromises()
|
||||
expect(deck.initialize).toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
wrapper.vm.keyPress({ key: 'ArrowRight' })
|
||||
expect(wrapper.vm.current_page).toBe(3)
|
||||
expect(deck.slide).toHaveBeenCalledWith(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TheComicReader in continuous mode', () => {
|
||||
function mountContinuous(pageCount) {
|
||||
store.state.reader_mode = 'scroll'
|
||||
api.get.mockResolvedValue({ data: { ...COMIC, pages: pageCount } })
|
||||
return mountReader()
|
||||
}
|
||||
|
||||
function growthObserver() {
|
||||
return observers.find((observer) => observer.options.rootMargin)
|
||||
}
|
||||
|
||||
function pageObserver() {
|
||||
return observers.find((observer) => !observer.options.rootMargin)
|
||||
}
|
||||
|
||||
it('renders only the first window of pages and does not start a deck', async () => {
|
||||
const wrapper = mountContinuous(45)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.rendered).toBe(20)
|
||||
expect(wrapper.findAll('section[data-page]')).toHaveLength(20)
|
||||
expect(reveal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders far enough to include the page it resumed on', async () => {
|
||||
store.state.reader_mode = 'scroll'
|
||||
api.get.mockResolvedValue({ data: { ...COMIC, last_read_page: 30, pages: 45 } })
|
||||
const wrapper = mountReader()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.rendered).toBe(31)
|
||||
expect(wrapper.findAll('section[data-page]')).toHaveLength(31)
|
||||
})
|
||||
|
||||
it('grows the window when the reader reaches the end of what is rendered', async () => {
|
||||
const wrapper = mountContinuous(45)
|
||||
await flushPromises()
|
||||
|
||||
growthObserver().fire([{ isIntersecting: true, target: wrapper.find('.sentinel').element, boundingClientRect: { top: 0 } }])
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.rendered).toBe(40)
|
||||
expect(wrapper.findAll('section[data-page]')).toHaveLength(40)
|
||||
expect(pageObserver().targets).toHaveLength(40)
|
||||
})
|
||||
|
||||
it('does not grow past the last page', async () => {
|
||||
const wrapper = mountContinuous(25)
|
||||
await flushPromises()
|
||||
|
||||
growthObserver().fire([{ isIntersecting: true, target: wrapper.find('.sentinel').element, boundingClientRect: { top: 0 } }])
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.rendered).toBe(25)
|
||||
expect(wrapper.find('.sentinel').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('records the page at the top of the viewport', async () => {
|
||||
const wrapper = mountContinuous(30)
|
||||
await flushPromises()
|
||||
api.put.mockClear()
|
||||
|
||||
const sections = wrapper.findAll('section[data-page]')
|
||||
pageObserver().fire([
|
||||
{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: 10 } },
|
||||
{ isIntersecting: true, target: sections[2].element, boundingClientRect: { top: 400 } },
|
||||
])
|
||||
|
||||
expect(wrapper.vm.current_page).toBe(4)
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
|
||||
})
|
||||
|
||||
it('keeps the recorded page while scrolling inside a page taller than the viewport', async () => {
|
||||
const wrapper = mountContinuous(30)
|
||||
await flushPromises()
|
||||
api.put.mockClear()
|
||||
|
||||
const sections = wrapper.findAll('section[data-page]')
|
||||
const observer = pageObserver()
|
||||
observer.fire([{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: 0 } }])
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
|
||||
|
||||
// Scrolling down inside that page leaves it topmost, so nothing is written.
|
||||
api.put.mockClear()
|
||||
observer.fire([{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: -300 } }])
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(wrapper.vm.current_page).toBe(4)
|
||||
expect(api.put).not.toHaveBeenCalled()
|
||||
|
||||
// The recorded page only changes when the next page becomes the topmost one.
|
||||
observer.fire([
|
||||
{ isIntersecting: false, target: sections[4].element },
|
||||
{ isIntersecting: true, target: sections[5].element, boundingClientRect: { top: 0 } },
|
||||
])
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(wrapper.vm.current_page).toBe(5)
|
||||
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 5 })
|
||||
})
|
||||
|
||||
it('writes once for a burst of scrolling rather than once per intersection', async () => {
|
||||
const wrapper = mountContinuous(30)
|
||||
await flushPromises()
|
||||
api.put.mockClear()
|
||||
|
||||
const sections = wrapper.findAll('section[data-page]')
|
||||
const observer = pageObserver()
|
||||
observer.fire([{ isIntersecting: true, target: sections[5].element, boundingClientRect: { top: 400 } }])
|
||||
observer.fire([{ isIntersecting: true, target: sections[6].element, boundingClientRect: { top: 0 } }])
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
expect(api.put).toHaveBeenCalledTimes(1)
|
||||
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 6 })
|
||||
})
|
||||
|
||||
it('marks the page in view as shown so it fades in', async () => {
|
||||
const wrapper = mountContinuous(30)
|
||||
await flushPromises()
|
||||
|
||||
const sections = wrapper.findAll('section[data-page]')
|
||||
const observer = pageObserver()
|
||||
observer.fire([{ isIntersecting: true, target: sections[2].element, boundingClientRect: { top: 0 } }])
|
||||
await flushPromises()
|
||||
|
||||
expect(sections[2].find('img').classes()).toContain('shown')
|
||||
expect(sections[0].find('img').classes()).not.toContain('shown')
|
||||
|
||||
observer.fire([{ isIntersecting: false, target: sections[2].element, boundingClientRect: { top: 0 } }])
|
||||
await flushPromises()
|
||||
expect(sections[2].find('img').classes()).not.toContain('shown')
|
||||
})
|
||||
|
||||
it('scrolls to a page chosen from the pager, rendering it first if it is outside the window', async () => {
|
||||
const wrapper = mountContinuous(30)
|
||||
await flushPromises()
|
||||
|
||||
const target = wrapper.findAll('section[data-page]')[19].element
|
||||
const spy = vi.spyOn(target, 'scrollIntoView')
|
||||
|
||||
wrapper.vm.setPage(20)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.rendered).toBe(20)
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disconnects both observers when it is unmounted', async () => {
|
||||
const wrapper = mountContinuous(30)
|
||||
await flushPromises()
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(observers.every((observer) => observer.disconnected)).toBe(true)
|
||||
})
|
||||
|
||||
it('commits the mode change and rebuilds the deck when switching back to one page at a time', async () => {
|
||||
const wrapper = mountContinuous(30)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.vm.setMode('paged')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.commit).toHaveBeenCalledWith('setReaderMode', 'paged')
|
||||
expect(observers.every((observer) => observer.disconnected)).toBe(true)
|
||||
expect(reveal).toHaveBeenCalled()
|
||||
expect(wrapper.findAll('section[data-page]')).toHaveLength(30)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
const api = vi.hoisted(() => ({ get: vi.fn() }))
|
||||
|
||||
vi.mock('@/api', () => ({ default: api }))
|
||||
|
||||
import HistoryTable from '@/components/HistoryTable.vue'
|
||||
|
||||
function mountTable() {
|
||||
return mount(HistoryTable, { global: { stubs: { paginate: true, 'font-awesome-icon': true } } })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
api.get.mockReset()
|
||||
api.get.mockResolvedValue({ data: { count: 25, results: [{ id: 1, selector: 'a1', file_name: 'Alpha', last_read_time: new Date(), unread: false, finished: true, last_read_page: 3, page_count: 5 }] } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('HistoryTable', () => {
|
||||
it('requests the first page when it is mounted', async () => {
|
||||
const wrapper = mountTable()
|
||||
await flushPromises()
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith('/api/history/', { params: { page: 1, page_size: 10 } })
|
||||
expect(wrapper.text()).toContain('Finished')
|
||||
})
|
||||
|
||||
it('works out how many pages the result count implies', async () => {
|
||||
const wrapper = mountTable()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.page_count).toBe(3)
|
||||
|
||||
wrapper.vm.page_size = 25
|
||||
wrapper.vm.setPage(2)
|
||||
await flushPromises()
|
||||
|
||||
expect(api.get).toHaveBeenLastCalledWith('/api/history/', { params: { page: 2, page_size: 25 } })
|
||||
expect(wrapper.vm.page_count).toBe(1)
|
||||
})
|
||||
|
||||
it('includes the search text once there is one', async () => {
|
||||
const wrapper = mountTable()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.search_text = 'alpha'
|
||||
wrapper.vm.setPage(1)
|
||||
await flushPromises()
|
||||
|
||||
expect(api.get).toHaveBeenLastCalledWith('/api/history/', {
|
||||
params: { page: 1, page_size: 10, search_text: 'alpha' },
|
||||
})
|
||||
})
|
||||
|
||||
it('debounces typing so one burst of keystrokes makes one request', async () => {
|
||||
const wrapper = mountTable()
|
||||
await flushPromises()
|
||||
expect(api.get).toHaveBeenCalledTimes(1)
|
||||
|
||||
wrapper.vm.debounceInput()
|
||||
wrapper.vm.debounceInput()
|
||||
wrapper.vm.debounceInput()
|
||||
|
||||
vi.advanceTimersByTime(500)
|
||||
await flushPromises()
|
||||
|
||||
expect(api.get).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not fire a pending debounce after the table is removed', async () => {
|
||||
const wrapper = mountTable()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.debounceInput()
|
||||
wrapper.unmount()
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
await flushPromises()
|
||||
|
||||
expect(api.get).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('goes back to page one when the page number is no longer valid', async () => {
|
||||
api.get.mockRejectedValueOnce({ response: { data: { detail: 'Invalid page.' } } })
|
||||
|
||||
const wrapper = mountTable()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.page).toBe(1)
|
||||
expect(api.get).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('shows the reading status for each entry', async () => {
|
||||
const wrapper = mountTable()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.get_status({ unread: true })).toBe('Unread')
|
||||
expect(wrapper.vm.get_status({ unread: null })).toBe('Unread')
|
||||
expect(wrapper.vm.get_status({ unread: false, finished: true })).toBe('Finished')
|
||||
expect(wrapper.vm.get_status({ unread: false, finished: false, last_read_page: 2, page_count: 5 })).toBe('2 / 5')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/api', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
import store from '@/store'
|
||||
import router from '@/router'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
store.commit('logOut')
|
||||
})
|
||||
|
||||
describe('navigation guards', () => {
|
||||
it('redirects an anonymous user to the login page', async () => {
|
||||
await router.push('/account')
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.error).toBe('Please log in to access this page')
|
||||
})
|
||||
|
||||
it('keeps the requested path so the user returns there after logging in', async () => {
|
||||
await router.push('/history')
|
||||
|
||||
expect(router.currentRoute.value.query.next).toBe('/history')
|
||||
})
|
||||
|
||||
it('allows an authenticated non-admin to reach the history page', async () => {
|
||||
store.commit('updateUser', { id: 1, is_superuser: false })
|
||||
|
||||
await router.push('/history')
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('history')
|
||||
})
|
||||
|
||||
it('still requires admin for the user page', async () => {
|
||||
store.commit('updateUser', { id: 1, is_superuser: false })
|
||||
|
||||
await router.push('/user')
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
|
||||
it('allows an admin on the user page', async () => {
|
||||
store.commit('updateUser', { id: 1, is_superuser: true })
|
||||
|
||||
await router.push('/user')
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('user')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { api, router } = vi.hoisted(() => ({
|
||||
api: { get: vi.fn(), post: vi.fn() },
|
||||
router: { currentRoute: { value: { query: {}, fullPath: '/account' } }, push: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({ default: api }))
|
||||
vi.mock('@/router', () => ({ default: router }))
|
||||
|
||||
import store from '@/store'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
store.commit('logOut')
|
||||
api.get.mockReset()
|
||||
api.post.mockReset()
|
||||
router.push.mockReset()
|
||||
})
|
||||
|
||||
describe('session state', () => {
|
||||
it('is not authenticated when there is no stored profile', () => {
|
||||
expect(store.getters.isAuthenticated).toBe(false)
|
||||
expect(store.getters.is_superuser).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the profile in localStorage so a reload is not a blank page', () => {
|
||||
store.commit('updateUser', { id: 1, is_superuser: false })
|
||||
|
||||
expect(store.getters.isAuthenticated).toBe(true)
|
||||
expect(JSON.parse(localStorage.getItem('u'))).toEqual({ id: 1, is_superuser: false })
|
||||
})
|
||||
|
||||
it('logOut clears both the profile and localStorage', () => {
|
||||
store.commit('updateUser', { id: 1, is_superuser: true })
|
||||
store.commit('logOut')
|
||||
|
||||
expect(store.getters.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('u')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reader mode', () => {
|
||||
it('starts in paged mode when nothing is stored', async () => {
|
||||
vi.resetModules()
|
||||
const fresh = (await import('@/store')).default
|
||||
|
||||
expect(fresh.state.reader_mode).toBe('paged')
|
||||
})
|
||||
|
||||
it('keeps the chosen mode in localStorage', () => {
|
||||
store.commit('setReaderMode', 'scroll')
|
||||
|
||||
expect(store.state.reader_mode).toBe('scroll')
|
||||
expect(localStorage.getItem('reader_mode')).toBe('scroll')
|
||||
})
|
||||
|
||||
it('ignores a mode it does not recognise', () => {
|
||||
store.commit('setReaderMode', 'scroll')
|
||||
store.commit('setReaderMode', 'banana')
|
||||
|
||||
expect(store.state.reader_mode).toBe('scroll')
|
||||
expect(localStorage.getItem('reader_mode')).toBe('scroll')
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreSession', () => {
|
||||
it('refreshes the stored profile from the cookie session', async () => {
|
||||
store.commit('updateUser', { id: 1, is_superuser: false })
|
||||
api.get.mockResolvedValue({ data: { id: 1, is_superuser: true } })
|
||||
|
||||
await store.dispatch('restoreSession')
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith('/api/account')
|
||||
expect(store.getters.is_superuser).toBe(true)
|
||||
})
|
||||
|
||||
it('logs out when the cookie no longer identifies the user', async () => {
|
||||
store.commit('updateUser', { id: 1, is_superuser: false })
|
||||
api.get.mockRejectedValue({ response: { status: 401 } })
|
||||
|
||||
await store.dispatch('restoreSession')
|
||||
|
||||
expect(store.getters.isAuthenticated).toBe(false)
|
||||
expect(router.push).toHaveBeenCalledWith({ name: 'login' })
|
||||
})
|
||||
|
||||
it('does nothing when there is no stored profile', async () => {
|
||||
await store.dispatch('restoreSession')
|
||||
|
||||
expect(api.get).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('token handling', () => {
|
||||
it('rejects and logs out when the refresh cookie is no longer valid', async () => {
|
||||
store.commit('updateUser', { id: 1, is_superuser: false })
|
||||
api.post.mockRejectedValue({ response: { status: 401 } })
|
||||
|
||||
await expect(store.dispatch('refreshToken')).rejects.toBeTruthy()
|
||||
|
||||
expect(store.getters.isAuthenticated).toBe(false)
|
||||
expect(router.push).toHaveBeenCalledWith(expect.objectContaining({ name: 'login' }))
|
||||
})
|
||||
|
||||
it('does not call the API when there is no session', async () => {
|
||||
await expect(store.dispatch('refreshToken')).rejects.toBeTruthy()
|
||||
expect(api.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not blacklist a token for a session that does not exist', async () => {
|
||||
await store.dispatch('logout')
|
||||
|
||||
expect(api.post).not.toHaveBeenCalled()
|
||||
expect(store.getters.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('blacklists the token when a session existed', async () => {
|
||||
store.commit('updateUser', { id: 1, is_superuser: false })
|
||||
api.post.mockResolvedValue({ data: {} })
|
||||
|
||||
await store.dispatch('logout')
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith('/api/token/blacklist/', {})
|
||||
expect(store.getters.isAuthenticated).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": false,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"module": "es2015",
|
||||
"moduleResolution": "node",
|
||||
"noImplicitAny": false,
|
||||
"noLib": false,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"suppressImplicitAnyIndexErrors": true,
|
||||
"target": "es2015",
|
||||
"baseUrl": "./src"
|
||||
},
|
||||
"exclude": [
|
||||
"./node_modules"
|
||||
],
|
||||
"include": [
|
||||
"./src/**/*.ts",
|
||||
"./src/**/*.vue"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'src')
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
// The source imports components without the .vue extension (e.g. '@/views/BrowseView'),
|
||||
// so mirror the extension list used by frontend/webpack.dev.js.
|
||||
resolve: { extensions: ['.ts', '.js', '.vue', '.json'] },
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
alias: { '@': SRC },
|
||||
},
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
const { defineConfig } = require('@vue/cli-service')
|
||||
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer')
|
||||
.BundleAnalyzerPlugin;
|
||||
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true,
|
||||
configureWebpack: {
|
||||
plugins: [new BundleAnalyzerPlugin()]
|
||||
},
|
||||
})
|
||||
+11
-11
@@ -3,6 +3,11 @@ const { VueLoaderPlugin } = require('vue-loader')
|
||||
const BundleTracker = require('webpack-bundle-tracker');
|
||||
const webpack = require('webpack')
|
||||
|
||||
// The dev server port. Keep this and the publicPath in sync - django-webpack-loader
|
||||
// builds the script URL from the publicPath written into webpack-stats.json.
|
||||
const DEV_PORT = process.env.DEV_PORT || 8001
|
||||
const DEV_PUBLIC_PATH = `http://localhost:${DEV_PORT}/`
|
||||
|
||||
module.exports = () => {
|
||||
return {
|
||||
|
||||
@@ -11,6 +16,7 @@ module.exports = () => {
|
||||
entry: path.resolve(__dirname, './src/main.js'),
|
||||
output: {
|
||||
path: path.resolve(__dirname, './dist/bundles/'),
|
||||
publicPath: DEV_PUBLIC_PATH,
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
@@ -18,14 +24,6 @@ module.exports = () => {
|
||||
test: /\.vue$/,
|
||||
use: 'vue-loader'
|
||||
},
|
||||
{
|
||||
test: /\.ts$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
appendTsSuffixTo: [/\.vue$/],
|
||||
transpileOnly: true
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
@@ -45,8 +43,9 @@ module.exports = () => {
|
||||
plugins: [
|
||||
new VueLoaderPlugin(),
|
||||
new BundleTracker({
|
||||
filename: './webpack-stats.json',
|
||||
publicPath: 'http://localhost:8080/'
|
||||
filename: 'webpack-stats.json',
|
||||
path: path.resolve(__dirname, './'),
|
||||
publicPath: DEV_PUBLIC_PATH
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.BASE_URL': JSON.stringify(process.env.BASE_URL),
|
||||
@@ -54,10 +53,11 @@ module.exports = () => {
|
||||
new webpack.DefinePlugin({ __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: true }),
|
||||
],
|
||||
devServer: {
|
||||
port: DEV_PORT,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin":"*"
|
||||
},
|
||||
hot: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ const path = require('path')
|
||||
const { VueLoaderPlugin } = require('vue-loader')
|
||||
const BundleTracker = require('webpack-bundle-tracker');
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
||||
|
||||
const webpack = require('webpack')
|
||||
|
||||
@@ -12,7 +11,9 @@ module.exports = (env = {}) => {
|
||||
return {
|
||||
|
||||
mode: 'production',
|
||||
devtool: 'hidden-source-map',
|
||||
// No source maps in production: they are not served to browsers, but collectstatic
|
||||
// copies them into /static/ so browser devtools would pull ~3.3 MiB of maps.
|
||||
devtool: false,
|
||||
entry: path.resolve(__dirname, './src/main.js'),
|
||||
output: {
|
||||
path: path.resolve(__dirname, './dist/bundles/'),
|
||||
@@ -23,14 +24,6 @@ module.exports = (env = {}) => {
|
||||
test: /\.vue$/,
|
||||
use: 'vue-loader'
|
||||
},
|
||||
{
|
||||
test: /\.ts$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
appendTsSuffixTo: [/\.vue$/],
|
||||
transpileOnly: true
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [MiniCssExtractPlugin.loader, "css-loader"],
|
||||
@@ -47,7 +40,8 @@ module.exports = (env = {}) => {
|
||||
plugins: [
|
||||
new VueLoaderPlugin(),
|
||||
new BundleTracker({
|
||||
filename: './webpack-stats.json',
|
||||
filename: 'webpack-stats.json',
|
||||
path: path.resolve(__dirname, './'),
|
||||
publicPath: '/static/bundles/',
|
||||
integrity: true
|
||||
}),
|
||||
|
||||
Generated
-1931
File diff suppressed because it is too large.
Load diff
+65
-48
@@ -1,56 +1,73 @@
|
||||
[tool.black]
|
||||
line_length = 119
|
||||
|
||||
[tool.poetry]
|
||||
[project]
|
||||
name = "cbwebreader"
|
||||
version = "1.1.8"
|
||||
version = "2.0.0"
|
||||
description = "CBR/Z Web Reader"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
authors = ["ajurna <ajurna@gmail.com>"]
|
||||
license = "Creative Commons Attribution-ShareAlike 4.0 International License"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
Django = "^4.1"
|
||||
gunicorn = "^21.2.0"
|
||||
dj-database-url = "^2.1.0"
|
||||
python-dotenv = "^1.0.0"
|
||||
loguru = "^0.7.0"
|
||||
django-silk = "^5.0.0"
|
||||
mysqlclient = "^2.0.1"
|
||||
psycopg2-binary = "^2.9.6"
|
||||
rarfile = "^4.0"
|
||||
django-extensions = "^3.2.1"
|
||||
Pillow = "^10.0.1"
|
||||
django-imagekit = "^5.0.0"
|
||||
PyMuPDF = "~1.20.2"
|
||||
django-bootstrap4 = "^23.1"
|
||||
django-csp = "^3.7"
|
||||
django-boost = "^2.1"
|
||||
django-sri = "^0.7.0"
|
||||
django-permissions-policy = "^4.15.0"
|
||||
djangorestframework = "^3.13.1"
|
||||
django-filter = "^23.1"
|
||||
django-cors-headers = "^4.2.0"
|
||||
djangorestframework-simplejwt = "^5.2.0"
|
||||
django-webpack-loader = "^2.0.1"
|
||||
drf-yasg = "^1.20.0"
|
||||
drf-extensions = "^0.7.1"
|
||||
dependencies = [
|
||||
"dj-database-url>=3.1.2",
|
||||
"django>=6.1.2",
|
||||
"django-boost>=2.2",
|
||||
"django-cors-headers>=4.9.0",
|
||||
"django-csp>=4.0",
|
||||
"django-extensions>=4.1",
|
||||
"django-filter>=26.2",
|
||||
"django-imagekit>=6.1.1",
|
||||
"django-permissions-policy>=4.34.0",
|
||||
"django-silk>=5.6.0",
|
||||
"django-webpack-loader>=3.2.4",
|
||||
"djangorestframework>=3.18.3",
|
||||
"djangorestframework-simplejwt>=5.5.1",
|
||||
"drf-yasg>=1.21.18",
|
||||
"gunicorn>=26.2.0",
|
||||
"loguru>=0.7.3",
|
||||
"pillow>=12.3.0",
|
||||
"psycopg2-binary>=2.9.13",
|
||||
"pymupdf>=1.28.2",
|
||||
"python-dotenv>=1.2.4",
|
||||
"rarfile>=4.5",
|
||||
]
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
mypy = "^1.2.0"
|
||||
Werkzeug = "^2.2"
|
||||
pyOpenSSL = "^22.0.0"
|
||||
ipython = "^8.12.0"
|
||||
coverage = "^7.2.3"
|
||||
pre-commit = "^3.2.2"
|
||||
flake8 = "^6.0.0"
|
||||
flake8-annotations = "^3.0.0"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"coverage>=7.13.5",
|
||||
"ipython>=9.13.0",
|
||||
"mypy>=2",
|
||||
"pre-commit>=4.6",
|
||||
"pylint>=4.0.5",
|
||||
"pylint-django>=2.7.0",
|
||||
"pyopenssl>=26.2.0",
|
||||
"pytest>=8.4",
|
||||
"pytest-django>=4.8",
|
||||
"ruff>=0.17.0",
|
||||
"werkzeug>=3.1.8",
|
||||
]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pylint = "^2.15.0"
|
||||
pylint-django = "^2.5.3"
|
||||
mypy = "^1.2.0"
|
||||
[tool.ruff]
|
||||
target-version = "py314"
|
||||
line-length = 120
|
||||
extend-exclude = [
|
||||
"*/migrations/*",
|
||||
"cbreader/settings",
|
||||
"frontend",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "ANN", "B", "C4", "SIM", "UP"]
|
||||
# These were ignored by the previous flake8-annotations setup (ANN001 is the
|
||||
# modern name for the old ANN101).
|
||||
ignore = ["ANN001", "ANN002", "ANN003"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
combine-as-imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "cbreader.settings.test"
|
||||
python_files = ["test_*.py"]
|
||||
filterwarnings = [
|
||||
# drf_yasg still uses jsonschema.RefResolver, which is deprecated upstream.
|
||||
"ignore::DeprecationWarning:swagger_spec_validator",
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
[pytest]
|
||||
pep8maxlinelength = 119
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
@@ -1,46 +0,0 @@
|
||||
asgiref==3.7.2 ; python_version >= "3.12" and python_version < "4.0"
|
||||
autopep8==2.0.4 ; python_version >= "3.12" and python_version < "4.0"
|
||||
beautifulsoup4==4.12.2 ; python_version >= "3.12" and python_version < "4.0"
|
||||
colorama==0.4.6 ; python_version >= "3.12" and python_version < "4.0" and sys_platform == "win32"
|
||||
dj-database-url==2.1.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
django-appconf==1.0.5 ; python_version >= "3.12" and python_version < "4.0"
|
||||
django-boost==2.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
django-bootstrap4==23.2 ; python_version >= "3.12" and python_version < "4.0"
|
||||
django-cors-headers==4.2.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
django-csp==3.7 ; python_version >= "3.12" and python_version < "4.0"
|
||||
django-extensions==3.2.3 ; python_version >= "3.12" and python_version < "4.0"
|
||||
django-filter==23.3 ; python_version >= "3.12" and python_version < "4.0"
|
||||
django-imagekit==5.0.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
django-permissions-policy==4.17.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
django-silk==5.0.4 ; python_version >= "3.12" and python_version < "4.0"
|
||||
django-sri==0.7.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
django-webpack-loader==2.0.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
django==4.2.5 ; python_version >= "3.12" and python_version < "4.0"
|
||||
djangorestframework-simplejwt==5.3.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
djangorestframework==3.14.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
drf-extensions==0.7.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
drf-yasg==1.21.7 ; python_version >= "3.12" and python_version < "4.0"
|
||||
gprof2dot==2022.7.29 ; python_version >= "3.12" and python_version < "4.0"
|
||||
gunicorn==21.2.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
inflection==0.5.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
loguru==0.7.2 ; python_version >= "3.12" and python_version < "4.0"
|
||||
mysqlclient==2.2.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
packaging==23.2 ; python_version >= "3.12" and python_version < "4.0"
|
||||
pilkit==3.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
pillow==10.0.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
psycopg2-binary==2.9.9 ; python_version >= "3.12" and python_version < "4.0"
|
||||
pycodestyle==2.11.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
pyjwt==2.8.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
pymupdf==1.20.2 ; python_version >= "3.12" and python_version < "4.0"
|
||||
python-dotenv==1.0.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
pytz==2023.3.post1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
pyyaml==6.0.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
rarfile==4.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
soupsieve==2.5 ; python_version >= "3.12" and python_version < "4.0"
|
||||
sqlparse==0.4.4 ; python_version >= "3.12" and python_version < "4.0"
|
||||
typing-extensions==4.8.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
tzdata==2023.3 ; python_version >= "3.12" and python_version < "4.0" and sys_platform == "win32"
|
||||
ua-parser==0.18.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
uritemplate==4.1.1 ; python_version >= "3.12" and python_version < "4.0"
|
||||
user-agents==2.2.0 ; python_version >= "3.12" and python_version < "4.0"
|
||||
win32-setctime==1.1.0 ; python_version >= "3.12" and python_version < "4.0" and sys_platform == "win32"
|
||||
@@ -1,26 +0,0 @@
|
||||
[tool:pytest]
|
||||
addopts = --flake8
|
||||
|
||||
[flake8]
|
||||
max-line-length = 120
|
||||
ignore =
|
||||
* ANN101
|
||||
* ANN002
|
||||
* ANN003
|
||||
# Ignore rules which contradicts black's formatting choices:
|
||||
; * E501
|
||||
; * W503
|
||||
; * W504
|
||||
; * E266
|
||||
exclude =
|
||||
# Exclude these files
|
||||
*/migrations/*
|
||||
cbreader/settings
|
||||
frontend
|
||||
|
||||
|
||||
[tool:isort]
|
||||
line_length = 119
|
||||
indent = ' '
|
||||
multi_line_output = 3
|
||||
include_trailing_comma = 1
|
||||
@@ -1,12 +0,0 @@
|
||||
from distutils.core import setup
|
||||
|
||||
setup(
|
||||
name="cbwebreader",
|
||||
version="",
|
||||
packages=["comic", "comic.migrations", "cbreader", "comic_auth", "comic_auth.migrations"],
|
||||
url="https://github.com/ajurna/cbwebreader",
|
||||
license="http://creativecommons.org/licenses/by-sa/4.0/",
|
||||
author="Ajurna",
|
||||
author_email="ajurna@gmail.com",
|
||||
description="Comic Book Web Reader",
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Whitespace-only changes.
Reference in new issue
Block a user