├── .circleci └── config.yml ├── .dockerignore ├── .env.platforms ├── .gitignore ├── .null-ls-root ├── .picopt_treestamps.yaml ├── .prettierignore ├── .shellcheckrc ├── DOCKER.md ├── LICENSE ├── Makefile ├── NEWS.md ├── PINNED.md ├── README.md ├── WINDOWS.md ├── bin ├── __init__.py ├── alpha-deploy.sh ├── alpha-test-build-dist.sh ├── benchmark-opds.sh ├── build-choices.sh ├── build-dist.sh ├── circleci │ ├── circleci-step-halt.sh │ └── old │ │ ├── circleci-set-dist-cache-key-if-last-commit-equal.sh │ │ ├── circleci-set-dist-cache-key-to-current-commit.sh │ │ └── circleci-set-skip-dist-build.sh ├── clean-pycache.sh ├── collectstatic.sh ├── dev-docker.sh ├── dev-module.sh ├── dev-prod-server.sh ├── dev-reverse-proxy.sh ├── dev-server.sh ├── dev-ttabs.sh ├── fix-lint-backend.sh ├── icons_transform.py ├── kill-codex.sh ├── lint-backend.sh ├── makefile-help.mk ├── manage.py ├── pm ├── prettier-nginx.sh ├── pypi-deploy.sh ├── roman.sh ├── sortignore.sh ├── test-backend.sh ├── uml.sh ├── update-builder-requirement.sh ├── update-deps.sh ├── vendor-diff-package.sh ├── vendor-patch-imports.sh ├── venv-upgrade.sh └── version.sh ├── builder-requirements.txt ├── codex ├── __init__.py ├── applications │ ├── __init__.py │ ├── lifespan.py │ └── websocket.py ├── asgi.py ├── choices │ ├── __init__.py │ ├── admin.py │ ├── browser.py │ ├── choices_to_json.py │ ├── notifications.py │ └── reader.py ├── db.py ├── exceptions.py ├── img │ ├── folder.svg │ ├── imprint.svg │ ├── logo-maskable.svg │ ├── logo.svg │ ├── missing-cover.svg │ ├── publisher.svg │ ├── series.svg │ ├── story-arc.svg │ └── volume.svg ├── librarian │ ├── README.md │ ├── __init__.py │ ├── bookmark │ │ ├── __init__.py │ │ ├── bookmarkd.py │ │ ├── tasks.py │ │ ├── update.py │ │ └── user_active.py │ ├── covers │ │ ├── __init__.py │ │ ├── coverd.py │ │ ├── create.py │ │ ├── path.py │ │ ├── purge.py │ │ ├── status.py │ │ └── tasks.py │ ├── cron │ │ ├── __init__.py │ │ └── crond.py │ ├── delayed_taskd.py │ ├── importer │ │ ├── __init__.py │ │ ├── aggregate.py │ │ ├── cache.py │ │ ├── const.py │ │ ├── create_comics.py │ │ ├── create_covers.py │ │ ├── create_fks.py │ │ ├── deleted.py │ │ ├── extract.py │ │ ├── failed_imports.py │ │ ├── importer.py │ │ ├── importerd.py │ │ ├── init.py │ │ ├── link_comics.py │ │ ├── link_covers.py │ │ ├── moved.py │ │ ├── query_covers.py │ │ ├── query_fks.py │ │ ├── status.py │ │ └── tasks.py │ ├── janitor │ │ ├── __init__.py │ │ ├── cleanup.py │ │ ├── failed_imports.py │ │ ├── integrity.py │ │ ├── janitor.py │ │ ├── latest_version.py │ │ ├── scheduled_time.py │ │ ├── status.py │ │ ├── tasks.py │ │ ├── update.py │ │ └── vacuum.py │ ├── librariand.py │ ├── mp_queue.py │ ├── notifier │ │ ├── __init__.py │ │ ├── notifierd.py │ │ └── tasks.py │ ├── search │ │ ├── __init__.py │ │ ├── optimize.py │ │ ├── remove.py │ │ ├── searchd.py │ │ ├── status.py │ │ ├── tasks.py │ │ └── update.py │ ├── tasks.py │ ├── telemeter │ │ ├── __init__.py │ │ ├── scheduled_time.py │ │ ├── stats.py │ │ ├── tasks.py │ │ └── telemeter.py │ └── watchdog │ │ ├── README.md │ │ ├── __init__.py │ │ ├── db_snapshot.py │ │ ├── dir_snapshot_diff.py │ │ ├── emitter.py │ │ ├── event_batcherd.py │ │ ├── events.py │ │ ├── observers.py │ │ ├── status.py │ │ └── tasks.py ├── logger │ ├── __init__.py │ ├── formatter.py │ ├── logger.py │ ├── loggerd.py │ └── mp_queue.py ├── logger_base.py ├── memory.py ├── middleware.py ├── migrations │ ├── 0001_init.py │ ├── 0002_auto_20200826_0622.py │ ├── 0003_auto_20200831_2033.py │ ├── 0004_failedimport.py │ ├── 0005_auto_20200918_0146.py │ ├── 0006_update_default_names_and_remove_duplicate_comics.py │ ├── 0007_auto_20211210_1710.py │ ├── 0008_alter_comic_created_at_alter_comic_format_and_more.py │ ├── 0009_alter_comic_parent_folder.py │ ├── 0010_haystack.py │ ├── 0011_library_groups_and_metadata_changes.py │ ├── 0012_rename_description_comic_comments.py │ ├── 0013_int_issue_count_longer_charfields.py │ ├── 0014_pdf_issue_suffix_remove_cover_image_sort_name.py │ ├── 0015_link_comics_to_top_level_folders.py │ ├── 0016_remove_comic_cover_path_librarianstatus.py │ ├── 0017_alter_timestamp_options_alter_adminflag_name_and_more.py │ ├── 0018_rename_userbookmark_bookmark.py │ ├── 0019_delete_queuejob.py │ ├── 0020_remove_search_tables.py │ ├── 0021_bookmark_fit_to_choices_read_in_reverse.py │ ├── 0022_bookmark_vertical_useractive_null_statuses.py │ ├── 0023_rename_credit_creator_and_more.py │ ├── 0024_comic_gtin_comic_story_arc_number.py │ ├── 0025_add_story_arc_number.py │ ├── 0026_comicbox_1.py │ ├── 0027_import_order_and_covers.py │ ├── 0028_telemeter.py │ ├── 0029_comicfts.py │ ├── 0030_nocase_collation_day_month_indexes_status_types.py │ ├── 0031_adminflag_banner.py │ ├── 0032_alter_librarianstatus_preactive.py │ ├── 0033_alter_librarianstatus_status_type.py │ └── __init__.py ├── models │ ├── __init__.py │ ├── admin.py │ ├── base.py │ ├── bookmark.py │ ├── comic.py │ ├── functions.py │ ├── groups.py │ ├── library.py │ ├── named.py │ ├── paths.py │ ├── query.py │ └── util.py ├── permissions.py ├── registration.py ├── run.py ├── serializers │ ├── README.md │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ ├── flags.py │ │ ├── groups.py │ │ ├── libraries.py │ │ ├── stats.py │ │ ├── tasks.py │ │ └── users.py │ ├── auth.py │ ├── browser │ │ ├── __init__.py │ │ ├── choices.py │ │ ├── filters.py │ │ ├── metadata.py │ │ ├── mixins.py │ │ ├── mtime.py │ │ ├── page.py │ │ └── settings.py │ ├── fields │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── browser.py │ │ ├── group.py │ │ ├── reader.py │ │ ├── sanitized.py │ │ ├── session.py │ │ ├── stats.py │ │ └── vuetify.py │ ├── homepage.py │ ├── mixins.py │ ├── models │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── base.py │ │ ├── bookmark.py │ │ ├── comic.py │ │ ├── groups.py │ │ ├── named.py │ │ └── pycountry.py │ ├── opds │ │ ├── __init__.py │ │ ├── authentication.py │ │ ├── urls.py │ │ ├── v1.py │ │ └── v2 │ │ │ ├── __init__.py │ │ │ ├── facet.py │ │ │ ├── feed.py │ │ │ ├── links.py │ │ │ ├── metadata.py │ │ │ ├── progression.py │ │ │ ├── publication.py │ │ │ └── unused.py │ ├── reader.py │ ├── redirect.py │ ├── route.py │ ├── settings.py │ └── versions.py ├── settings │ ├── README.md │ ├── __init__.py │ ├── hypercorn.py │ ├── hypercorn.toml.default │ ├── secret_key.py │ ├── settings.py │ ├── timezone.py │ └── whitenoise.py ├── signals │ ├── __init__.py │ ├── django_signals.py │ └── os_signals.py ├── startup.py ├── static_src │ ├── img │ │ ├── .picopt_treestamps.yaml │ │ ├── folder.svg │ │ ├── imprint.svg │ │ ├── logo-32.webp │ │ ├── logo-maskable-180.webp │ │ ├── logo-maskable.svg │ │ ├── logo.svg │ │ ├── missing-cover-165.webp │ │ ├── missing-cover.svg │ │ ├── publisher.svg │ │ ├── series.svg │ │ ├── story-arc.svg │ │ └── volume.svg │ ├── pwa │ │ └── offline.html │ └── robots.txt ├── status.py ├── status_controller.py ├── templates │ ├── README.md │ ├── headers-icons.html │ ├── headers-script-globals.html │ ├── index.html │ ├── opds_v1 │ │ ├── index.xml │ │ └── opensearch_v1.xml │ ├── pwa │ │ ├── headers.html │ │ ├── manifest.webmanifest │ │ ├── serviceworker-register.js │ │ └── serviceworker.js │ └── swagger-ui.html ├── threads.py ├── urls │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── auth.py │ │ ├── browser.py │ │ ├── reader.py │ │ ├── root.py │ │ └── v3.py │ ├── app.py │ ├── const.py │ ├── converters.py │ ├── opds │ │ ├── __init__.py │ │ ├── authentication.py │ │ ├── binary.py │ │ ├── root.py │ │ ├── v1.py │ │ └── v2.py │ ├── pwa.py │ ├── root.py │ └── spectacular.py ├── util.py ├── version.py ├── views │ ├── README.md │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ ├── api_key.py │ │ ├── auth.py │ │ ├── flag.py │ │ ├── group.py │ │ ├── library.py │ │ ├── stats.py │ │ ├── tasks.py │ │ └── user.py │ ├── auth.py │ ├── bookmark.py │ ├── browser │ │ ├── __init__.py │ │ ├── annotate │ │ │ ├── __init__.py │ │ │ ├── bookmark.py │ │ │ ├── card.py │ │ │ └── order.py │ │ ├── bookmark.py │ │ ├── breadcrumbs.py │ │ ├── browser.py │ │ ├── choices.py │ │ ├── cover.py │ │ ├── download.py │ │ ├── filters │ │ │ ├── __init__.py │ │ │ ├── bookmark.py │ │ │ ├── field.py │ │ │ ├── filter.py │ │ │ ├── group.py │ │ │ └── search │ │ │ │ ├── __init__.py │ │ │ │ ├── aliases.py │ │ │ │ ├── field │ │ │ │ ├── __init__.py │ │ │ │ ├── column.py │ │ │ │ ├── expression.py │ │ │ │ ├── filter.py │ │ │ │ ├── optimize.py │ │ │ │ └── parse.py │ │ │ │ ├── fts.py │ │ │ │ └── parse.py │ │ ├── group_mtime.py │ │ ├── metadata │ │ │ ├── __init__.py │ │ │ ├── annotate.py │ │ │ ├── copy_intersections.py │ │ │ ├── metadata.py │ │ │ └── query_intersections.py │ │ ├── mtime.py │ │ ├── order_by.py │ │ ├── page_in_bounds.py │ │ ├── paginate.py │ │ ├── params.py │ │ ├── settings.py │ │ ├── title.py │ │ └── validate.py │ ├── const.py │ ├── download.py │ ├── error.py │ ├── frontend.py │ ├── mixins.py │ ├── opds │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── authentication_v1.py │ │ ├── binary.py │ │ ├── const.py │ │ ├── urls.py │ │ ├── util.py │ │ ├── v1 │ │ │ ├── __init__.py │ │ │ ├── data.py │ │ │ ├── entry │ │ │ │ ├── __init__.py │ │ │ │ ├── data.py │ │ │ │ ├── entry.py │ │ │ │ └── links.py │ │ │ ├── facets.py │ │ │ ├── feed.py │ │ │ ├── links.py │ │ │ └── opensearch_v1.py │ │ └── v2 │ │ │ ├── __init__.py │ │ │ ├── const.py │ │ │ ├── feed.py │ │ │ ├── href.py │ │ │ ├── links.py │ │ │ ├── progression.py │ │ │ ├── publications.py │ │ │ └── top_links.py │ ├── public.py │ ├── pwa.py │ ├── reader │ │ ├── __init__.py │ │ ├── arcs.py │ │ ├── books.py │ │ ├── page.py │ │ ├── params.py │ │ ├── reader.py │ │ └── settings.py │ ├── session.py │ ├── settings.py │ ├── template.py │ ├── timezone.py │ ├── util.py │ └── version.py ├── websockets │ ├── README.md │ ├── __init__.py │ ├── aio_queue.py │ ├── consumers.py │ └── listener.py └── worker_base.py ├── docker-compose.yaml ├── docker ├── Dockerfile ├── base.Dockerfile ├── builder-base.Dockerfile ├── debian.sources ├── dev.Dockerfile ├── dist-builder.Dockerfile ├── docker-arch.sh ├── docker-build-image.sh ├── docker-compose-exit.sh ├── docker-create-multiarch-codex.sh ├── docker-env-filename.sh ├── docker-env.sh ├── docker-hub-remove-tags.sh ├── docker-init.sh ├── docker-install-api-deps.sh ├── docker-install-hub-tool.sh ├── docker-login.sh ├── docker-promote-latest.sh ├── docker-tag-remote-version-as-latest.sh ├── docker-version-checksum.sh ├── docker-version-codex-arch.sh ├── docker-version-codex-base.sh ├── docker-version-codex-builder-base.sh ├── docker-version-codex-dist-builder.sh ├── fix-manifest-deploy-to-docker-hub.sh ├── nginx │ ├── README.md │ └── http.d │ │ └── codex │ │ └── test.conf └── registry.yaml ├── eslint.config.js ├── frontend ├── .npmrc ├── .prettierignore ├── Makefile ├── README.md ├── bin │ ├── dev-server.sh │ ├── fix-lint.sh │ ├── lint.sh │ └── test.sh ├── eslint.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── src │ ├── admin.vue │ ├── api │ │ └── v3 │ │ │ ├── admin.js │ │ │ ├── auth.js │ │ │ ├── base.js │ │ │ ├── browser.js │ │ │ ├── common.js │ │ │ ├── notify.js │ │ │ ├── reader.js │ │ │ └── vuetify-items.js │ ├── app.vue │ ├── browser.vue │ ├── comic-name.js │ ├── components │ │ ├── admin │ │ │ ├── admin-header.vue │ │ │ ├── browser-link.vue │ │ │ ├── create-update-dialog │ │ │ │ ├── create-update-button.vue │ │ │ │ ├── create-update-dialog.vue │ │ │ │ ├── group-create-update-inputs.vue │ │ │ │ ├── library-create-update-inputs.vue │ │ │ │ ├── relation-picker.vue │ │ │ │ ├── server-folder-picker.vue │ │ │ │ ├── time-text-field.vue │ │ │ │ └── user-create-update-inputs.vue │ │ │ ├── drawer │ │ │ │ ├── admin-menu.vue │ │ │ │ ├── admin-settings-button-progress.vue │ │ │ │ ├── admin-settings-drawer.vue │ │ │ │ ├── admin-settings-panel.vue │ │ │ │ └── status-list.vue │ │ │ ├── group-chip.vue │ │ │ └── tabs │ │ │ │ ├── admin-table.vue │ │ │ │ ├── custom-covers-panel.vue │ │ │ │ ├── datetime-column.vue │ │ │ │ ├── delete-row-dialog.vue │ │ │ │ ├── failed-imports-panel.vue │ │ │ │ ├── flag-descriptions.json │ │ │ │ ├── flag-tab.vue │ │ │ │ ├── group-tab.vue │ │ │ │ ├── library-tab.vue │ │ │ │ ├── library-table.vue │ │ │ │ ├── relation-chips.vue │ │ │ │ ├── stats-tab.vue │ │ │ │ ├── stats-table.vue │ │ │ │ ├── tabs.vue │ │ │ │ ├── task-tab.vue │ │ │ │ └── user-tab.vue │ │ ├── anchors.scss │ │ ├── auth │ │ │ ├── auth-menu.vue │ │ │ ├── change-password-dialog.vue │ │ │ └── login-dialog.vue │ │ ├── banner.vue │ │ ├── book-cover.scss │ │ ├── book-cover.vue │ │ ├── browser │ │ │ ├── browser-header.vue │ │ │ ├── card │ │ │ │ ├── browser-card-menu.vue │ │ │ │ ├── card.vue │ │ │ │ ├── controls.vue │ │ │ │ ├── order-by-caption.vue │ │ │ │ └── subtitle.vue │ │ │ ├── drawer │ │ │ │ ├── browser-settings-drawer.vue │ │ │ │ └── browser-settings-panel.vue │ │ │ ├── empty.vue │ │ │ ├── main.vue │ │ │ └── toolbars │ │ │ │ ├── breadcrumbs │ │ │ │ ├── breadcrumbs.vue │ │ │ │ └── browser-toolbar-breadcrumbs.vue │ │ │ │ ├── browser-toolbar-title.vue │ │ │ │ ├── nav │ │ │ │ ├── browser-nav-button.vue │ │ │ │ └── browser-toolbar-nav.vue │ │ │ │ ├── search │ │ │ │ ├── browser-toolbar-search.vue │ │ │ │ ├── search-combobox.vue │ │ │ │ ├── search-help-text.vue │ │ │ │ └── search-help.vue │ │ │ │ └── top │ │ │ │ ├── browser-toolbar-top.vue │ │ │ │ ├── filter-by-select.vue │ │ │ │ ├── filter-sub-menu.vue │ │ │ │ ├── order-by-select.vue │ │ │ │ ├── order-reverse-button.vue │ │ │ │ ├── search-button.vue │ │ │ │ ├── toolbar-button.vue │ │ │ │ └── top-group-select.vue │ │ ├── cancel-button.vue │ │ ├── close-button.vue │ │ ├── codex-list-item.vue │ │ ├── confirm-dialog.vue │ │ ├── confirm-footer.vue │ │ ├── download-button.vue │ │ ├── empty.vue │ │ ├── mark-read-button.vue │ │ ├── metadata │ │ │ ├── expand-button.vue │ │ │ ├── metadata-body.vue │ │ │ ├── metadata-chip.vue │ │ │ ├── metadata-controls.vue │ │ │ ├── metadata-cover.vue │ │ │ ├── metadata-dialog.vue │ │ │ ├── metadata-header.vue │ │ │ ├── metadata-ratings.vue │ │ │ ├── metadata-tags.vue │ │ │ ├── metadata-text.vue │ │ │ ├── table.scss │ │ │ └── tags-table.vue │ │ ├── pagination-nav-button.vue │ │ ├── pagination-slider.vue │ │ ├── pagination-toolbar.vue │ │ ├── placeholder-loading.vue │ │ ├── reader │ │ │ ├── book-change-activator.vue │ │ │ ├── book-change-drawer.vue │ │ │ ├── books-window.vue │ │ │ ├── change-column.scss │ │ │ ├── drawer │ │ │ │ ├── download-panel.vue │ │ │ │ ├── keyboard-shortcuts-panel.vue │ │ │ │ ├── keyboard-shortcuts-table.vue │ │ │ │ ├── reader-settings-drawer.vue │ │ │ │ ├── reader-settings-panel.vue │ │ │ │ └── reader-settings-super-panel.vue │ │ │ ├── empty.vue │ │ │ ├── pager │ │ │ │ ├── horizontal-pages.vue │ │ │ │ ├── page-change-link.vue │ │ │ │ ├── page │ │ │ │ │ ├── page-error.vue │ │ │ │ │ ├── page-img.vue │ │ │ │ │ ├── page-loading.vue │ │ │ │ │ └── page.vue │ │ │ │ ├── pager-full-pdf.vue │ │ │ │ ├── pager-horizontal.vue │ │ │ │ ├── pager-vertical.vue │ │ │ │ ├── pager.vue │ │ │ │ ├── pdf-doc.vue │ │ │ │ └── scale-for-scroll.vue │ │ │ └── toolbars │ │ │ │ ├── nav │ │ │ │ ├── reader-book-change-nav-button.vue │ │ │ │ ├── reader-nav-button.vue │ │ │ │ └── reader-toolbar-nav.vue │ │ │ │ └── top │ │ │ │ ├── reader-arc-select.vue │ │ │ │ └── reader-toolbar-top.vue │ │ ├── scale-button.vue │ │ ├── settings │ │ │ ├── button.vue │ │ │ ├── opds-dialog.vue │ │ │ ├── opds-url.vue │ │ │ ├── repo-footer.vue │ │ │ ├── settings-drawer.vue │ │ │ └── version-footer.vue │ │ ├── submit-footer.vue │ │ ├── toolbar-select.vue │ │ └── unauthorized.vue │ ├── copy-to-clipboard.js │ ├── datetime.js │ ├── http-error.vue │ ├── main.js │ ├── platform.js │ ├── plugins │ │ ├── router.js │ │ ├── vue-native-sock.js │ │ └── vuetify.js │ ├── reader.vue │ ├── route.js │ ├── stores │ │ ├── admin.js │ │ ├── auth.js │ │ ├── browser.js │ │ ├── common.js │ │ ├── metadata.js │ │ ├── reader.js │ │ ├── socket.js │ │ └── store.js │ └── util.js ├── tests │ └── unit │ │ └── reader-nav-button.test.js └── vite.config.js ├── mock_comics ├── __init__.py ├── mock_comics.py └── mock_comics.sh ├── package-lock.json ├── package.json ├── poetry.lock ├── pyproject.toml ├── strange.jpg └── tests ├── README.md ├── __init__.py ├── nginx-local-codex.conf ├── test_asgi.py └── test_models.py /.dockerignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.py[co] 3 | *Dockerfile 4 | *~ 5 | .DS_Store 6 | .circleci 7 | .coverage* 8 | .docker-token 9 | .env* 10 | .eslintcache 11 | .git 12 | .mypy_cache 13 | .picopt_treestamps.yaml 14 | .pypi-token 15 | .pytest_cache 16 | .ropeproject 17 | .venv* 18 | MANIFEST 19 | NEWS 20 | TODO.md 21 | __pycache__ 22 | cache 23 | codex/static_build 24 | codex/static_root 25 | comics 26 | config 27 | dev* 28 | !dist 29 | docker* 30 | !docker/debian.sources 31 | docker-compose* 32 | mock_comics 33 | monkeytype.sqlite3 34 | node_modules 35 | strange.jpg 36 | test-results 37 | update-builder-requirement.sh 38 | version.sh 39 | -------------------------------------------------------------------------------- /.env.platforms: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # platforms to use for building with docker 3 | # shellcheck disable=SC2034 4 | PLATFORMS=linux/amd64,linux/arm64 5 | #,linux/armhf blocked on https://github.com/pyca/cryptography/issues/6286 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *$py.class 2 | *.cover 3 | *.egg 4 | *.egg-info/ 5 | *.log 6 | *.manifest 7 | *.mo 8 | *.pot 9 | *.py,cover 10 | *.py[cod] 11 | *.sage.py 12 | *.so 13 | *.spec 14 | *~ 15 | .DS_Store 16 | .Python 17 | .cache 18 | .coverage 19 | .coverage.* 20 | .dmypy.json 21 | .docker-token 22 | .eggs/ 23 | .env 24 | .env-* 25 | .env.pushover 26 | .eslintcache 27 | .hypothesis/ 28 | .installed.cfg 29 | .ipynb_checkpoints 30 | .mypy_cache/ 31 | .nox/ 32 | .npm 33 | .pypi-token 34 | .pypirc 35 | .pyre/ 36 | .pytest_cache/ 37 | .python-version 38 | .ropeproject 39 | .scrapy 40 | .spyderproject 41 | .spyproject 42 | .tox/ 43 | .venv* 44 | .webassets-cache 45 | ENV/ 46 | MANIFEST 47 | TODO.md 48 | __pycache__/ 49 | __pypackages__/ 50 | __snapshots__ 51 | build/ 52 | celerybeat-schedule 53 | celerybeat.pid 54 | codex/static_build/ 55 | codex/static_root/ 56 | comics 57 | config 58 | coverage.xml 59 | develop-eggs/ 60 | dist/ 61 | dmypy.json 62 | docs/_build/ 63 | downloads/ 64 | eggs/ 65 | env.bak/ 66 | env/ 67 | frontend/components.d.ts 68 | frontend/coverage 69 | frontend/src/choices/ 70 | htmlcov/ 71 | instance/ 72 | jspm_packages/ 73 | lib/ 74 | lib64/ 75 | monkeytype.sqlite3 76 | node_modules 77 | nosetests.xml 78 | pip-delete-this-directory.txt 79 | pip-log.txt 80 | requirements.txt 81 | test-results 82 | -------------------------------------------------------------------------------- /.null-ls-root: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajslater/codex/f34aa9ff4e86592dae729ac841f872fa860ad9df/.null-ls-root -------------------------------------------------------------------------------- /.picopt_treestamps.yaml: -------------------------------------------------------------------------------- 1 | config: 2 | bigger: false 3 | convert_to: [] 4 | formats: 5 | - GIF 6 | - JPEG 7 | - PNG 8 | - WEBP 9 | ignore: [] 10 | keep_metadata: true 11 | recurse: true 12 | symlinks: true 13 | .: 1657150080.593595 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | !codex/templates/pwa/serviceworkerRegister.js 2 | .circleci 3 | .git 4 | .mypy_cache 5 | .pytest_cache 6 | .ruff_cache 7 | .venv* 8 | /config 9 | /node_modules 10 | /test-results 11 | __pycache__ 12 | cache 13 | codex/_vendor 14 | codex/static_build 15 | codex/static_src/img/*.svg 16 | codex/static_root 17 | codex/templates/**/*.html 18 | codex/templates/**/*.xml 19 | codex/templates/pwa/manifest.webmanifest 20 | comics 21 | dist 22 | frontend 23 | package-lock.json 24 | poetry.lock 25 | webpack-stats.json 26 | -------------------------------------------------------------------------------- /.shellcheckrc: -------------------------------------------------------------------------------- 1 | external-sources=true 2 | -------------------------------------------------------------------------------- /PINNED.md: -------------------------------------------------------------------------------- 1 | # Prettier 2 | 3 | [Prettier 3.5.0](https://prettier.io/blog/2025/02/09/3.5.0.html) has new 4 | unsightly wrapping behavior in view blocks 5 | 6 | tweaking objectWrap doesn't seem to fix it. 7 | -------------------------------------------------------------------------------- /bin/__init__.py: -------------------------------------------------------------------------------- 1 | """Repo make scripts.""" 2 | -------------------------------------------------------------------------------- /bin/alpha-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run alpha, test, build and deploy for a local release on one arch 3 | set -euxo pipefail 4 | # shellcheck disable=SC1091 5 | ./docker/docker-env.sh 6 | ./docker/docker-build-image.sh codex-base 7 | ./docker/docker-build-image.sh codex-builder-base 8 | ./bin/alpha-test-build-dist.sh 9 | ./docker/docker-build-image.sh codex 10 | -------------------------------------------------------------------------------- /bin/alpha-test-build-dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run CI test & build for a local alpha release 3 | set -euxo pipefail 4 | ./docker/docker-build-image.sh codex-dist-builder 5 | ./docker/docker-compose-exit.sh codex-save-cache 6 | ./docker/docker-compose-exit.sh codex-frontend-lint 7 | ./docker/docker-compose-exit.sh codex-frontend-test 8 | ./docker/docker-compose-exit.sh codex-frontend-build 9 | ./docker/docker-compose-exit.sh codex-backend-test 10 | ./docker/docker-compose-exit.sh codex-backend-lint 11 | ./docker/docker-compose-exit.sh codex-build-dist 12 | -------------------------------------------------------------------------------- /bin/benchmark-opds.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # benchmark opds url times 3 | set -euo pipefail 4 | 5 | BASE_URL="http://localhost:9810" 6 | OPDS_BASE="/opds/v1.2" 7 | 8 | timeit() { 9 | echo "${1}": 10 | TEST_PATH="${OPDS_BASE}${2}" 11 | echo -e "\t$TEST_PATH" 12 | URL="${BASE_URL}${TEST_PATH}" 13 | /usr/bin/time -h curl -S -s -o /dev/null "$URL" 14 | } 15 | 16 | timeit "Recently Added:" "/s/0/1?orderBy=created_at&orderReverse=True" 17 | #timeit "All Series" "/r/0/1?topGroup=s" 18 | -------------------------------------------------------------------------------- /bin/build-choices.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Build json choices for frontend using special script. 3 | set -euo pipefail 4 | THIS_DIR="$(dirname "$0")/.." 5 | cd "$THIS_DIR" || exit 1 6 | export PYTHONPATH="${PYTHONPATH:-}:$THIS_DIR" 7 | CHOICES_DIR=frontend/src/choices 8 | # rm -rf "${CHOICES_DIR:?}"/* # breaks vite build 9 | poetry run codex/choices/choices_to_json.py "$CHOICES_DIR" 10 | -------------------------------------------------------------------------------- /bin/build-dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Build script for producing a codex python package 3 | set -euxo pipefail 4 | cd "$(dirname "$0")" 5 | 6 | export BUILD=1 7 | make collectstatic 8 | ./bin/pm check 9 | echo "*** build and package application ***" 10 | PIP_CACHE_DIR=$(pip3 cache dir) 11 | export PIP_CACHE_DIR 12 | poetry build 13 | -------------------------------------------------------------------------------- /bin/circleci/circleci-step-halt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # If the skip job flag is step. skip this step. 3 | set -euo pipefail 4 | if [ -f ./SKIP_STEPS ]; then 5 | circleci-agent step halt 6 | fi 7 | -------------------------------------------------------------------------------- /bin/circleci/old/circleci-set-dist-cache-key-if-last-commit-equal.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Is this current commit equal to the previous one 3 | set -euo pipefail 4 | FN=$1 5 | A=$(git rev-list -n 1 HEAD^1) 6 | B=$(git rev-list -n 1 HEAD) # $CIRCLE_SHA1 7 | # shellcheck disable=SC2086 8 | if git log --decorate --graph --oneline --cherry-mark --boundary "$A...$B" | grep "^+"; then 9 | echo "$A" > "$FN" 10 | else 11 | echo "$B" > "$FN" 12 | fi 13 | -------------------------------------------------------------------------------- /bin/circleci/old/circleci-set-dist-cache-key-to-current-commit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Is this current commit equal to the previous one 3 | set -euo pipefail 4 | FN=$1 5 | git rev-list -n 1 HEAD > "$FN" 6 | -------------------------------------------------------------------------------- /bin/circleci/old/circleci-set-skip-dist-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # sets a circleci skip steps flag if the wheel is already built 3 | set -eou pipefail 4 | pip3 install --upgrade pip 5 | pip3 install --requirement builder-requirements.txt 6 | PKG_VERSION=$(./version.sh) 7 | WHEEL_PATH=dist/codex-${PKG_VERSION}-py3-none-any.whl 8 | if [ -f "$WHEEL_PATH" ]; then 9 | touch ./SKIP_STEPS 10 | fi 11 | -------------------------------------------------------------------------------- /bin/clean-pycache.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # remove all pycache dirs 3 | find codex -name "__pycache__" -print0 | xargs -0 rm -rf 4 | -------------------------------------------------------------------------------- /bin/collectstatic.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run the django collectstatic command to collect static files from all 3 | # locations specified in settings.STATIC_DIRS and place them in 4 | # settings.STATIC_ROOT for production builds. 5 | set -euo pipefail 6 | BUILD=1 ./bin/pm collectstatic --clear --no-input 7 | -------------------------------------------------------------------------------- /bin/dev-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Recreate the codex-dev container and enter it with a shell 3 | set -euo pipefail 4 | docker rm -f codex-dev || true 5 | docker compose down 6 | docker compose up codex-dev -d 7 | -------------------------------------------------------------------------------- /bin/dev-module.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run a main method in an arbitrary module 3 | set -euxo pipefail 4 | THIS_DIR="$(dirname "$0")" 5 | cd "$THIS_DIR" || exit 1 6 | export PYTHONPATH="${PYTHONPATH:-}:$THIS_DIR" 7 | export DEBUG="${DEBUG:-1}" 8 | export PYTHONDEVMODE="$DEBUG" 9 | export PYTHONDONTWRITEBYTECODE=1 #"$DEBUG" 10 | poetry run python3 "$@" 11 | -------------------------------------------------------------------------------- /bin/dev-prod-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # run a production-like server 3 | export PYTHONPATH="$PYTHONPATH:$THIS_DIR" 4 | poetry run python3 ./codex/run.py 5 | -------------------------------------------------------------------------------- /bin/dev-reverse-proxy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run an nginx reverse proxy with a subpath for development testing 3 | set -euo pipefail 4 | cd "$(dirname "$0")/nginx" || exit 1 5 | docker-compose -f nginx.yaml up 6 | -------------------------------------------------------------------------------- /bin/dev-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run the codex server 3 | set -euxo pipefail 4 | THIS_DIR="$(dirname "$0")/.." 5 | cd "$THIS_DIR" || exit 1 6 | export PYTHONPATH="${PYTHONPATH:-}:$THIS_DIR" 7 | export DEBUG="${DEBUG:-1}" 8 | export PYTHONDEVMODE="$DEBUG" 9 | export PYTHONDONTWRITEBYTECODE=1 10 | #export CODEX_THROTTLE_OPDS=10 11 | #export CODEX_THROTTLE_USER=10 12 | poetry run python3 ./codex/run.py 13 | -------------------------------------------------------------------------------- /bin/dev-ttabs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Open development server processes in macOS terminal tabs 3 | # Requires npm ttab 4 | # The Vue dev server 5 | ttab -t "Codex Vue" "make dev-frontend-server" 6 | # The API server 7 | make dev-server 8 | -------------------------------------------------------------------------------- /bin/fix-lint-backend.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Fix common linting errors 3 | set -euxo pipefail 4 | # bin/sortignore.sh borks order for !ignores it think 5 | #################### 6 | ###### Python ###### 7 | ################### 8 | poetry run ruff check --fix . 9 | poetry run ruff format . 10 | poetry run djlint codex/templates --profile=django --reformat 11 | 12 | ############################################ 13 | ##### Javascript, JSON, Markdown, YAML ##### 14 | ############################################ 15 | npm run fix 16 | 17 | ################### 18 | ###### Shell ###### 19 | ################### 20 | shellharden --replace ./**/*.sh .env.platforms 21 | -------------------------------------------------------------------------------- /bin/kill-codex.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # kill all codex processes 3 | set -euo pipefail 4 | pkill -9 -f 'codex/run.py' 5 | -------------------------------------------------------------------------------- /bin/lint-backend.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Lint checks 3 | set -euxo pipefail 4 | 5 | #################### 6 | ###### Python ###### 7 | #################### 8 | poetry run ruff check . 9 | poetry run ruff format --check . 10 | poetry run pyright 11 | poetry run vulture . 12 | if [ "$(uname)" = "Darwin" ]; then 13 | # Radon is only of interest to development 14 | poetry run radon mi --min B . 15 | poetry run radon cc --min C . 16 | fi 17 | poetry run djlint codex/templates --profile=django --lint 18 | 19 | ############################################ 20 | ##### Javascript, JSON, Markdown, YAML ##### 21 | ############################################ 22 | npm run lint 23 | 24 | ################################ 25 | ###### Docker, Shell, Etc ###### 26 | ################################ 27 | if [ "$(uname)" = "Darwin" ]; then 28 | # Hadolint & shfmt are difficult to install on linux 29 | # shellcheck disable=2035 30 | hadolint ./docker/*Dockerfile 31 | shellharden --check ./**/*.sh .env.platforms 32 | # subdirs aren't copied into docker builder 33 | # .env files aren't copied into docker 34 | shellcheck --external-sources ./**/*.sh .env.platforms 35 | circleci config check .circleci/config.yml 36 | fi 37 | ./bin/roman.sh -i .gitignore . 38 | poetry run codespell . 39 | -------------------------------------------------------------------------------- /bin/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | 4 | import os 5 | import sys 6 | 7 | 8 | def main(): 9 | """Run the server.""" 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "codex.settings.settings") 11 | try: 12 | from django.core.management import execute_from_command_line 13 | except ImportError as exc: 14 | reason = ( 15 | "Couldn't import Django. Are you sure it's installed and " 16 | "available on your PYTHONPATH environment variable? Did you " 17 | "forget to activate a virtual environment?" 18 | ) 19 | raise ImportError(reason) from exc 20 | execute_from_command_line(sys.argv) 21 | 22 | 23 | if __name__ == "__main__": 24 | main() 25 | -------------------------------------------------------------------------------- /bin/pm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Convenience script for running django manage tasks under poetry 3 | export PYTHONPATH=. 4 | poetry run python3 bin/manage.py "$@" 5 | -------------------------------------------------------------------------------- /bin/prettier-nginx.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run prettier on nginx files because overrides doesn't work yet. 3 | set -euxo pipefail 4 | CONFIG_DIR=docker/nginx/http.d/codex 5 | if [ -d "$CONFIG_DIR" ]; then 6 | prettier --parser nginx "$CONFIG_DIR/*.conf" "$@" 7 | fi 8 | -------------------------------------------------------------------------------- /bin/pypi-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Publish codex to pypi 3 | set -euo pipefail 4 | pip3 install --upgrade pip 5 | pip3 install --requirement builder-requirements.txt 6 | poetry publish -u "$PYPI_USER" -p "$PYPI_PASS" 7 | -------------------------------------------------------------------------------- /bin/roman.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Find all shell scripts without a first line comment. 3 | # Created due to working with @defunctzombie 4 | set -uo pipefail 5 | 6 | # set options 7 | if [ "$1" = "-i" ]; then 8 | shift 9 | ignorefile=$1 10 | shift 11 | fi 12 | 13 | # check for paths 14 | if [ "$1" = "" ]; then 15 | echo "Usage: $0 [options] [path...]" 16 | echo "Options:" 17 | echo -e "\t-i " 18 | exit 1 19 | fi 20 | 21 | if command -v ggrep &> /dev/null; then 22 | GREP_CMD=$(command -v ggrep) 23 | else 24 | GREP_CMD=$(command -v grep) 25 | fi 26 | # get files 27 | fns=$(find "$@" -type f -name "*.sh") 28 | if [ "$ignorefile" ]; then 29 | fns=$(echo "$fns" | "$GREP_CMD" -vf "$ignorefile") 30 | fi 31 | 32 | # find nonconforming files 33 | good=1 34 | while read -ra fn; do 35 | # sc doesn't understand that fn isn't an array 36 | # shellcheck disable=2128 37 | bad=$(sed -n '2 p' < "$fn" | "$GREP_CMD" -v '^# +*') 38 | if [ "$bad" ]; then 39 | # shellcheck disable=2128 40 | echo "🔪 $fn" 41 | good=0 42 | fi 43 | done < <(echo "$fns") 44 | 45 | if [ "$good" = 0 ]; then 46 | exit 1 47 | fi 48 | echo 👍 49 | -------------------------------------------------------------------------------- /bin/sortignore.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Sort all ignore files in place and remove duplicates 3 | for f in .*ignore; do 4 | sort --mmap --unique --output="$f" "$f" 5 | done 6 | -------------------------------------------------------------------------------- /bin/test-backend.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run all codex tests 3 | set -euxo pipefail 4 | 5 | export PYTHONPATH=. 6 | BUILD=1 poetry run pytest 7 | # pytest-cov leaves .coverage.$HOST.$PID.$RAND files around while coverage itself doesn't 8 | poetry run coverage erase || true 9 | -------------------------------------------------------------------------------- /bin/update-builder-requirement.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Update the builder-requirements.txt with installed versions. 3 | set -euo pipefail 4 | version=$(poetry --version) 5 | version=${version#"Poetry (version "} 6 | version=${version%?} 7 | echo "poetry>=$version" > builder-requirements.txt 8 | -------------------------------------------------------------------------------- /bin/update-deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Update python and npm dependencies 3 | set -euo pipefail 4 | poetry update 5 | poetry show --outdated 6 | npm update 7 | bash -c "cd frontend && npm update" 8 | npm outdated 9 | bash -c "cd frontend && npm outdated" 10 | -------------------------------------------------------------------------------- /bin/vendor-diff-package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Find the diffs for two vendored packages. 3 | # vendor the original package into codex/_vendor_orig before comparing edits 4 | # Would be slicker if this automated the creation and destruction of _vendor-orig in /tmp 5 | set -euo pipefail 6 | PKG=$1 7 | MODULE=$2 8 | VENDOR_TARGET=/tmp/_vendor_orig 9 | rm -rf "$VENDOR_TARGET" 10 | mkdir -p "$VENDOR_TARGET" 11 | 12 | cd cache 13 | 14 | # vendorize the original in a tmp dir 15 | cat << EOT > vendorize.toml 16 | target = "$VENDOR_TARGET" 17 | packages = [ "$PKG" ] 18 | EOT 19 | poetry run python-vendorize 20 | 21 | # compare 22 | DIFF_FN="../codex/_vendor/$PKG.diff" 23 | echo "# Non automated/import patches to $PKG" > "$DIFF_FN" 24 | diff --minimal --recursive --suppress-common-lines \ 25 | -x "*~" \ 26 | -x "*.pyc" \ 27 | -x "*__pycache__*" \ 28 | "$VENDOR_TARGET/$MODULE" \ 29 | "../codex/_vendor/$MODULE" \ 30 | | rg -v "Binary|Only" >> "$DIFF_FN" 31 | 32 | # cleanup 33 | rm -rf "$VENDOR_TARGET" 34 | rm -f vendorize.toml 35 | -------------------------------------------------------------------------------- /bin/vendor-patch-imports.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Replace relative imports with direct vendor imports 3 | set -euo pipefail 4 | MODULE=$1 5 | MODULE_DIR="codex/_vendor/$MODULE" 6 | find "$MODULE_DIR" -name "*.py" -print0 | xargs -0 sed -ri '' "s/from \.+$MODULE/from codex._vendor.$MODULE/g" 7 | -------------------------------------------------------------------------------- /bin/venv-upgrade.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Upgrade python venv 3 | set -euxo pipefail 4 | 5 | OLD_VERSION=$1 6 | NEW_VERSION=$2 7 | PYTHON_OLD="python$OLD_VERSION" 8 | PYTHON_CMD="python$NEW_VERSION" 9 | "$PYTHON_CMD" -m venv --upgrade .venv 10 | rm -rf ".venv/lib/$PYTHON_OLD" 11 | rm -f ".venv/bin/$PYTHON_OLD" 12 | cd .venv/bin 13 | ln -sf "$PYTHON_CMD" python3 14 | ln -sf python3 python 15 | PYOLD="py$OLD_VERSION" 16 | PYNEW="py$NEW_VERSION" 17 | find . -maxdepth 1 -type f -name 'activate*' -print0 | xargs -0 sed -i '' -e "s/$PYOLD/$PYNEW/g" 18 | -------------------------------------------------------------------------------- /bin/version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Get version or set version in Frontend & API. 3 | set -euo pipefail 4 | VERSION="${1:-}" 5 | if [ "$VERSION" = "" ]; then 6 | poetry version | awk '{print $2};' 7 | else 8 | poetry version "$VERSION" 9 | cd frontend 10 | npm version --allow-same-version "$VERSION" 11 | fi 12 | -------------------------------------------------------------------------------- /builder-requirements.txt: -------------------------------------------------------------------------------- 1 | poetry>=2.0.0 2 | -------------------------------------------------------------------------------- /codex/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialize Django.""" 2 | 3 | from os import environ 4 | 5 | from django import setup 6 | 7 | from codex.signals.django_signals import connect_signals 8 | 9 | # This all happens before anything else to make django safe to use. 10 | environ.setdefault("DJANGO_SETTINGS_MODULE", "codex.settings.settings") 11 | setup() 12 | connect_signals() 13 | -------------------------------------------------------------------------------- /codex/applications/__init__.py: -------------------------------------------------------------------------------- 1 | """ASGI Applications.""" 2 | -------------------------------------------------------------------------------- /codex/applications/websocket.py: -------------------------------------------------------------------------------- 1 | """Channels Websocket Application.""" 2 | 3 | from channels.auth import AuthMiddlewareStack 4 | from channels.routing import URLRouter 5 | from channels.security.websocket import AllowedHostsOriginValidator 6 | from django.urls import path 7 | 8 | from codex.websockets.consumers import NotifierConsumer 9 | 10 | WEBSOCKET_APPLICATION = AllowedHostsOriginValidator( 11 | AuthMiddlewareStack( 12 | URLRouter( 13 | [ 14 | path( 15 | "api/v3/ws", 16 | NotifierConsumer.as_asgi(), 17 | name="websocket", 18 | ), 19 | ] 20 | ) 21 | ) 22 | ) 23 | -------------------------------------------------------------------------------- /codex/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for codex project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``DJANGO_APPLICATION``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/dev/howto/deployment/asgi/ 8 | """ 9 | 10 | from channels.routing import ProtocolTypeRouter 11 | from django.core.asgi import get_asgi_application 12 | 13 | from codex.applications.lifespan import LifespanApplication 14 | from codex.applications.websocket import WEBSOCKET_APPLICATION 15 | from codex.logger.mp_queue import LOG_QUEUE 16 | from codex.websockets.aio_queue import BROADCAST_QUEUE 17 | 18 | application = ProtocolTypeRouter( 19 | { 20 | "http": get_asgi_application(), 21 | "websocket": WEBSOCKET_APPLICATION, 22 | "lifespan": LifespanApplication(LOG_QUEUE, BROADCAST_QUEUE), 23 | } 24 | ) 25 | -------------------------------------------------------------------------------- /codex/choices/__init__.py: -------------------------------------------------------------------------------- 1 | """Enums and Choices for models and Seralizers.""" 2 | -------------------------------------------------------------------------------- /codex/choices/notifications.py: -------------------------------------------------------------------------------- 1 | """Notification messages.""" 2 | 3 | from enum import Enum 4 | 5 | 6 | class Notifications(Enum): 7 | """Websocket Notifications.""" 8 | 9 | ADMIN_FLAGS = "ADMIN_FLAGS_CHANGED" 10 | BOOKMARK = "BOOKMARK_CHANGED" 11 | COVERS = "COVERS_CHANGED" 12 | FAILED_IMPORTS = "FAILED_IMPORTS" 13 | GROUPS = "GROUPS_CHANGED" 14 | LIBRARY = "LIBRARY_CHANGED" 15 | LIBRARIAN_STATUS = "LIBRARIAN_STATUS" 16 | USERS = "USERS_CHANGED" 17 | -------------------------------------------------------------------------------- /codex/choices/reader.py: -------------------------------------------------------------------------------- 1 | """Frontend Choices, Defaults and Messages.""" 2 | 3 | from types import MappingProxyType 4 | 5 | READER_CHOICES = MappingProxyType( 6 | { 7 | "fit_to": MappingProxyType( 8 | { 9 | "S": "Fit to Screen", 10 | "W": "Fit to Width", 11 | "H": "Fit to Height", 12 | "O": "Original Size", 13 | } 14 | ), 15 | "reading_direction": MappingProxyType( 16 | { 17 | "ltr": "Left to Right", 18 | "rtl": "Right to Left", 19 | "ttb": "Top to Bottom", 20 | "btt": "Bottom to Top", 21 | } 22 | ), 23 | } 24 | ) 25 | READER_DEFAULTS = MappingProxyType( 26 | { 27 | "finish_on_last_page": True, 28 | "fit_to": "S", 29 | "reading_direction": "ltr", 30 | "read_rtl_in_reverse": False, 31 | "two_pages": False, 32 | "page_transition": True, 33 | } 34 | ) 35 | -------------------------------------------------------------------------------- /codex/librarian/README.md: -------------------------------------------------------------------------------- 1 | # librarian 2 | 3 | Most non-ui tasks are run by the background librariand process. librariand 4 | spawns threads as well. 5 | -------------------------------------------------------------------------------- /codex/librarian/__init__.py: -------------------------------------------------------------------------------- 1 | """The librarian is a collection of daemons that run background tasks.""" 2 | -------------------------------------------------------------------------------- /codex/librarian/bookmark/__init__.py: -------------------------------------------------------------------------------- 1 | """Bookmark Thread.""" 2 | -------------------------------------------------------------------------------- /codex/librarian/bookmark/tasks.py: -------------------------------------------------------------------------------- 1 | """Bookmark Tasks.""" 2 | 3 | from collections.abc import Mapping 4 | from dataclasses import dataclass 5 | from typing import Any 6 | 7 | 8 | @dataclass 9 | class BookmarkTask: 10 | """Bookmark Base Class.""" 11 | 12 | 13 | @dataclass 14 | class BookmarkUpdateTask(BookmarkTask): 15 | """Bookmark a page.""" 16 | 17 | auth_filter: Mapping[str, int | str | None] 18 | comic_pks: tuple[int] 19 | updates: Mapping[str, Any] 20 | 21 | 22 | @dataclass 23 | class UserActiveTask(BookmarkTask): 24 | """Update the user's last active status.""" 25 | 26 | pk: int 27 | -------------------------------------------------------------------------------- /codex/librarian/bookmark/user_active.py: -------------------------------------------------------------------------------- 1 | """Mixin for recording user active entry.""" 2 | 3 | from datetime import timedelta 4 | 5 | from django.contrib.auth.models import User 6 | from django.utils import timezone as django_timezone 7 | 8 | from codex.models.admin import UserActive 9 | from codex.views.const import EPOCH_START 10 | 11 | 12 | class UserActiveMixin: 13 | """Record user active entry.""" 14 | 15 | # only hit the disk to record user activity every so often 16 | USER_ACTIVE_RESOLUTION = timedelta(hours=1) 17 | 18 | def __init__(self, *args, **kwargs): 19 | """Init the last recorded dict.""" 20 | super().__init__(*args, **kwargs) 21 | self._user_active_recorded = {} 22 | 23 | def update_user_active(self, pk: int, log): 24 | """Update user active.""" 25 | # Offline because profile gets hit rapidly in succession. 26 | try: 27 | last_recorded = self._user_active_recorded.get(pk, EPOCH_START) 28 | now = django_timezone.now() 29 | if now - last_recorded > self.USER_ACTIVE_RESOLUTION: 30 | user = User.objects.get(pk=pk) 31 | UserActive.objects.update_or_create(user=user) 32 | self._user_active_recorded[pk] = now 33 | except User.DoesNotExist: 34 | pass 35 | except Exception as exc: 36 | reason = f"Update user activity {exc}" 37 | log.warning(reason) 38 | -------------------------------------------------------------------------------- /codex/librarian/covers/__init__.py: -------------------------------------------------------------------------------- 1 | """Comic cover operations.""" 2 | -------------------------------------------------------------------------------- /codex/librarian/covers/coverd.py: -------------------------------------------------------------------------------- 1 | """Functions for dealing with comic cover thumbnails.""" 2 | 3 | from codex.librarian.covers.purge import CoverPurgeThread 4 | from codex.librarian.covers.tasks import ( 5 | CoverCreateAllTask, 6 | CoverRemoveAllTask, 7 | CoverRemoveOrphansTask, 8 | CoverRemoveTask, 9 | CoverSaveToCache, 10 | ) 11 | 12 | 13 | class CoverThread(CoverPurgeThread): 14 | """Create comic covers in it's own thread.""" 15 | 16 | def process_item(self, item): 17 | """Run the task method.""" 18 | task = item 19 | if isinstance(task, CoverSaveToCache): 20 | self.save_cover_to_cache(task.cover_path, task.data) 21 | elif isinstance(task, CoverRemoveAllTask): 22 | self.purge_all_comic_covers(self.librarian_queue) 23 | elif isinstance(task, CoverRemoveTask): 24 | self.purge_comic_covers(task.pks, task.custom) 25 | elif isinstance(task, CoverRemoveOrphansTask): 26 | self.cleanup_orphan_covers() 27 | elif isinstance(task, CoverCreateAllTask): 28 | self.create_all_covers() 29 | else: 30 | self.log.error(f"Bad task sent to {self.__class__.__name__}: {task}") 31 | -------------------------------------------------------------------------------- /codex/librarian/covers/path.py: -------------------------------------------------------------------------------- 1 | """Cover Path functions.""" 2 | 3 | from pathlib import Path 4 | 5 | from fnvhash import fnv1a_32 6 | 7 | from codex.settings.settings import ROOT_CACHE_PATH 8 | 9 | 10 | class CoverPathMixin: 11 | """Path methods for covers.""" 12 | 13 | COVERS_ROOT = ROOT_CACHE_PATH / "covers" 14 | CUSTOM_COVERS_ROOT = ROOT_CACHE_PATH / "custom-covers" 15 | _HEX_FILL = 8 16 | _PATH_STEP = 2 17 | _ZFILL = 12 18 | 19 | @classmethod 20 | def _hex_path(cls, pk): 21 | """Translate an integer into an efficient filesystem path.""" 22 | fnv = fnv1a_32(bytes(str(pk).zfill(cls._ZFILL), "utf-8")) 23 | hex_str = format(fnv, f"0{cls._ZFILL}x") 24 | parts = [ 25 | hex_str[i : i + cls._PATH_STEP] 26 | for i in range(0, len(hex_str), cls._PATH_STEP) 27 | ] 28 | return Path("/".join(parts)) 29 | 30 | @classmethod 31 | def get_cover_path(cls, pk, custom): 32 | """Get cover path for comic pk.""" 33 | cover_path = cls._hex_path(pk) 34 | root = cls.CUSTOM_COVERS_ROOT if custom else cls.COVERS_ROOT 35 | return root / cover_path.with_suffix(".webp") 36 | 37 | @classmethod 38 | def get_cover_paths(cls, pks, custom): 39 | """Get cover paths for many comic pks.""" 40 | cover_paths = set() 41 | for pk in pks: 42 | cover_path = cls.get_cover_path(pk, custom) 43 | cover_paths.add(cover_path) 44 | return cover_paths 45 | -------------------------------------------------------------------------------- /codex/librarian/covers/status.py: -------------------------------------------------------------------------------- 1 | """Cover status types.""" 2 | 3 | from django.db.models import TextChoices 4 | 5 | 6 | class CoverStatusTypes(TextChoices): 7 | """Cover Types.""" 8 | 9 | CREATE_COVERS = "CCC" 10 | PURGE_COVERS = "CCD" 11 | FIND_ORPHAN = "CFO" 12 | -------------------------------------------------------------------------------- /codex/librarian/covers/tasks.py: -------------------------------------------------------------------------------- 1 | """Covers Tasks.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class CoverTask: 8 | """Handle with the CoverThread.""" 9 | 10 | 11 | @dataclass 12 | class CoverRemoveAllTask(CoverTask): 13 | """Remove all comic covers.""" 14 | 15 | 16 | @dataclass 17 | class CoverRemoveOrphansTask(CoverTask): 18 | """Clean up covers from missing comics.""" 19 | 20 | 21 | @dataclass 22 | class CoverRemoveTask(CoverTask): 23 | """Purge a set of comic covers.""" 24 | 25 | pks: frozenset 26 | custom: bool 27 | 28 | 29 | @dataclass 30 | class CoverSaveToCache(CoverTask): 31 | """Write cover to disk.""" 32 | 33 | cover_path: str 34 | data: bytes 35 | 36 | 37 | @dataclass 38 | class CoverCreateAllTask(CoverTask): 39 | """A create all comic covers.""" 40 | -------------------------------------------------------------------------------- /codex/librarian/cron/__init__.py: -------------------------------------------------------------------------------- 1 | """Crond thread.""" 2 | -------------------------------------------------------------------------------- /codex/librarian/delayed_taskd.py: -------------------------------------------------------------------------------- 1 | """Delay tasks.""" 2 | 3 | from queue import PriorityQueue 4 | from time import sleep, time 5 | 6 | from codex.threads import QueuedThread 7 | 8 | 9 | class DelayedTasksThread(QueuedThread): 10 | """Wait for the something before running tasks.""" 11 | 12 | def __init__(self, *args, **kwargs): 13 | """Use a priority queue.""" 14 | super().__init__(*args, queue=PriorityQueue(), **kwargs) 15 | 16 | def process_item(self, item): 17 | """Sleep and then put tasks on the queue.""" 18 | delay = max(0.0, item.until - time()) 19 | sleep(delay) 20 | for task in item.tasks: 21 | self.librarian_queue.put(task) 22 | -------------------------------------------------------------------------------- /codex/librarian/importer/__init__.py: -------------------------------------------------------------------------------- 1 | """Bulk import module.""" 2 | -------------------------------------------------------------------------------- /codex/librarian/importer/status.py: -------------------------------------------------------------------------------- 1 | """Librarian Status for imports.""" 2 | 3 | from django.db.models import TextChoices 4 | 5 | 6 | class ImportStatusTypes(TextChoices): 7 | """Keys for Import tasks.""" 8 | 9 | DIRS_MOVED = "IDM" 10 | FILES_MOVED = "IFM" 11 | AGGREGATE_TAGS = "ITR" 12 | QUERY_MISSING_FKS = "ITQ" 13 | CREATE_FKS = "ITC" 14 | DIRS_MODIFIED = "IDU" 15 | FILES_MODIFIED = "IFU" 16 | FILES_CREATED = "IFC" 17 | QUERY_M2M_FIELDS = "IMQ" 18 | LINK_M2M_FIELDS = "IMC" 19 | DIRS_DELETED = "IDD" 20 | FILES_DELETED = "IFD" 21 | FAILED_IMPORTS = "IFI" 22 | QUERY_MISSING_COVERS = "ICQ" 23 | COVERS_MOVED = "ICM" 24 | COVERS_MODIFIED = "ICU" 25 | COVERS_CREATED = "ICC" 26 | COVERS_DELETED = "ICD" 27 | COVERS_LINK = "ICL" 28 | GROUP_UPDATE = "IGU" 29 | ADOPT_FOLDERS = "IAF" 30 | -------------------------------------------------------------------------------- /codex/librarian/janitor/__init__.py: -------------------------------------------------------------------------------- 1 | """Maintenance tasks.""" 2 | -------------------------------------------------------------------------------- /codex/librarian/janitor/failed_imports.py: -------------------------------------------------------------------------------- 1 | """Force update events for failed imports.""" 2 | 3 | from watchdog.events import FileModifiedEvent 4 | 5 | from codex.librarian.watchdog.tasks import WatchdogEventTask 6 | from codex.models import FailedImport, Library 7 | from codex.worker_base import WorkerBaseMixin 8 | 9 | 10 | class UpdateFailedImportsMixin(WorkerBaseMixin): 11 | """Methods for updating failed imports.""" 12 | 13 | def _force_update_failed_imports(self, library_id): 14 | """Force update events for failed imports in a library.""" 15 | failed_import_paths = FailedImport.objects.filter( 16 | library=library_id 17 | ).values_list("path", flat=True) 18 | for path in failed_import_paths: 19 | event = FileModifiedEvent(path) 20 | task = WatchdogEventTask(library_id, event) 21 | self.librarian_queue.put(task) 22 | 23 | def force_update_all_failed_imports(self): 24 | """Force update events for failed imports in every library.""" 25 | pks = Library.objects.filter(covers_only=False).values_list("pk", flat=True) 26 | for pk in pks: 27 | self._force_update_failed_imports(pk) 28 | -------------------------------------------------------------------------------- /codex/librarian/janitor/scheduled_time.py: -------------------------------------------------------------------------------- 1 | """Janitor Scheduled time.""" 2 | 3 | from datetime import datetime, time, timedelta 4 | 5 | from django.utils import timezone as django_timezone 6 | 7 | 8 | def get_janitor_time(_log): 9 | """Get midnight relative to now.""" 10 | tomorrow = django_timezone.now() + timedelta(days=1) 11 | tomorrow = tomorrow.astimezone() 12 | return datetime.combine(tomorrow, time.min).astimezone() 13 | -------------------------------------------------------------------------------- /codex/librarian/janitor/status.py: -------------------------------------------------------------------------------- 1 | """Janitor Status Types.""" 2 | 3 | from django.db.models import TextChoices 4 | 5 | 6 | class JanitorStatusTypes(TextChoices): 7 | """Janitor Status Types.""" 8 | 9 | CLEANUP_FK = "JTD" 10 | CODEX_LATEST_VERSION = "JLV" 11 | CODEX_UPDATE = "JCU" 12 | CODEX_RESTART = "JCR" 13 | CODEX_STOP = "JCS" 14 | DB_OPTIMIZE = "JDO" 15 | DB_BACKUP = "JDB" 16 | CLEANUP_SESSIONS = "JSD" 17 | CLEANUP_COVERS = "JCD" 18 | CLEANUP_BOOKMARKS = "JCB" 19 | INTEGRITY_FK = "JIF" 20 | INTEGRITY_CHECK = "JIC" 21 | FTS_INTEGRITY_CHECK = "JFC" 22 | FTS_REBUILD = "JFR" 23 | -------------------------------------------------------------------------------- /codex/librarian/mp_queue.py: -------------------------------------------------------------------------------- 1 | """Library Queue.""" 2 | 3 | # This file cannot be named queue or it causes weird type checker errors 4 | from multiprocessing import Queue 5 | 6 | LIBRARIAN_QUEUE = Queue() 7 | -------------------------------------------------------------------------------- /codex/librarian/notifier/__init__.py: -------------------------------------------------------------------------------- 1 | """Notifier Thread.""" 2 | -------------------------------------------------------------------------------- /codex/librarian/notifier/tasks.py: -------------------------------------------------------------------------------- 1 | """Notifier Tasks.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | from codex.choices.notifications import Notifications 6 | from codex.websockets.consumers import ChannelGroups 7 | 8 | 9 | @dataclass 10 | class NotifierTask: 11 | """Handle with the Notifier.""" 12 | 13 | text: str 14 | group: str 15 | 16 | 17 | ADMIN_FLAGS_CHANGED_TASK = NotifierTask( 18 | Notifications.ADMIN_FLAGS.value, ChannelGroups.ALL.name 19 | ) 20 | COVERS_CHANGED_TASK = NotifierTask(Notifications.COVERS.value, ChannelGroups.ALL.name) 21 | FAILED_IMPORTS_CHANGED_TASK = NotifierTask( 22 | Notifications.FAILED_IMPORTS.value, ChannelGroups.ADMIN.name 23 | ) 24 | GROUPS_CHANGED_TASK = NotifierTask(Notifications.GROUPS.value, ChannelGroups.ALL.name) 25 | LIBRARIAN_STATUS_TASK = NotifierTask( 26 | Notifications.LIBRARIAN_STATUS.value, ChannelGroups.ADMIN.name 27 | ) 28 | LIBRARY_CHANGED_TASK = NotifierTask(Notifications.LIBRARY.value, ChannelGroups.ALL.name) 29 | USERS_CHANGED_TASK = NotifierTask(Notifications.USERS.value, ChannelGroups.ALL.name) 30 | ADMIN_USERS_CHANGED_TASK = NotifierTask( 31 | Notifications.USERS.value, ChannelGroups.ADMIN.name 32 | ) 33 | -------------------------------------------------------------------------------- /codex/librarian/search/__init__.py: -------------------------------------------------------------------------------- 1 | """Search Indexer Thread.""" 2 | -------------------------------------------------------------------------------- /codex/librarian/search/searchd.py: -------------------------------------------------------------------------------- 1 | """Haystack Search index updater.""" 2 | 3 | from codex.librarian.search.tasks import ( 4 | SearchIndexClearTask, 5 | SearchIndexOptimizeTask, 6 | SearchIndexRemoveStaleTask, 7 | SearchIndexUpdateTask, 8 | ) 9 | from codex.librarian.search.update import FTSUpdateMixin 10 | 11 | 12 | class SearchIndexerThread(FTSUpdateMixin): 13 | """A worker to handle search index update tasks.""" 14 | 15 | def process_item(self, item): 16 | """Run the updater.""" 17 | task = item 18 | match task: 19 | case SearchIndexUpdateTask(): 20 | self.update_search_index(rebuild=task.rebuild) 21 | case SearchIndexRemoveStaleTask(): 22 | self.remove_stale_records() 23 | case SearchIndexOptimizeTask(): 24 | self.optimize(task.janitor) 25 | case SearchIndexClearTask(): 26 | self.clear_search_index() 27 | case _: 28 | self.log.warning(f"Bad task sent to search index thread: {task}") 29 | -------------------------------------------------------------------------------- /codex/librarian/search/status.py: -------------------------------------------------------------------------------- 1 | """Search Index Status Types.""" 2 | 3 | from django.db.models import TextChoices 4 | 5 | 6 | class SearchIndexStatusTypes(TextChoices): 7 | """Search Index Status Types.""" 8 | 9 | SEARCH_INDEX_CLEAR = "SIX" 10 | SEARCH_INDEX_UPDATE = "SIU" 11 | SEARCH_INDEX_CREATE = "SIC" 12 | SEARCH_INDEX_REMOVE = "SID" 13 | SEARCH_INDEX_OPTIMIZE = "SIO" 14 | -------------------------------------------------------------------------------- /codex/librarian/search/tasks.py: -------------------------------------------------------------------------------- 1 | """Libarian Tasks for searchd.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class SearchIndexerTask: 8 | """Tasks for the search indexer.""" 9 | 10 | 11 | @dataclass 12 | class SearchIndexUpdateTask(SearchIndexerTask): 13 | """Update the search index.""" 14 | 15 | rebuild: bool = False 16 | 17 | 18 | @dataclass 19 | class SearchIndexOptimizeTask(SearchIndexerTask): 20 | """Optimize search index.""" 21 | 22 | janitor: bool = False 23 | 24 | 25 | @dataclass 26 | class SearchIndexRemoveStaleTask(SearchIndexerTask): 27 | """Remove stale records.""" 28 | 29 | 30 | @dataclass 31 | class SearchIndexAbortTask(SearchIndexerTask): 32 | """Abort current search index.""" 33 | 34 | 35 | @dataclass 36 | class SearchIndexClearTask(SearchIndexerTask): 37 | """Clear current search index.""" 38 | -------------------------------------------------------------------------------- /codex/librarian/tasks.py: -------------------------------------------------------------------------------- 1 | """Librarian Tasks.""" 2 | 3 | from dataclasses import dataclass, field 4 | 5 | 6 | @dataclass(order=True) 7 | class DelayedTasks: 8 | """A list of tasks to start on a delay.""" 9 | 10 | until: float 11 | tasks: tuple = field(compare=False) 12 | 13 | 14 | class LibrarianShutdownTask: 15 | """Signal task.""" 16 | 17 | 18 | class WakeCronTask: 19 | """Signal task.""" 20 | -------------------------------------------------------------------------------- /codex/librarian/telemeter/__init__.py: -------------------------------------------------------------------------------- 1 | """Telemeter.""" 2 | -------------------------------------------------------------------------------- /codex/librarian/telemeter/tasks.py: -------------------------------------------------------------------------------- 1 | """Telemter tasks.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class TelemeterTask: 8 | """Send telemetry.""" 9 | -------------------------------------------------------------------------------- /codex/librarian/watchdog/README.md: -------------------------------------------------------------------------------- 1 | # Watchdog 2 | 3 | - Extends the Watchdog API with custom Observers, DirSnapshot, Emitter. 4 | - The Observers decide to watch different paths by querying the Library objects. 5 | - A CodexDatabaseSnapshot exists to compare the codex database against a 6 | standard DirSnapshot 7 | - Events batch into large tasks for the Updater by the EventBatcher. The Emitter 8 | could do this more efficiently, but I'm also using the standard 9 | FileSystemObserver that spits them into a queue. 10 | 11 | [Watchdog project page](https://github.com/gorakhargosh/watchdog) 12 | -------------------------------------------------------------------------------- /codex/librarian/watchdog/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom Watchdog Observers for Codex Libraries.""" 2 | -------------------------------------------------------------------------------- /codex/librarian/watchdog/status.py: -------------------------------------------------------------------------------- 1 | """Watchdog Status Types.""" 2 | 3 | from django.db.models import TextChoices 4 | 5 | 6 | class WatchdogStatusTypes(TextChoices): 7 | """Watchdog Status Types.""" 8 | 9 | POLL = "WPO" 10 | -------------------------------------------------------------------------------- /codex/librarian/watchdog/tasks.py: -------------------------------------------------------------------------------- 1 | """Watchdog Tasks.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | from watchdog.events import FileSystemEvent 6 | 7 | 8 | @dataclass 9 | class WatchdogTask: 10 | """Watchdog tasks.""" 11 | 12 | 13 | @dataclass 14 | class WatchdogPollLibrariesTask(WatchdogTask): 15 | """Tell observer to poll these libraries now.""" 16 | 17 | library_ids: frozenset 18 | force: bool 19 | 20 | 21 | @dataclass 22 | class WatchdogEventTask(WatchdogTask): 23 | """Task for filesystem events.""" 24 | 25 | library_id: int 26 | event: FileSystemEvent 27 | 28 | 29 | @dataclass 30 | class WatchdogSyncTask(WatchdogTask): 31 | """Sync watches with libraries.""" 32 | -------------------------------------------------------------------------------- /codex/logger/__init__.py: -------------------------------------------------------------------------------- 1 | """Central logger thread.""" 2 | -------------------------------------------------------------------------------- /codex/logger/formatter.py: -------------------------------------------------------------------------------- 1 | """Logging Color Formatter.""" 2 | 3 | import logging 4 | from types import MappingProxyType 5 | 6 | from colors import color 7 | 8 | 9 | class ColorFormatter(logging.Formatter): 10 | """Logging Formatter to add colors and count warning / errors.""" 11 | 12 | FORMAT_COLORS = MappingProxyType( 13 | { 14 | "CRITICAL": {"fg": "red", "style": "bold"}, 15 | "ERROR": {"fg": "red"}, 16 | "WARNING": {"fg": "yellow"}, 17 | "INFO": {"fg": "green"}, 18 | "DEBUG": {"fg": "black", "style": "bold"}, 19 | "NOTSET": {"fg": "blue"}, 20 | } 21 | ) 22 | 23 | def __init__(self, fmt, **kwargs): 24 | """Set up the FORMATS dict.""" 25 | super().__init__(**kwargs) 26 | self.formatters = {} 27 | for level_name, args in self.FORMAT_COLORS.items(): 28 | levelno = getattr(logging, level_name) 29 | template = color(fmt, **args) 30 | formatter = logging.Formatter(fmt=template, **kwargs) 31 | self.formatters[levelno] = formatter 32 | 33 | def format(self, record): 34 | """Format each log message.""" 35 | formatter = self.formatters[record.levelno] 36 | return formatter.format(record) 37 | -------------------------------------------------------------------------------- /codex/logger/mp_queue.py: -------------------------------------------------------------------------------- 1 | """Central logging queue.""" 2 | 3 | from multiprocessing import Queue 4 | 5 | LOG_QUEUE = Queue() 6 | -------------------------------------------------------------------------------- /codex/logger_base.py: -------------------------------------------------------------------------------- 1 | """Class to run librarian tasks inline without a thread.""" 2 | 3 | from codex.logger.logger import get_logger 4 | 5 | 6 | class LoggerBaseMixin: 7 | """A class that holds it's own logger.""" 8 | 9 | def init_logger(self, log_queue): 10 | """Set up logger.""" 11 | self.log_queue = log_queue 12 | self.log = get_logger(self.__class__.__name__, log_queue) 13 | -------------------------------------------------------------------------------- /codex/migrations/0002_auto_20200826_0622.py: -------------------------------------------------------------------------------- 1 | """Generated by Django 3.1 on 2020-08-26 06:22.""" 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """Change libraries verbose name.""" 8 | 9 | dependencies = [ 10 | ("codex", "0001_init"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelOptions( 15 | name="library", 16 | options={"verbose_name_plural": "libraries"}, 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /codex/migrations/0003_auto_20200831_2033.py: -------------------------------------------------------------------------------- 1 | """Generated by Django 3.1 on 2020-08-31 20:33.""" 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | """Credit roles can be none.""" 9 | 10 | dependencies = [ 11 | ("codex", "0002_auto_20200826_0622"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="credit", 17 | name="role", 18 | field=models.ForeignKey( 19 | null=True, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | to="codex.creditrole", 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /codex/migrations/0004_failedimport.py: -------------------------------------------------------------------------------- 1 | """Generated by Django 3.1.1 on 2020-09-14 22:15.""" 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | """Keep track of failed imports.""" 9 | 10 | dependencies = [ 11 | ("codex", "0003_auto_20200831_2033"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="FailedImport", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("created_at", models.DateTimeField(auto_now_add=True)), 28 | ("updated_at", models.DateTimeField(auto_now=True)), 29 | ("path", models.CharField(db_index=True, max_length=128)), 30 | ("reason", models.CharField(max_length=64)), 31 | ( 32 | "library", 33 | models.ForeignKey( 34 | on_delete=django.db.models.deletion.CASCADE, to="codex.library" 35 | ), 36 | ), 37 | ], 38 | options={ 39 | "unique_together": {("library", "path")}, 40 | }, 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /codex/migrations/0005_auto_20200918_0146.py: -------------------------------------------------------------------------------- 1 | """Generated by Django 3.1.1 on 2020-09-18 01:46.""" 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """Update verbose names.""" 8 | 9 | dependencies = [ 10 | ("codex", "0004_failedimport"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelOptions( 15 | name="comic", 16 | options={"verbose_name": "Issue"}, 17 | ), 18 | migrations.AlterModelOptions( 19 | name="series", 20 | options={"verbose_name_plural": "Series"}, 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /codex/migrations/0009_alter_comic_parent_folder.py: -------------------------------------------------------------------------------- 1 | """Generated by Django 4.0 on 2021-12-19 17:47.""" 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | """Override related_in becauese interferes with comic.folders.""" 9 | 10 | dependencies = [ 11 | ("codex", "0008_alter_comic_created_at_alter_comic_format_and_more"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="comic", 17 | name="parent_folder", 18 | field=models.ForeignKey( 19 | null=True, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | related_name="comic_in", 22 | to="codex.folder", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /codex/migrations/0012_rename_description_comic_comments.py: -------------------------------------------------------------------------------- 1 | """Generated by Django 4.0.2 on 2022-02-24 20:58.""" 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """Rename comic description to comic comments.""" 8 | 9 | dependencies = [ 10 | ("codex", "0011_library_groups_and_metadata_changes"), 11 | ] 12 | 13 | operations = [ 14 | migrations.RenameField( 15 | model_name="comic", 16 | old_name="description", 17 | new_name="comments", 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /codex/migrations/0015_link_comics_to_top_level_folders.py: -------------------------------------------------------------------------------- 1 | """Fix no parent folder comics.""" 2 | 3 | from pathlib import Path 4 | 5 | from django.db import migrations 6 | 7 | 8 | def fix_no_parent_folder_comics(apps, _schema_editor): 9 | """Add a parent folder to orphan comics.""" 10 | folder_model = apps.get_model("codex", "folder") 11 | top_folders = folder_model.objects.filter(parent_folder=None).only("path") 12 | comic_model = apps.get_model("codex", "comic") 13 | orphan_comics = comic_model.objects.filter(parent_folder=None).only( 14 | "parent_folder", "path" 15 | ) 16 | 17 | update_comics = [] 18 | if orphan_comics: 19 | print(f"\nfixing {len(orphan_comics)} orphan comics.") 20 | for comic in orphan_comics: 21 | for folder in top_folders: 22 | if Path(comic.path).is_relative_to(folder.path): 23 | comic.parent_folder = folder 24 | print(f"linking {comic.path} to {folder.path}") 25 | update_comics.append(comic) 26 | break 27 | 28 | count = comic_model.objects.bulk_update(update_comics, ["parent_folder"]) 29 | if count: 30 | print(f"updated {count} comics.") 31 | 32 | 33 | class Migration(migrations.Migration): 34 | """Fix top level comics.""" 35 | 36 | dependencies = [("codex", "0014_pdf_issue_suffix_remove_cover_image_sort_name")] 37 | 38 | operations = [ 39 | migrations.RunPython(fix_no_parent_folder_comics), 40 | ] 41 | -------------------------------------------------------------------------------- /codex/migrations/0019_delete_queuejob.py: -------------------------------------------------------------------------------- 1 | """Generated by Django 4.1.4 on 2022-12-07 20:25.""" 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """Delete fake model used in Django Admin.""" 8 | 9 | dependencies = [ 10 | ("codex", "0018_rename_userbookmark_bookmark"), 11 | ] 12 | 13 | operations = [ 14 | migrations.DeleteModel( 15 | name="QueueJob", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /codex/migrations/0021_bookmark_fit_to_choices_read_in_reverse.py: -------------------------------------------------------------------------------- 1 | """Generated by Django 4.1.7 on 2023-02-26 21:49.""" 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | def ensure_fit_to_has_valid_choices(apps, _schema_editor): 7 | """Ensure fit_to has valid choices before adding constraint.""" 8 | bookmark_model = apps.get_model("codex", "bookmark") 9 | choices = {"", "SCREEN", "WIDTH", "HEIGHT", "ORIG"} 10 | bookmark_model.objects.exclude(fit_to__in=choices).update(fit_to="") 11 | 12 | 13 | class Migration(migrations.Migration): 14 | """Add bookmark choices to database.""" 15 | 16 | dependencies = [ 17 | ("codex", "0020_remove_search_tables"), 18 | ] 19 | 20 | operations = [ 21 | migrations.RunPython(ensure_fit_to_has_valid_choices), 22 | migrations.AlterField( 23 | model_name="bookmark", 24 | name="fit_to", 25 | field=models.CharField( 26 | blank=True, 27 | choices=[ 28 | ("SCREEN", "Screen"), 29 | ("WIDTH", "Width"), 30 | ("HEIGHT", "Height"), 31 | ("ORIG", "Orig"), 32 | ], 33 | default="", 34 | max_length=6, 35 | ), 36 | ), 37 | migrations.AddField( 38 | model_name="bookmark", 39 | name="read_in_reverse", 40 | field=models.BooleanField(default=None, null=True), 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /codex/migrations/0024_comic_gtin_comic_story_arc_number.py: -------------------------------------------------------------------------------- 1 | """Generated by Django 4.2.1 on 2023-05-10 22:44.""" 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """Add fields.""" 8 | 9 | dependencies = [ 10 | ("codex", "0023_rename_credit_creator_and_more"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="comic", 16 | name="gtin", 17 | field=models.CharField(db_index=True, default="", max_length=32), 18 | ), 19 | migrations.AddField( 20 | model_name="comic", 21 | name="story_arc_number", 22 | field=models.PositiveSmallIntegerField(db_index=True, null=True), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /codex/migrations/0028_telemeter.py: -------------------------------------------------------------------------------- 1 | """Generated by Django 5.0.6 on 2024-07-02 21:01.""" 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """Migrate db.""" 8 | 9 | dependencies = [("codex", "0027_import_order_and_covers")] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="adminflag", 14 | name="key", 15 | field=models.CharField( 16 | choices=[ 17 | ("FV", "Folder View"), 18 | ("RG", "Registration"), 19 | ("NU", "Non Users"), 20 | ("AU", "Auto Update"), 21 | ("SO", "Search Index Optimize"), 22 | ("IM", "Import Metadata"), 23 | ("ST", "Send Telemetry"), 24 | ], 25 | db_index=True, 26 | max_length=2, 27 | ), 28 | ), 29 | migrations.AlterField( 30 | model_name="timestamp", 31 | name="key", 32 | field=models.CharField( 33 | choices=[ 34 | ("JA", "Janitor"), 35 | ("VR", "Codex Version"), 36 | ("SI", "Search Index UUID"), 37 | ("AP", "API Key"), 38 | ("TS", "Telemeter Sent"), 39 | ], 40 | db_index=True, 41 | max_length=2, 42 | ), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /codex/migrations/0032_alter_librarianstatus_preactive.py: -------------------------------------------------------------------------------- 1 | """Generated by Django 5.1.3 on 2024-11-14 21:50.""" 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """Migrate db.""" 8 | 9 | dependencies = [ 10 | ("codex", "0031_adminflag_banner"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="librarianstatus", 16 | name="preactive", 17 | field=models.DateTimeField(default=None, null=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /codex/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | """Database migrations.""" 2 | -------------------------------------------------------------------------------- /codex/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Codex Django models.""" 2 | 3 | from codex.models.admin import * 4 | from codex.models.bookmark import * 5 | from codex.models.comic import * 6 | from codex.models.groups import * 7 | from codex.models.library import * 8 | from codex.models.named import * 9 | from codex.models.paths import * 10 | -------------------------------------------------------------------------------- /codex/models/base.py: -------------------------------------------------------------------------------- 1 | """Base model.""" 2 | 3 | from django.db.models import DateTimeField, Model 4 | from django.db.models.base import ModelBase 5 | 6 | from codex.models.query import GroupByManager 7 | 8 | __all__ = ("BaseModel",) 9 | 10 | MAX_PATH_LEN = 4095 11 | MAX_NAME_LEN = 128 12 | MAX_FIELD_LEN = 32 13 | MAX_ISSUE_SUFFIX_LEN = 16 14 | 15 | 16 | def max_choices_len(choices): 17 | """Return the maximum possible size for a Choice's key.""" 18 | if not isinstance(choices, tuple): 19 | choices = choices.choices 20 | return max(len(choice[0]) for choice in choices) 21 | 22 | 23 | class BaseModel(Model): 24 | """A base model with universal fields.""" 25 | 26 | created_at = DateTimeField(auto_now_add=True) 27 | updated_at = DateTimeField(auto_now=True) 28 | objects = GroupByManager() 29 | 30 | class Meta(ModelBase): # type: ignore[reportIncompatibleVariableOverride] 31 | """Without this a real table is created and joined to.""" 32 | 33 | # Model.Meta is not inheritable. 34 | 35 | abstract = True 36 | get_latest_by = "updated_at" 37 | 38 | def presave(self): 39 | """Create values before save.""" 40 | -------------------------------------------------------------------------------- /codex/models/util.py: -------------------------------------------------------------------------------- 1 | """Utilities for models.""" 2 | 3 | _ARTICLES = frozenset( 4 | ("a", "an", "the") # en # noqa: RUF005 5 | + ("un", "unos", "unas", "el", "los", "la", "las") # es 6 | + ("un", "une", "le", "les", "la", "les", "l'") # fr 7 | + ("o", "a", "os") # pt 8 | # pt "as" conflicts with English 9 | + ("der", "dem", "des", "das") # de 10 | # de: "den & die conflict with English 11 | + ("il", "lo", "gli", "la", "le", "l'") # it 12 | # it: "i" conflicts with English 13 | + ("de", "het", "een") # nl 14 | + ("en", "ett") # sw 15 | + ("en", "ei", "et") # no 16 | + ("en", "et") # da 17 | + ("el", "la", "els", "les", "un", "una", "uns", "unes", "na") # ct 18 | ) 19 | 20 | 21 | def get_sort_name(name: str) -> str: 22 | """Create sort_name from name.""" 23 | lower_name = name.lower() 24 | sort_name = lower_name 25 | name_parts = lower_name.split() 26 | if len(name_parts) > 1: 27 | first_word = name_parts[0] 28 | if first_word in _ARTICLES: 29 | sort_name = " ".join(name_parts[1:]) 30 | sort_name += ", " + first_word 31 | return sort_name 32 | -------------------------------------------------------------------------------- /codex/permissions.py: -------------------------------------------------------------------------------- 1 | """Codex drf permissions.""" 2 | 3 | from rest_framework.permissions import BasePermission, IsAdminUser 4 | 5 | from codex.models import Timestamp 6 | 7 | 8 | class HasAPIKeyOrIsAdminUser(BasePermission): 9 | """Does the request have the current api key.""" 10 | 11 | def has_permission(self, request, view): 12 | """Test the request api key against the database.""" 13 | data = request.GET if request.method == "GET" else request.POST 14 | api_key = data.get("apiKey") 15 | if not api_key: 16 | return IsAdminUser().has_permission(request, view) 17 | return Timestamp.objects.filter( 18 | key=Timestamp.TimestampChoices.API_KEY.value, version=api_key 19 | ).exists() 20 | -------------------------------------------------------------------------------- /codex/registration.py: -------------------------------------------------------------------------------- 1 | """Patch settings at runtime.""" 2 | 3 | from rest_registration.settings import registration_settings 4 | 5 | from codex.models import AdminFlag 6 | 7 | 8 | def patch_registration_setting(): 9 | """Patch rest_registration setting.""" 10 | # Technically this is a no-no, but rest-registration makes it easy. 11 | enr = ( 12 | AdminFlag.objects.only("on") 13 | .get(key=AdminFlag.FlagChoices.REGISTRATION.value) 14 | .on 15 | ) 16 | registration_settings.user_settings["REGISTER_FLOW_ENABLED"] = enr 17 | -------------------------------------------------------------------------------- /codex/serializers/README.md: -------------------------------------------------------------------------------- 1 | # serializers 2 | 3 | Django Rest Framework serializers. 4 | [DRF API Guide](https://www.django-rest-framework.org/api-guide/serializers/). 5 | -------------------------------------------------------------------------------- /codex/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | """Django Rest Framework serializers.""" 2 | -------------------------------------------------------------------------------- /codex/serializers/admin/__init__.py: -------------------------------------------------------------------------------- 1 | """Admin view serialzers.""" 2 | -------------------------------------------------------------------------------- /codex/serializers/admin/flags.py: -------------------------------------------------------------------------------- 1 | """Admin flag serializers.""" 2 | 3 | from codex.models import AdminFlag 4 | from codex.serializers.models.base import BaseModelSerializer 5 | 6 | 7 | class AdminFlagSerializer(BaseModelSerializer): 8 | """Admin Flag Serializer.""" 9 | 10 | class Meta(BaseModelSerializer.Meta): 11 | """Specify Model.""" 12 | 13 | model = AdminFlag 14 | fields = ("key", "on", "value") 15 | read_only_fields = ("key",) 16 | -------------------------------------------------------------------------------- /codex/serializers/admin/groups.py: -------------------------------------------------------------------------------- 1 | """Admin group serializers.""" 2 | 3 | from django.contrib.auth.models import Group 4 | from rest_framework.serializers import ( 5 | BooleanField, 6 | ) 7 | 8 | from codex.models.admin import GroupAuth 9 | from codex.serializers.models.base import BaseModelSerializer 10 | 11 | 12 | class GroupSerializer(BaseModelSerializer): 13 | """Group Serialier.""" 14 | 15 | exclude = BooleanField(default=False, source="groupauth.exclude") 16 | 17 | class Meta(BaseModelSerializer.Meta): 18 | """Specify Model.""" 19 | 20 | model = Group 21 | fields = ("pk", "name", "library_set", "user_set", "exclude") 22 | read_only_fields = ("pk",) 23 | 24 | def update(self, instance, validated_data): 25 | """Update with nested GroupAuth.""" 26 | exclude = validated_data.pop("groupauth", {}).get("exclude") 27 | if exclude is not None: 28 | groupauth = GroupAuth.objects.get(group=instance) 29 | groupauth.exclude = exclude 30 | groupauth.save() 31 | return super().update(instance, validated_data) 32 | 33 | def create(self, validated_data): 34 | """Create with nested GroupAuth.""" 35 | exclude = validated_data.pop("groupauth", {}).get("exclude", False) 36 | instance = super().create(validated_data) 37 | GroupAuth.objects.create(group=instance, exclude=exclude) 38 | return instance 39 | -------------------------------------------------------------------------------- /codex/serializers/admin/tasks.py: -------------------------------------------------------------------------------- 1 | """Admin tasks serializers.""" 2 | 3 | from rest_framework.serializers import ( 4 | ChoiceField, 5 | IntegerField, 6 | Serializer, 7 | ) 8 | 9 | from codex.choices.admin import ADMIN_TASK_GROUPS 10 | 11 | _ADMIN_TASK_CHOICES = tuple( 12 | sorted( 13 | [ 14 | item["value"] 15 | for group in ADMIN_TASK_GROUPS["tasks"] 16 | for item in group["tasks"] 17 | ] 18 | ) 19 | ) 20 | 21 | 22 | class AdminLibrarianTaskSerializer(Serializer): 23 | """Get tasks from front end.""" 24 | 25 | task = ChoiceField(choices=_ADMIN_TASK_CHOICES) 26 | library_id = IntegerField(required=False) 27 | -------------------------------------------------------------------------------- /codex/serializers/admin/users.py: -------------------------------------------------------------------------------- 1 | """User serializers.""" 2 | 3 | from django.contrib.auth.models import User 4 | from rest_framework.serializers import ( 5 | CharField, 6 | DateTimeField, 7 | Serializer, 8 | ) 9 | 10 | from codex.serializers.models.base import BaseModelSerializer 11 | 12 | 13 | class UserChangePasswordSerializer(Serializer): 14 | """Special User Change Password Serializer.""" 15 | 16 | password = CharField(write_only=True) 17 | 18 | 19 | class UserSerializer(BaseModelSerializer, UserChangePasswordSerializer): 20 | """User Serializer.""" 21 | 22 | last_active = DateTimeField( 23 | read_only=True, source="useractive.updated_at", allow_null=True 24 | ) 25 | 26 | class Meta(BaseModelSerializer.Meta): 27 | """Specify Model.""" 28 | 29 | model = User 30 | fields = ( 31 | "pk", 32 | "username", 33 | "password", 34 | "groups", 35 | "is_staff", 36 | "is_active", 37 | "last_active", 38 | "last_login", 39 | "date_joined", 40 | ) 41 | read_only_fields = ("pk", "last_active", "last_login", "date_joined") 42 | -------------------------------------------------------------------------------- /codex/serializers/browser/__init__.py: -------------------------------------------------------------------------------- 1 | """Browser Serializers.""" 2 | -------------------------------------------------------------------------------- /codex/serializers/browser/mtime.py: -------------------------------------------------------------------------------- 1 | """Group Mtimes.""" 2 | 3 | from rest_framework.serializers import Serializer 4 | 5 | from codex.serializers.browser.settings import BrowserFilterChoicesInputSerilalizer 6 | from codex.serializers.fields import TimestampField 7 | from codex.serializers.route import SimpleRouteSerializer 8 | 9 | 10 | class GroupsMtimeSerializer(BrowserFilterChoicesInputSerilalizer): 11 | """Groups Mtimes.""" 12 | 13 | groups = SimpleRouteSerializer(many=True, required=True) 14 | 15 | 16 | class MtimeSerializer(Serializer): 17 | """Max mtime for all submitted groups.""" 18 | 19 | max_mtime = TimestampField(read_only=True) 20 | -------------------------------------------------------------------------------- /codex/serializers/fields/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom Serializer Fields.""" 2 | 3 | from codex.serializers.fields.auth import TimestampField, TimezoneField 4 | from codex.serializers.fields.group import BrowseGroupField 5 | from codex.serializers.fields.reader import FitToField, ReadingDirectionField 6 | from codex.serializers.fields.sanitized import SanitizedCharField 7 | from codex.serializers.fields.session import SessionKeyField 8 | from codex.serializers.fields.stats import ( 9 | CountDictField, 10 | SerializerChoicesField, 11 | StringListMultipleChoiceField, 12 | ) 13 | from codex.serializers.fields.vuetify import ( 14 | VuetifyBooleanField, 15 | VuetifyCharField, 16 | VuetifyDecadeField, 17 | VuetifyFloatField, 18 | VuetifyIntegerField, 19 | ) 20 | 21 | __all__ = ( 22 | "BrowseGroupField", 23 | "CountDictField", 24 | "FitToField", 25 | "ReadingDirectionField", 26 | "SanitizedCharField", 27 | "SerializerChoicesField", 28 | "SessionKeyField", 29 | "StringListMultipleChoiceField", 30 | "TimestampField", 31 | "TimezoneField", 32 | "VuetifyBooleanField", 33 | "VuetifyCharField", 34 | "VuetifyDecadeField", 35 | "VuetifyFloatField", 36 | "VuetifyIntegerField", 37 | ) 38 | -------------------------------------------------------------------------------- /codex/serializers/fields/auth.py: -------------------------------------------------------------------------------- 1 | """Custom fields.""" 2 | 3 | from datetime import datetime, timezone 4 | from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 5 | 6 | from rest_framework.exceptions import ValidationError 7 | from rest_framework.serializers import ( 8 | CharField, 9 | IntegerField, 10 | ) 11 | 12 | from codex.logger.logger import get_logger 13 | 14 | LOG = get_logger(__name__) 15 | 16 | 17 | class TimestampField(IntegerField): 18 | """IntegerTimestampField.""" 19 | 20 | def to_representation(self, value) -> int: 21 | """Convert to Jascript millisecond int timestamp from datetime, or castable.""" 22 | if isinstance(value, datetime): 23 | value = value.timestamp() 24 | return int(float(value) * 1000) 25 | 26 | def to_internal_value(self, data) -> datetime: # type: ignore[reportIncompatibleMethodOverride] 27 | """Convert from castable, likely string to datetime.""" 28 | return datetime.fromtimestamp(float(data) / 1000, tz=timezone.utc) 29 | 30 | 31 | def validate_timezone(data): 32 | """Validate Timezone.""" 33 | try: 34 | ZoneInfo(data) 35 | except ZoneInfoNotFoundError as exc: 36 | raise ValidationError from exc 37 | return data 38 | 39 | 40 | class TimezoneField(CharField): 41 | """Timezone field.""" 42 | 43 | def __init__(self, *args, **kwargs): 44 | """Call Charfield with defaults.""" 45 | super().__init__(*args, min_length=2, validators=[validate_timezone], **kwargs) 46 | -------------------------------------------------------------------------------- /codex/serializers/fields/group.py: -------------------------------------------------------------------------------- 1 | """Browser Group Field.""" 2 | 3 | from rest_framework.fields import ChoiceField 4 | 5 | from codex.choices.browser import BROWSER_GROUP_CHOICES 6 | 7 | 8 | class BrowseGroupField(ChoiceField): 9 | """BrowseGroup Field.""" 10 | 11 | def __init__(self, *args, **kwargs): 12 | """Add choices.""" 13 | super().__init__(*args, choices=tuple(BROWSER_GROUP_CHOICES.keys()), **kwargs) 14 | -------------------------------------------------------------------------------- /codex/serializers/fields/reader.py: -------------------------------------------------------------------------------- 1 | """Reader Fields.""" 2 | 3 | from rest_framework.serializers import ChoiceField 4 | 5 | from codex.models import Bookmark 6 | from codex.models.comic import ReadingDirection 7 | 8 | 9 | class FitToField(ChoiceField): 10 | """Bookmark FitTo Fieild.""" 11 | 12 | def __init__(self, *args, **kwargs): 13 | """Add Choices.""" 14 | super().__init__(*args, choices=Bookmark.FitTo.values, **kwargs) 15 | 16 | 17 | class ReadingDirectionField(ChoiceField): 18 | """Reading Direction Field.""" 19 | 20 | def __init__(self, *args, **kwargs): 21 | """Add Choices.""" 22 | super().__init__(*args, choices=ReadingDirection.values, **kwargs) 23 | -------------------------------------------------------------------------------- /codex/serializers/fields/sanitized.py: -------------------------------------------------------------------------------- 1 | """Sanitied Fields.""" 2 | 3 | from nh3 import clean 4 | from rest_framework.fields import CharField 5 | 6 | 7 | class SanitizedCharField(CharField): 8 | """Sanitize CharField using NH3.""" 9 | 10 | def to_internal_value(self, data): 11 | """Sanitize CharField using NH3.""" 12 | sanitized_data = clean(data) 13 | return super().to_internal_value(sanitized_data) 14 | -------------------------------------------------------------------------------- /codex/serializers/fields/session.py: -------------------------------------------------------------------------------- 1 | """Session Custom Fields.""" 2 | 3 | from rest_framework.fields import ChoiceField 4 | 5 | from codex.choices.browser import BROWSER_DEFAULTS 6 | from codex.choices.reader import READER_DEFAULTS 7 | 8 | _SESSION_KEYS = (*READER_DEFAULTS.keys(), *BROWSER_DEFAULTS.keys(), "filters") 9 | 10 | 11 | class SessionKeyField(ChoiceField): 12 | """Session Key Field.""" 13 | 14 | def __init__(self, *args, **kwargs): 15 | """Add choices.""" 16 | super().__init__(*args, choices=_SESSION_KEYS, **kwargs) 17 | -------------------------------------------------------------------------------- /codex/serializers/fields/stats.py: -------------------------------------------------------------------------------- 1 | """Custom Vuetify fields.""" 2 | 3 | from rest_framework.fields import DictField, IntegerField 4 | from rest_framework.serializers import MultipleChoiceField 5 | 6 | from codex.logger.logger import get_logger 7 | 8 | LOG = get_logger(__name__) 9 | 10 | 11 | class StringListMultipleChoiceField(MultipleChoiceField): 12 | """A Multiple Choice Field expressed as as a comma delimited string.""" 13 | 14 | def to_internal_value(self, data): 15 | """Convert comma delimited strings to sets.""" 16 | if isinstance(data, str): 17 | data = frozenset(data.split(",")) 18 | return super().to_internal_value(data) # type: ignore[reportIncompatibleMethodOverride] 19 | 20 | 21 | class SerializerChoicesField(StringListMultipleChoiceField): 22 | """A String List Multiple Choice Field limited to a specified serializer's fields.""" 23 | 24 | def __init__(self, *args, serializer=None, **kwargs): 25 | """Limit choices to fields from serializers.""" 26 | if not serializer: 27 | reason = "serializer required for this field." 28 | raise ValueError(reason) 29 | choices = serializer().get_fields().keys() 30 | super().__init__(*args, choices=choices, **kwargs) 31 | 32 | 33 | class CountDictField(DictField): 34 | """Dict for counting things.""" 35 | 36 | child = IntegerField(read_only=True) 37 | -------------------------------------------------------------------------------- /codex/serializers/homepage.py: -------------------------------------------------------------------------------- 1 | """Serializers for homepage endpoint.""" 2 | 3 | from rest_framework.serializers import IntegerField, Serializer 4 | 5 | 6 | class HomepageSerializer(Serializer): 7 | """Minimal stats for homepage.""" 8 | 9 | publisher_count = IntegerField() 10 | series_count = IntegerField() 11 | comic_count = IntegerField() 12 | -------------------------------------------------------------------------------- /codex/serializers/mixins.py: -------------------------------------------------------------------------------- 1 | """Serializer mixins.""" 2 | 3 | from rest_framework.serializers import ( 4 | BooleanField, 5 | Serializer, 6 | ) 7 | 8 | 9 | class OKSerializer(Serializer): 10 | """Default serializer for views without much response.""" 11 | 12 | ok = BooleanField(default=True) 13 | -------------------------------------------------------------------------------- /codex/serializers/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Django Model Serializers.""" 2 | -------------------------------------------------------------------------------- /codex/serializers/models/admin.py: -------------------------------------------------------------------------------- 1 | """Admin Model Serializers.""" 2 | 3 | from codex.models.admin import LibrarianStatus 4 | from codex.serializers.models.base import BaseModelSerializer 5 | 6 | 7 | class LibrarianStatusSerializer(BaseModelSerializer): 8 | """Serializer Librarian task statuses.""" 9 | 10 | class Meta(BaseModelSerializer.Meta): 11 | """Configure the model.""" 12 | 13 | model = LibrarianStatus 14 | exclude = ("preactive", "created_at", "updated_at") 15 | -------------------------------------------------------------------------------- /codex/serializers/models/base.py: -------------------------------------------------------------------------------- 1 | """Serializer Base class for inheritable metaclass.""" 2 | 3 | from rest_framework.serializers import ModelSerializer, SerializerMetaclass 4 | 5 | 6 | class BaseModelSerializer(ModelSerializer): 7 | """BaseModel Serializer for inheritance.""" 8 | 9 | class Meta(SerializerMetaclass): # type: ignore[reportIncompatibleVariableOverride] 10 | """Use explicit metaclass instead of python 3 method.""" 11 | 12 | abstract = True 13 | -------------------------------------------------------------------------------- /codex/serializers/models/bookmark.py: -------------------------------------------------------------------------------- 1 | """Bookmark Model Serializers.""" 2 | 3 | from codex.models.bookmark import Bookmark 4 | from codex.serializers.models.base import BaseModelSerializer 5 | 6 | 7 | class BookmarkSerializer(BaseModelSerializer): 8 | """Serializer Bookmark.""" 9 | 10 | class Meta(BaseModelSerializer.Meta): 11 | """Configure the model.""" 12 | 13 | model = Bookmark 14 | fields = ( 15 | "finished", 16 | "fit_to", 17 | "page", 18 | "reading_direction", 19 | "two_pages", 20 | ) 21 | 22 | 23 | class BookmarkFinishedSerializer(BaseModelSerializer): 24 | """The finished field of the Bookmark.""" 25 | 26 | class Meta(BaseModelSerializer.Meta): 27 | """Model spec.""" 28 | 29 | model = Bookmark 30 | fields = ("finished",) 31 | -------------------------------------------------------------------------------- /codex/serializers/models/groups.py: -------------------------------------------------------------------------------- 1 | """Browser Group Model Serializers.""" 2 | 3 | from codex.models import ( 4 | Imprint, 5 | Publisher, 6 | Series, 7 | Volume, 8 | ) 9 | from codex.serializers.models.named import NamedModelSerializer 10 | 11 | 12 | class GroupModelSerializer(NamedModelSerializer): 13 | """A common class for BrowserGroupModels.""" 14 | 15 | class Meta(NamedModelSerializer.Meta): 16 | """Abstract class.""" 17 | 18 | abstract = True 19 | 20 | 21 | class PublisherSerializer(GroupModelSerializer): 22 | """Publisher Model.""" 23 | 24 | class Meta(GroupModelSerializer.Meta): 25 | """Configure model.""" 26 | 27 | model = Publisher 28 | 29 | 30 | class ImprintSerializer(GroupModelSerializer): 31 | """Imprint Model.""" 32 | 33 | class Meta(GroupModelSerializer.Meta): 34 | """Configure model.""" 35 | 36 | model = Imprint 37 | 38 | 39 | class SeriesSerializer(GroupModelSerializer): 40 | """Series Model.""" 41 | 42 | class Meta(GroupModelSerializer.Meta): 43 | """Configure model.""" 44 | 45 | model = Series 46 | 47 | 48 | class VolumeSerializer(GroupModelSerializer): 49 | """Volume Model.""" 50 | 51 | class Meta(GroupModelSerializer.Meta): 52 | """Configure model.""" 53 | 54 | model = Volume 55 | -------------------------------------------------------------------------------- /codex/serializers/models/pycountry.py: -------------------------------------------------------------------------------- 1 | """Pycountry Model Serializers.""" 2 | 3 | from codex.logger.logger import get_logger 4 | from codex.models import ( 5 | Country, 6 | Language, 7 | ) 8 | from codex.serializers.fields.browser import CountryField, LanguageField 9 | from codex.serializers.models.named import NamedModelSerializer 10 | 11 | LOG = get_logger(__name__) 12 | 13 | 14 | class CountrySerializer(NamedModelSerializer): 15 | """Pycountry serializer for country field.""" 16 | 17 | name = CountryField(read_only=True) 18 | 19 | class Meta(NamedModelSerializer.Meta): 20 | """Configure model.""" 21 | 22 | model = Country 23 | 24 | 25 | class LanguageSerializer(NamedModelSerializer): 26 | """Pycountry serializer for language field.""" 27 | 28 | name = LanguageField(read_only=True) 29 | 30 | class Meta(NamedModelSerializer.Meta): 31 | """Configure model.""" 32 | 33 | model = Language 34 | -------------------------------------------------------------------------------- /codex/serializers/opds/__init__.py: -------------------------------------------------------------------------------- 1 | """OPDS Serializers.""" 2 | -------------------------------------------------------------------------------- /codex/serializers/opds/authentication.py: -------------------------------------------------------------------------------- 1 | """ 2 | OPDS Authentication 1.0 Serializer. 3 | 4 | https://drafts.opds.io/schema/authentication.schema.json 5 | """ 6 | 7 | from rest_framework.fields import CharField 8 | from rest_framework.serializers import IntegerField, Serializer 9 | 10 | 11 | class OPDSAuth1LinksSerializer(Serializer): 12 | """OPDS Authentication Links.""" 13 | 14 | rel = CharField(read_only=True) 15 | href = CharField(read_only=True) 16 | type = CharField(read_only=True) 17 | width = IntegerField(read_only=True, required=False) 18 | height = IntegerField(read_only=True, required=False) 19 | 20 | 21 | class OPDSAuthetication1LabelsSerializer(Serializer): 22 | """Authentication Labels.""" 23 | 24 | login = CharField(read_only=True) 25 | password = CharField(read_only=True) 26 | 27 | 28 | class OPDSAuthentication1FlowSerializer(Serializer): 29 | """Authentication Flow.""" 30 | 31 | type = CharField(read_only=True) 32 | links = OPDSAuth1LinksSerializer(many=True, read_only=True, required=False) 33 | labels = OPDSAuthetication1LabelsSerializer(read_only=True) 34 | 35 | 36 | class OPDSAuthentication1Serializer(Serializer): 37 | """Authentication.""" 38 | 39 | title = CharField(read_only=True) 40 | id = CharField(read_only=True) 41 | description = CharField(required=False, read_only=True) 42 | links = OPDSAuth1LinksSerializer(many=True, read_only=True, required=False) 43 | authentication = OPDSAuthentication1FlowSerializer(many=True, read_only=True) 44 | -------------------------------------------------------------------------------- /codex/serializers/opds/urls.py: -------------------------------------------------------------------------------- 1 | """OPDS URLs.""" 2 | 3 | from rest_framework.fields import CharField 4 | from rest_framework.serializers import Serializer 5 | 6 | 7 | class OPDSURLsSerializer(Serializer): 8 | """OPDS Urls.""" 9 | 10 | v1 = CharField(read_only=True) 11 | v2 = CharField(read_only=True) 12 | -------------------------------------------------------------------------------- /codex/serializers/opds/v2/__init__.py: -------------------------------------------------------------------------------- 1 | """OPDS v2.0 Serializers.""" 2 | -------------------------------------------------------------------------------- /codex/serializers/opds/v2/facet.py: -------------------------------------------------------------------------------- 1 | """OPDS v2.0 Facet Serializer.""" 2 | 3 | from rest_framework.serializers import Serializer 4 | 5 | from codex.serializers.opds.v2.links import OPDS2LinkListField 6 | from codex.serializers.opds.v2.metadata import OPDS2MetadataSerializer 7 | 8 | 9 | class OPDS2FacetSerializer(Serializer): 10 | """Facets.""" 11 | 12 | metadata = OPDS2MetadataSerializer(read_only=True) 13 | links = OPDS2LinkListField(read_only=True) 14 | -------------------------------------------------------------------------------- /codex/serializers/opds/v2/feed.py: -------------------------------------------------------------------------------- 1 | """OPDS v2.0 Feed Serializer.""" 2 | 3 | from rest_framework.fields import ( 4 | ListField, 5 | ) 6 | from rest_framework.serializers import Serializer 7 | 8 | from codex.serializers.opds.v2.facet import OPDS2FacetSerializer 9 | from codex.serializers.opds.v2.links import OPDS2LinkListField 10 | from codex.serializers.opds.v2.publication import ( 11 | OPDS2MetadataSerializer, 12 | OPDS2PublicationSerializer, 13 | ) 14 | 15 | 16 | class OPDS2GroupSerializer(Serializer): 17 | """Group.""" 18 | 19 | metadata = OPDS2MetadataSerializer(read_only=True) 20 | links = OPDS2LinkListField(read_only=True, required=False) 21 | publications = ListField( 22 | child=OPDS2PublicationSerializer(), read_only=True, required=False 23 | ) 24 | navigation = OPDS2LinkListField(read_only=True, required=False) 25 | 26 | 27 | class OPDS2FeedSerializer(OPDS2GroupSerializer): 28 | """ 29 | Feed. 30 | 31 | https://drafts.opds.io/schema/feed.schema.json 32 | https://readium.org/webpub-manifest/schema/subcollection.schema.json 33 | """ 34 | 35 | facets = ListField(child=OPDS2FacetSerializer(), read_only=True, required=False) 36 | groups = ListField(child=OPDS2GroupSerializer(), read_only=True, required=False) 37 | -------------------------------------------------------------------------------- /codex/serializers/opds/v2/metadata.py: -------------------------------------------------------------------------------- 1 | """OPDS 2.0 Metadata Serializer.""" 2 | 3 | from rest_framework.fields import CharField, DateTimeField 4 | from rest_framework.serializers import Serializer 5 | 6 | 7 | class OPDS2MetadataSerializer(Serializer): 8 | """ 9 | Metadata. 10 | 11 | https://drafts.opds.io/schema/feed-metadata.schema.json 12 | """ 13 | 14 | identifier = CharField(read_only=True, required=False) 15 | title = CharField(read_only=True) 16 | subtitle = CharField(read_only=True, required=False) 17 | modified = DateTimeField(read_only=True, required=False) 18 | description = CharField(read_only=True, required=False) 19 | -------------------------------------------------------------------------------- /codex/serializers/redirect.py: -------------------------------------------------------------------------------- 1 | """Notification serializers.""" 2 | 3 | from rest_framework.fields import CharField 4 | from rest_framework.serializers import Serializer 5 | 6 | from codex.serializers.browser.settings import BrowserSettingsSerializer 7 | from codex.serializers.route import RouteSerializer 8 | 9 | 10 | class ReaderRedirectSerializer(Serializer): 11 | """Reader 404 message.""" 12 | 13 | reason = CharField(read_only=True) 14 | route = RouteSerializer(read_only=True) 15 | 16 | 17 | class BrowserRedirectSerializer(ReaderRedirectSerializer): 18 | """Redirect to another route.""" 19 | 20 | settings = BrowserSettingsSerializer(read_only=True) 21 | -------------------------------------------------------------------------------- /codex/serializers/settings.py: -------------------------------------------------------------------------------- 1 | """Settings Serializer.""" 2 | 3 | from rest_framework.fields import BooleanField 4 | from rest_framework.serializers import ListSerializer, Serializer 5 | 6 | from codex.serializers.fields import BrowseGroupField, SessionKeyField 7 | 8 | 9 | class SettingsSerializer(Serializer): 10 | """For requesting settings.""" 11 | 12 | only = ListSerializer(child=SessionKeyField(), required=False) 13 | group = BrowseGroupField(required=False) 14 | breadcrumb_names = BooleanField(required=False, default=True) 15 | -------------------------------------------------------------------------------- /codex/serializers/versions.py: -------------------------------------------------------------------------------- 1 | """Versions Serializer.""" 2 | 3 | from rest_framework.fields import CharField 4 | from rest_framework.serializers import Serializer 5 | 6 | 7 | class VersionsSerializer(Serializer): 8 | """Codex version information.""" 9 | 10 | installed = CharField(read_only=True) 11 | latest = CharField(read_only=True) 12 | -------------------------------------------------------------------------------- /codex/settings/README.md: -------------------------------------------------------------------------------- 1 | # codex.settings 2 | 3 | I moved the functions that service the django settings file into their own 4 | module to clean up the readability of the settings file. 5 | -------------------------------------------------------------------------------- /codex/settings/__init__.py: -------------------------------------------------------------------------------- 1 | """Settings functions for the settings file.""" 2 | -------------------------------------------------------------------------------- /codex/settings/hypercorn.py: -------------------------------------------------------------------------------- 1 | """Parse the hypercorn config for settings.""" 2 | 3 | import shutil 4 | 5 | from hypercorn.config import Config 6 | 7 | 8 | def _ensure_config(hypercon_config_toml, hypercorn_config_toml_default): 9 | """Ensure that a valid config exists.""" 10 | if not hypercon_config_toml.exists(): 11 | shutil.copy(hypercorn_config_toml_default, hypercon_config_toml) 12 | print(f"Copied default config to {hypercon_config_toml}") 13 | 14 | 15 | def load_hypercorn_config(hypercorn_config_toml, hypercorn_config_toml_default, debug): 16 | """Load the hypercorn config.""" 17 | _ensure_config(hypercorn_config_toml, hypercorn_config_toml_default) 18 | config = Config.from_toml(hypercorn_config_toml) 19 | if debug: 20 | config.use_reloader = True 21 | return config 22 | -------------------------------------------------------------------------------- /codex/settings/hypercorn.toml.default: -------------------------------------------------------------------------------- 1 | # http1 & http2 binding 2 | bind = ["0.0.0.0:9810"] 3 | # http3 quick binding 4 | quick_bind = ["0.0.0.0:9810"] 5 | # http path prefix for codex 6 | root_path = "" 7 | # This can be a number or "auto" for Codex to guess a good value. 8 | # https://github.com/ajslater/codex#bulk-database-updates-fail 9 | max_import_batch_size = "auto" 10 | -------------------------------------------------------------------------------- /codex/settings/secret_key.py: -------------------------------------------------------------------------------- 1 | """Manage the django secret key.""" 2 | 3 | from django.core.management.utils import get_random_secret_key 4 | 5 | 6 | def get_secret_key(config_path): 7 | """Get the secret key from a file or create and write it.""" 8 | secret_key_path = config_path / "secret_key" 9 | try: 10 | with secret_key_path.open("r") as scf: 11 | secret_key = scf.read().strip() 12 | except FileNotFoundError: 13 | with secret_key_path.open("w") as scf: 14 | secret_key = get_random_secret_key() 15 | scf.write(secret_key) 16 | return secret_key 17 | -------------------------------------------------------------------------------- /codex/settings/timezone.py: -------------------------------------------------------------------------------- 1 | """Timezone settings functions.""" 2 | 3 | from tzlocal import get_localzone_name 4 | 5 | 6 | def get_time_zone(tz): 7 | """Get the timezone from the tz.""" 8 | if tz and not tz.startswith(":") and "etc/localtime" not in tz and "/" in tz: 9 | time_zone = tz 10 | elif get_localzone_name(): 11 | time_zone = get_localzone_name() 12 | else: 13 | time_zone = "Etc/UTC" 14 | return time_zone 15 | -------------------------------------------------------------------------------- /codex/settings/whitenoise.py: -------------------------------------------------------------------------------- 1 | """Whitenoise setup functions.""" 2 | 3 | import re 4 | 5 | IMF_RE = re.compile(r"^.+[.-][0-9a-zA-Z_-]{8,12}\..+$") 6 | 7 | 8 | def immutable_file_test(_path, url): 9 | """For django-vite.""" 10 | # Match filename with 12 hex digits before the extension 11 | # e.g. app.db8f2edc0c8a.js 12 | return IMF_RE.match(url) 13 | -------------------------------------------------------------------------------- /codex/signals/__init__.py: -------------------------------------------------------------------------------- 1 | """OS and Django Signals.""" 2 | -------------------------------------------------------------------------------- /codex/signals/django_signals.py: -------------------------------------------------------------------------------- 1 | """Django signal actions.""" 2 | 3 | from time import time 4 | 5 | from django.core.cache import cache 6 | from django.db.models.signals import m2m_changed 7 | 8 | from codex.librarian.mp_queue import LIBRARIAN_QUEUE 9 | from codex.librarian.notifier.tasks import LIBRARIAN_STATUS_TASK 10 | from codex.librarian.tasks import DelayedTasks 11 | from codex.logger.logger import get_logger 12 | 13 | GROUP_CHANGE_MODEL_NAMES = frozenset(("User", "Library")) 14 | GROUP_CHANGE_ACTIONS = frozenset( 15 | { 16 | "post_add", 17 | "post_remove", 18 | "post_clear", 19 | } 20 | ) 21 | LOG = get_logger(__name__) 22 | 23 | 24 | def _user_group_change(**kwargs): 25 | """Clear cache and send update signals when groups change.""" 26 | model = kwargs["model"] 27 | action = kwargs["action"] 28 | if ( 29 | model.__name__ not in GROUP_CHANGE_MODEL_NAMES 30 | or action not in GROUP_CHANGE_ACTIONS 31 | ): 32 | return 33 | cache.clear() 34 | tasks = (LIBRARIAN_STATUS_TASK,) 35 | task = DelayedTasks(time() + 2, tasks) 36 | LIBRARIAN_QUEUE.put(task) 37 | 38 | 39 | def connect_signals(): 40 | """Connect actions to signals.""" 41 | # connection_created.connect(_db_connect) unused atm 42 | m2m_changed.connect(_user_group_change) 43 | -------------------------------------------------------------------------------- /codex/signals/os_signals.py: -------------------------------------------------------------------------------- 1 | """OS Signal actions.""" 2 | 3 | import asyncio 4 | import signal 5 | from asyncio import Event 6 | 7 | from codex.logger.logger import get_logger 8 | 9 | LOG = get_logger(__name__) 10 | STOP_SIGNAL_NAMES = ( 11 | "SIGABRT", 12 | "SIGBREAK", 13 | "SIGBUS", 14 | "SIGHUP", 15 | "SIGINT", 16 | "SIGQUIT", 17 | "SIGSEGV", 18 | "SIGTERM", 19 | "SIGUSR1", 20 | "SIGUSR2", 21 | ) 22 | RESTART_EVENT = Event() 23 | SHUTDOWN_EVENT = Event() 24 | 25 | 26 | def _shutdown_signal_handler(*_args): 27 | """Initiate Codex Shutdown.""" 28 | if SHUTDOWN_EVENT.is_set(): 29 | return 30 | LOG.info("Asking hypercorn to shut down gracefully. Could take 10 seconds...") 31 | SHUTDOWN_EVENT.set() 32 | 33 | 34 | def _restart_signal_handler(*_args): 35 | """Initiate Codex Restart.""" 36 | if RESTART_EVENT.is_set(): 37 | return 38 | LOG.info("Restart signal received.") 39 | RESTART_EVENT.set() 40 | _shutdown_signal_handler() 41 | 42 | 43 | def bind_signals_to_loop(): 44 | """Binds signals to the handlers.""" 45 | try: 46 | loop = asyncio.get_running_loop() 47 | for name in STOP_SIGNAL_NAMES: 48 | if sig := getattr(signal, name, None): 49 | loop.add_signal_handler(sig, _shutdown_signal_handler) 50 | loop.add_signal_handler(signal.SIGUSR1, _restart_signal_handler) 51 | except NotImplementedError: 52 | LOG.info("Shutdown and restart signal handling not implemented on windows.") 53 | -------------------------------------------------------------------------------- /codex/static_src/img/.picopt_treestamps.yaml: -------------------------------------------------------------------------------- 1 | .: 1715731925.021083 2 | config: 3 | bigger: false 4 | convert_to: 5 | - WEBP 6 | formats: 7 | - GIF 8 | - JPEG 9 | - PNG 10 | - SVG 11 | - WEBP 12 | ignore: [] 13 | keep_metadata: true 14 | near_lossless: false 15 | recurse: true 16 | symlinks: true 17 | treestamps_config: 18 | ignore: [] 19 | symlinks: true 20 | -------------------------------------------------------------------------------- /codex/static_src/img/folder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /codex/static_src/img/logo-32.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajslater/codex/f34aa9ff4e86592dae729ac841f872fa860ad9df/codex/static_src/img/logo-32.webp -------------------------------------------------------------------------------- /codex/static_src/img/logo-maskable-180.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajslater/codex/f34aa9ff4e86592dae729ac841f872fa860ad9df/codex/static_src/img/logo-maskable-180.webp -------------------------------------------------------------------------------- /codex/static_src/img/missing-cover-165.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajslater/codex/f34aa9ff4e86592dae729ac841f872fa860ad9df/codex/static_src/img/missing-cover-165.webp -------------------------------------------------------------------------------- /codex/static_src/img/story-arc.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /codex/static_src/pwa/offline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Codex is offline 10 | 18 | 19 | 20 |

Can't connect to the Codex server

21 | Check your network connectivity 22 | 23 | 24 | -------------------------------------------------------------------------------- /codex/static_src/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /codex/status.py: -------------------------------------------------------------------------------- 1 | """Librarian Status dataclass.""" 2 | 3 | from dataclasses import dataclass 4 | from enum import Enum 5 | 6 | 7 | @dataclass 8 | class Status: 9 | """Args for passing into import functions.""" 10 | 11 | status_type: str | Enum 12 | complete: int | None = None 13 | total: int | None = None 14 | since: float = 0.0 15 | subtitle: str = "" 16 | 17 | def __post_init__(self): 18 | """Convert enums to values.""" 19 | if isinstance(self.status_type, Enum): 20 | self.status_type = self.status_type.value 21 | 22 | def add_complete(self, count: int): 23 | """Add count to complete.""" 24 | if self.complete is None: 25 | self.complete = 0 26 | self.complete += count 27 | 28 | def increment_complete(self): 29 | """Increment complete handling None.""" 30 | self.add_complete(1) 31 | 32 | def decrement_total(self): 33 | """Decreent total if not not.""" 34 | if self.total is not None: 35 | self.total = max(self.total - 1, 0) 36 | -------------------------------------------------------------------------------- /codex/templates/README.md: -------------------------------------------------------------------------------- 1 | # templates 2 | 3 | Django Templates for serving the codex app index and special generated files. 4 | -------------------------------------------------------------------------------- /codex/templates/headers-icons.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 6 | {# Safari doesn't support svg #} 7 | 11 | -------------------------------------------------------------------------------- /codex/templates/headers-script-globals.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 11 | -------------------------------------------------------------------------------- /codex/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | {% load django_vite %} 3 | 4 | 5 | 6 | 8 | {{ title }} 9 | {% include "headers-icons.html" %} 10 | {% include "pwa/headers.html" %} 11 | {% include "headers-script-globals.html" %} 12 | {% vite_hmr_client %} 13 | {% vite_asset "src/main.js" %} 14 | 15 | 16 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /codex/templates/opds_v1/opensearch_v1.xml: -------------------------------------------------------------------------------- 1 | 2 | {% load static %} 3 | 4 | Codex 5 | Codex OPDS Search 6 | UTF-8 7 | UTF-8 8 | {% static 'img/logo.svg' %} 9 | 10 | 11 | -------------------------------------------------------------------------------- /codex/templates/pwa/headers.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {# Apple #} 3 | 7 | 8 | 9 | 10 | {# Chrome #} 11 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /codex/templates/pwa/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { {% load static %} 2 | "name": "Codex", 3 | "short_name": "Codex", 4 | "description": "Comic book reader", 5 | "scope": "{% url 'app:start' %}", 6 | "start_url" : "{% url 'app:start' %}", 7 | "icons": [ 8 | { 9 | "src": "{% static 'img/logo.svg' %}", 10 | "sizes": "any 192x192 512x512", 11 | "type": "image/svg+xml", 12 | "purpose": "any" 13 | }, 14 | { 15 | "src": "{% static 'img/logo-maskable.svg' %}", 16 | "sizes": "any 192x192 512x512", 17 | "type": "image/svg+xml", 18 | "purpose": "maskable" 19 | } 20 | ], 21 | "display": "standalone", 22 | "lang": "en-US", 23 | "background_color": "#2A2A2A", 24 | "theme_color": "#cc7b19", 25 | "dir": "auto", 26 | "orientation": "any", 27 | "status_bar": "default" 28 | } 29 | -------------------------------------------------------------------------------- /codex/templates/pwa/serviceworker-register.js: -------------------------------------------------------------------------------- 1 | // Initialize the service worker 2 | if ("serviceWorker" in navigator) { 3 | navigator.serviceWorker 4 | .register("{% url 'pwa:serviceworker' %}", { 5 | scope: "{% url 'app:start' %}", 6 | }) 7 | .then(function (registration) { 8 | // Registration was successful 9 | console.debug( 10 | "codex-pwa: ServiceWorker registration successful with scope:", 11 | registration.scope, 12 | ); 13 | return true; 14 | }) 15 | .catch(function (error) { 16 | // registration failed :( 17 | console.warn("codex-pwa: ServiceWorker registration failed:", error); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /codex/templates/pwa/serviceworker.js: -------------------------------------------------------------------------------- 1 | // {% load static %} 2 | const CACHE_PREFIX = "codex-pwa-v"; 3 | const STATIC_CACHE_NAME = CACHE_PREFIX + new Date().getSeconds(); 4 | const OFFLINE_PATH = "{% static 'pwa/offline.html' %}"; 5 | const FILES_TO_CACHE = [ 6 | OFFLINE_PATH, 7 | "{% static 'img/logo-32.webp' %}", 8 | "{% static 'img/logo-maskable-180.webp' %}", 9 | "{% static 'img/logo-maskable.svg' %}", 10 | "{% static 'img/logo.svg' %}", 11 | ]; 12 | // Cache offline page on install 13 | self.addEventListener("install", (event) => { 14 | this.skipWaiting(); 15 | event.waitUntil( 16 | caches.open(STATIC_CACHE_NAME).then((cache) => { 17 | return cache.addAll(FILES_TO_CACHE); 18 | }), 19 | ); 20 | }); 21 | // Clear old caches on activate 22 | self.addEventListener("activate", (event) => { 23 | event.waitUntil( 24 | caches.keys().then((cacheNames) => { 25 | return Promise.all( 26 | cacheNames 27 | .filter((cacheName) => cacheName.startsWith(CACHE_PREFIX)) 28 | .filter((cacheName) => cacheName !== STATIC_CACHE_NAME) 29 | .map((cacheName) => caches.delete(cacheName)), 30 | ); 31 | }), 32 | ); 33 | }); 34 | // Serve from Cache 35 | self.addEventListener("fetch", (event) => { 36 | event.respondWith( 37 | caches 38 | .match(event.request) 39 | .then((response) => { 40 | return response || fetch(event.request); 41 | }) 42 | .catch(() => { 43 | return caches.match(OFFLINE_PATH); 44 | }), 45 | ); 46 | }); 47 | -------------------------------------------------------------------------------- /codex/templates/swagger-ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Swagger 5 | 6 | 7 | 10 | 11 | 12 |
13 | 14 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /codex/urls/__init__.py: -------------------------------------------------------------------------------- 1 | """Django URLS.""" 2 | -------------------------------------------------------------------------------- /codex/urls/api/__init__.py: -------------------------------------------------------------------------------- 1 | """API urls.""" 2 | -------------------------------------------------------------------------------- /codex/urls/api/auth.py: -------------------------------------------------------------------------------- 1 | """codex:api:v3:auth URL Configuration.""" 2 | 3 | from django.urls import include, path 4 | 5 | from codex.views.public import AdminFlagsView 6 | from codex.views.timezone import TimezoneView 7 | 8 | app_name = "auth" 9 | urlpatterns = [ 10 | path("", include("rest_registration.api.urls")), 11 | path("flags/", AdminFlagsView.as_view(), name="flags"), 12 | path("timezone/", TimezoneView.as_view(), name="timezone"), 13 | ] 14 | -------------------------------------------------------------------------------- /codex/urls/api/reader.py: -------------------------------------------------------------------------------- 1 | """codex:api:v3:reader URL Configuration.""" 2 | 3 | from django.urls import path 4 | from django.views.decorators.cache import cache_control 5 | 6 | from codex.urls.const import PAGE_MAX_AGE 7 | from codex.views.download import DownloadView 8 | from codex.views.reader.page import ReaderPageView 9 | from codex.views.reader.reader import ReaderView 10 | from codex.views.reader.settings import ReaderSettingsView 11 | 12 | app_name = "issue" 13 | urlpatterns = [ 14 | # 15 | # 16 | # Reader 17 | path("", ReaderView.as_view(), name="reader"), 18 | path( 19 | "//page.jpg", 20 | cache_control(max_age=PAGE_MAX_AGE, public=True)(ReaderPageView.as_view()), 21 | name="page", 22 | ), 23 | path("settings", ReaderSettingsView.as_view(), name="settings"), 24 | # 25 | # 26 | # Download 27 | path( 28 | "/download/", 29 | DownloadView.as_view(), 30 | name="download", 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /codex/urls/api/root.py: -------------------------------------------------------------------------------- 1 | """codex:api URL Configuration.""" 2 | 3 | from django.urls import include, path 4 | 5 | app_name = "api" 6 | urlpatterns = [ 7 | path("v3/", include("codex.urls.api.v3")), 8 | ] 9 | -------------------------------------------------------------------------------- /codex/urls/api/v3.py: -------------------------------------------------------------------------------- 1 | """codex:api:v3 URL Configuration.""" 2 | 3 | from django.urls import include, path 4 | from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView 5 | 6 | from codex.views.browser.mtime import MtimeView 7 | from codex.views.opds.urls import OPDSURLsView 8 | from codex.views.version import VersionView 9 | 10 | app_name = "v3" 11 | urlpatterns = [ 12 | path("auth/", include("codex.urls.api.auth")), 13 | # reader must come first to occlude browser group 14 | path("c/", include("codex.urls.api.reader")), 15 | path("/", include("codex.urls.api.browser")), 16 | path("mtime", MtimeView.as_view(), name="mtimes"), 17 | path("version", VersionView.as_view(), name="version"), 18 | path("admin/", include("codex.urls.api.admin")), 19 | path("schema", SpectacularAPIView.as_view(), name="schema"), 20 | path("opds-urls", OPDSURLsView.as_view(), name="opds_urls"), 21 | path( 22 | "", 23 | SpectacularSwaggerView.as_view(url_name="api:v3:schema"), 24 | name="base", 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /codex/urls/app.py: -------------------------------------------------------------------------------- 1 | """codex:app URL Configuration.""" 2 | 3 | from django.urls import path, re_path 4 | from django.views.decorators.cache import cache_control 5 | from django.views.generic import RedirectView 6 | 7 | from codex.views.download import FileView 8 | from codex.views.frontend import IndexView 9 | 10 | app_name = "app" 11 | 12 | BOOK_AGE = 60 * 60 * 24 * 7 13 | 14 | urlpatterns = [ 15 | path("//", IndexView.as_view(), name="route"), 16 | path( 17 | "c//book.pdf", 18 | cache_control(max_age=BOOK_AGE)(FileView.as_view()), 19 | name="pdf", 20 | ), 21 | path("admin/", IndexView.as_view(), name="admin"), 22 | path("error/", IndexView.as_view(), name="error"), 23 | path("", IndexView.as_view(), name="start"), 24 | re_path( 25 | ".*", 26 | RedirectView.as_view(pattern_name="app:start", permanent=False), 27 | name="catchall", 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /codex/urls/const.py: -------------------------------------------------------------------------------- 1 | """Timeouts.""" 2 | 3 | COMMON_TIMEOUT = 60 * 60 4 | BROWSER_TIMEOUT = 60 * 5 5 | COVER_MAX_AGE = 60 * 60 * 24 * 7 6 | PAGE_MAX_AGE = COVER_MAX_AGE 7 | -------------------------------------------------------------------------------- /codex/urls/converters.py: -------------------------------------------------------------------------------- 1 | """Custom url converters.""" 2 | 3 | from django.urls.converters import StringConverter 4 | 5 | from codex.logger.logger import get_logger 6 | 7 | LOG = get_logger(__name__) 8 | 9 | 10 | class GroupConverter(StringConverter): 11 | """Only accept valid browser groups.""" 12 | 13 | regex = "[rpisvcfa]" 14 | 15 | 16 | class IntListConverter: 17 | """Integer list converter.""" 18 | 19 | regex = r"\d+(,\d+)*" 20 | DELIMITER = "," 21 | 22 | def to_python(self, value): 23 | """Convert string list to tuple of ints.""" 24 | parts = value.split(self.DELIMITER) 25 | pks = set() 26 | for part in parts: 27 | try: 28 | pk = int(part) 29 | if pk == 0: 30 | pks = set() 31 | break 32 | pks.add(pk) 33 | except ValueError: 34 | reason = f"Bad pk list submitted to IntConverter {part=} in {value=}" 35 | LOG.warn(reason) 36 | 37 | return tuple(sorted(pks)) 38 | 39 | def to_url(self, value): 40 | """Convert sequence of ints to a comma delineated string list.""" 41 | pks: set[str] = set() 42 | if value: 43 | for pk in sorted(value): 44 | if pk == 0: 45 | pks = set() 46 | break 47 | pks.add(str(pk)) 48 | return self.DELIMITER.join(pks) if pks else "0" 49 | -------------------------------------------------------------------------------- /codex/urls/opds/__init__.py: -------------------------------------------------------------------------------- 1 | """OPDS urls.""" 2 | -------------------------------------------------------------------------------- /codex/urls/opds/authentication.py: -------------------------------------------------------------------------------- 1 | """codex:opds:v1 URL Configuration.""" 2 | 3 | from django.urls import path 4 | from django.views.decorators.cache import cache_page 5 | 6 | from codex.urls.const import COMMON_TIMEOUT 7 | from codex.views.opds.authentication_v1 import OPDSAuthentication1View 8 | 9 | app_name = "authentication" 10 | 11 | 12 | urlpatterns = [ 13 | path( 14 | "v1", 15 | cache_page(COMMON_TIMEOUT)(OPDSAuthentication1View.as_view()), 16 | name="v1", 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /codex/urls/opds/binary.py: -------------------------------------------------------------------------------- 1 | """codex:opds:v1 URL Configuration.""" 2 | 3 | from django.urls import path 4 | from django.views.decorators.cache import cache_control 5 | 6 | from codex.urls.const import COVER_MAX_AGE, PAGE_MAX_AGE 7 | from codex.views.opds.binary import OPDSCoverView, OPDSDownloadView, OPDSPageView 8 | 9 | app_name = "bin" 10 | 11 | 12 | urlpatterns = [ 13 | # 14 | # Reader 15 | path( 16 | "c///page.jpg", 17 | cache_control(max_age=PAGE_MAX_AGE, public=True)(OPDSPageView.as_view()), 18 | name="page", 19 | ), 20 | # 21 | # utilities 22 | path( 23 | "//cover.webp", 24 | cache_control(max_age=COVER_MAX_AGE, public=True)(OPDSCoverView.as_view()), 25 | name="cover", 26 | ), 27 | path( 28 | "c//download/", 29 | OPDSDownloadView.as_view(), 30 | name="download", 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /codex/urls/opds/root.py: -------------------------------------------------------------------------------- 1 | """codex:opds URL Configuration.""" 2 | 3 | from django.urls import include, path, re_path 4 | 5 | from codex.views.opds.util import full_redirect_view 6 | 7 | app_name = "opds" 8 | 9 | opds_v1_start_view = full_redirect_view("opds:v1:feed") 10 | 11 | urlpatterns = ( 12 | path( 13 | "authentication/", 14 | include("codex.urls.opds.authentication"), 15 | name="authentication", 16 | ), 17 | path("bin/", include("codex.urls.opds.binary")), 18 | path("v1.2/", include("codex.urls.opds.v1")), 19 | path("v1/", opds_v1_start_view, name="v1_start"), 20 | path("v2.0/", include("codex.urls.opds.v2")), 21 | path("v2/", full_redirect_view("opds:v2:feed"), name="v2_start"), 22 | re_path(".*", opds_v1_start_view, name="catchall"), 23 | ) 24 | -------------------------------------------------------------------------------- /codex/urls/opds/v1.py: -------------------------------------------------------------------------------- 1 | """codex:opds:v1 URL Configuration.""" 2 | 3 | from django.urls import path 4 | from django.views.decorators.cache import cache_page 5 | 6 | from codex.urls.const import BROWSER_TIMEOUT, COMMON_TIMEOUT 7 | from codex.views.opds.util import full_redirect_view 8 | from codex.views.opds.v1.feed import OPDS1FeedView 9 | from codex.views.opds.v1.opensearch_v1 import OpenSearch1View 10 | 11 | app_name = "v1" 12 | 13 | 14 | urlpatterns = [ 15 | # 16 | # Browser 17 | path( 18 | "//", 19 | cache_page(BROWSER_TIMEOUT)(OPDS1FeedView.as_view()), 20 | name="feed", 21 | ), 22 | path( 23 | "opensearch/v1.1", 24 | cache_page(COMMON_TIMEOUT)(OpenSearch1View.as_view()), 25 | name="opensearch_v1", 26 | ), 27 | # 28 | # Catch All 29 | path("", full_redirect_view("opds:v1:feed"), name="start"), 30 | ] 31 | -------------------------------------------------------------------------------- /codex/urls/opds/v2.py: -------------------------------------------------------------------------------- 1 | """codex:opds:v1 URL Configuration.""" 2 | 3 | from django.urls import path 4 | from django.views.decorators.cache import cache_page 5 | 6 | from codex.urls.const import BROWSER_TIMEOUT 7 | from codex.views.opds.util import full_redirect_view 8 | from codex.views.opds.v2.feed import OPDS2FeedView 9 | from codex.views.opds.v2.progression import OPDS2ProgressionView 10 | 11 | app_name = "v2" 12 | 13 | urlpatterns = [ 14 | # 15 | # Browser 16 | path( 17 | "//", 18 | cache_page(BROWSER_TIMEOUT)(OPDS2FeedView.as_view()), 19 | name="feed", 20 | ), 21 | path( 22 | "c//", 23 | cache_page(BROWSER_TIMEOUT)(OPDS2FeedView.as_view()), 24 | name="acq", 25 | ), 26 | path( 27 | "//position", 28 | cache_page(BROWSER_TIMEOUT)(OPDS2ProgressionView.as_view()), 29 | name="position", 30 | ), 31 | # 32 | # Catch All 33 | path("", full_redirect_view("opds:v2:feed"), name="start"), 34 | ] 35 | -------------------------------------------------------------------------------- /codex/urls/pwa.py: -------------------------------------------------------------------------------- 1 | """codex:pwa URL Configuration.""" 2 | 3 | from django.urls import path 4 | from django.views.decorators.cache import cache_page 5 | 6 | from codex.urls.const import COMMON_TIMEOUT 7 | from codex.views.pwa import ( 8 | ServiceWorkerRegisterView, 9 | ServiceWorkerView, 10 | WebManifestView, 11 | ) 12 | 13 | app_name = "pwa" 14 | 15 | urlpatterns = [ 16 | path( 17 | "manifest.webmanifest", 18 | cache_page(COMMON_TIMEOUT)(WebManifestView.as_view()), 19 | name="manifest", 20 | ), 21 | path( 22 | "serviceworker-register.js", 23 | cache_page(COMMON_TIMEOUT)(ServiceWorkerRegisterView.as_view()), 24 | name="serviceworker_register", 25 | ), 26 | path( 27 | "serviceworker.js", 28 | cache_page(COMMON_TIMEOUT)(ServiceWorkerView.as_view()), 29 | name="serviceworker", 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /codex/urls/root.py: -------------------------------------------------------------------------------- 1 | """ 2 | Codex URL Configuration. 3 | 4 | https://docs.djangoproject.com/en/dev/topics/http/urls/ 5 | """ 6 | 7 | from django.contrib.staticfiles.storage import staticfiles_storage 8 | from django.urls import include, path, register_converter 9 | from django.views.generic.base import RedirectView 10 | 11 | from codex.settings.settings import DEBUG 12 | from codex.urls.converters import GroupConverter, IntListConverter 13 | 14 | register_converter(GroupConverter, "group") 15 | register_converter(IntListConverter, "int_list") 16 | 17 | 18 | urlpatterns = [] 19 | if DEBUG: 20 | # Pyright doesn't follow logic so will try to find these types. 21 | from schema_graph.views import Schema # type: ignore[reportMissingImports] 22 | 23 | urlpatterns += [ 24 | path("schema/", Schema.as_view()), # type: ignore[reportPossiblyUnboundVariable] 25 | ] 26 | 27 | urlpatterns += [ 28 | path( 29 | "favicon.ico", 30 | RedirectView.as_view(url=staticfiles_storage.url("img/logo-32.webp")), 31 | name="favicon", 32 | ), 33 | path( 34 | "robots.txt", 35 | RedirectView.as_view(url=staticfiles_storage.url("robots.txt")), 36 | name="robots", 37 | ), 38 | path("api/", include("codex.urls.api.root")), 39 | path("opds/", include("codex.urls.opds.root")), 40 | path("", include("codex.urls.pwa")), 41 | # The app must be last because it includes a catch-all path 42 | path("", include("codex.urls.app")), 43 | ] 44 | -------------------------------------------------------------------------------- /codex/urls/spectacular.py: -------------------------------------------------------------------------------- 1 | """Spectacular hooks.""" 2 | 3 | ALLOW_PREFIXES = ("/api", "/opds") 4 | 5 | 6 | def allow_list(endpoints): 7 | """Allow only API endpoints.""" 8 | drf_endpoints = [] 9 | for endpoint in endpoints: 10 | path = endpoint[0] 11 | for prefix in ALLOW_PREFIXES: 12 | if path.startswith(prefix): 13 | drf_endpoints += [endpoint] 14 | break 15 | return drf_endpoints 16 | -------------------------------------------------------------------------------- /codex/util.py: -------------------------------------------------------------------------------- 1 | """Utility functions.""" 2 | 3 | from collections.abc import Mapping 4 | 5 | 6 | def max_none(*args): 7 | """None aware math.max.""" 8 | max_arg = None 9 | for arg in args: 10 | if max_arg is None: 11 | max_arg = arg 12 | elif arg is not None: 13 | max_arg = max(max_arg, arg) 14 | return max_arg 15 | 16 | 17 | def mapping_to_dict(data): 18 | """Convert nested Mapping objects to dicts.""" 19 | if isinstance(data, Mapping): 20 | return {key: mapping_to_dict(value) for key, value in data.items()} 21 | if isinstance(data, list | tuple | frozenset | set): 22 | return [mapping_to_dict(item) for item in data] 23 | return data 24 | -------------------------------------------------------------------------------- /codex/version.py: -------------------------------------------------------------------------------- 1 | """Hold the current codex version.""" 2 | 3 | from importlib.metadata import PackageNotFoundError, version 4 | 5 | PACKAGE_NAME = "codex" 6 | 7 | 8 | def get_version(): 9 | """Get the current installed codex version.""" 10 | try: 11 | v = version(PACKAGE_NAME) 12 | except PackageNotFoundError: 13 | v = "test" 14 | return v 15 | 16 | 17 | VERSION = get_version() 18 | -------------------------------------------------------------------------------- /codex/views/README.md: -------------------------------------------------------------------------------- 1 | # views 2 | 3 | The Django Rest Framework views. 4 | [API Docs](https://www.django-rest-framework.org/api-guide/views/). 5 | -------------------------------------------------------------------------------- /codex/views/__init__.py: -------------------------------------------------------------------------------- 1 | """Django Rest Framework views.""" 2 | -------------------------------------------------------------------------------- /codex/views/admin/__init__.py: -------------------------------------------------------------------------------- 1 | """Admin Views.""" 2 | -------------------------------------------------------------------------------- /codex/views/admin/api_key.py: -------------------------------------------------------------------------------- 1 | """API Key Endpoint.""" 2 | 3 | from drf_spectacular.utils import extend_schema 4 | from rest_framework.response import Response 5 | 6 | from codex.models import Timestamp 7 | from codex.serializers.admin.stats import APIKeySerializer 8 | from codex.views.admin.auth import AdminGenericAPIView 9 | 10 | 11 | class AdminAPIKey(AdminGenericAPIView): 12 | """Regenerate API Key.""" 13 | 14 | serializer_class = APIKeySerializer 15 | input_serializer_class = None 16 | 17 | @extend_schema(request=input_serializer_class) 18 | def put(self, *_args, **_kwargs): 19 | """Regenerate the API Key.""" 20 | ts = Timestamp.objects.get(key=Timestamp.TimestampChoices.API_KEY.value) 21 | ts.save_uuid_version() 22 | serializer = self.get_serializer(ts) 23 | return Response(serializer.data) 24 | -------------------------------------------------------------------------------- /codex/views/admin/auth.py: -------------------------------------------------------------------------------- 1 | """Admin Auth.""" 2 | 3 | from rest_framework.generics import GenericAPIView 4 | from rest_framework.permissions import IsAdminUser 5 | from rest_framework.views import APIView 6 | from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet 7 | 8 | 9 | class AdminAuthMixin: 10 | """Admin Authorization Classes.""" 11 | 12 | permission_classes = (IsAdminUser,) 13 | 14 | 15 | class AdminAPIView(AdminAuthMixin, APIView): 16 | """Admin API View.""" 17 | 18 | 19 | class AdminGenericAPIView(AdminAuthMixin, GenericAPIView): 20 | """Admin Generic API View.""" 21 | 22 | 23 | class AdminModelViewSet(AdminAuthMixin, ModelViewSet): 24 | """Admin Model View Set.""" 25 | 26 | 27 | class AdminReadOnlyModelViewSet(AdminAuthMixin, ReadOnlyModelViewSet): 28 | """Admin Read Only Model View Set.""" 29 | -------------------------------------------------------------------------------- /codex/views/browser/__init__.py: -------------------------------------------------------------------------------- 1 | """Browser views.""" 2 | -------------------------------------------------------------------------------- /codex/views/browser/annotate/__init__.py: -------------------------------------------------------------------------------- 1 | """Browser annotation methods.""" 2 | -------------------------------------------------------------------------------- /codex/views/browser/filters/__init__.py: -------------------------------------------------------------------------------- 1 | """Browser filter mixins.""" 2 | -------------------------------------------------------------------------------- /codex/views/browser/filters/bookmark.py: -------------------------------------------------------------------------------- 1 | """Bookmark filter view methods.""" 2 | 3 | from django.db.models import Q 4 | 5 | from codex.views.bookmark import BookmarkFilterMixin 6 | from codex.views.browser.validate import BrowserValidateView 7 | 8 | 9 | class BrowserFilterBookmarkView(BookmarkFilterMixin, BrowserValidateView): 10 | """BookmarkFilter view methods.""" 11 | 12 | def get_bookmark_filter(self, model): 13 | """Build bookmark query.""" 14 | choice: str = self.params.get("filters", {}).get("bookmark", "") 15 | if choice: 16 | bm_rel = self.get_bm_rel(model) 17 | my_bookmark_filter = self.get_my_bookmark_filter(bm_rel) 18 | if choice == "READ": 19 | bookmark_filter = my_bookmark_filter & Q( 20 | **{f"{bm_rel}__finished": True} 21 | ) 22 | else: 23 | my_not_finished_filter = my_bookmark_filter & Q( 24 | **{f"{bm_rel}__finished__in": (False, None)} 25 | ) 26 | if choice == "UNREAD": 27 | bookmark_filter = Q(**{bm_rel: None}) | my_not_finished_filter 28 | else: # IN_PROGRESS 29 | bookmark_filter = my_not_finished_filter & Q( 30 | **{f"{bm_rel}__page__gt": 0} 31 | ) 32 | else: 33 | bookmark_filter = Q() 34 | return bookmark_filter 35 | -------------------------------------------------------------------------------- /codex/views/browser/filters/search/__init__.py: -------------------------------------------------------------------------------- 1 | """Search filter.""" 2 | -------------------------------------------------------------------------------- /codex/views/browser/filters/search/field/__init__.py: -------------------------------------------------------------------------------- 1 | """Column Lookup Queries from field tokens.""" 2 | -------------------------------------------------------------------------------- /codex/views/browser/filters/search/fts.py: -------------------------------------------------------------------------------- 1 | """Search Filters Methods.""" 2 | 3 | from codex.logger.logger import get_logger 4 | from codex.views.browser.filters.search.field.filter import BrowserFieldQueryFilter 5 | 6 | LOG = get_logger(__name__) 7 | 8 | 9 | class BrowserFTSFilter(BrowserFieldQueryFilter): 10 | """Search Filters Methods.""" 11 | 12 | def get_fts_filter(self, model, text): 13 | """Perform the search and return the scores as a dict.""" 14 | fts_filter = {} 15 | try: 16 | if text: 17 | rel = self.get_rel_prefix(model) 18 | # Custom lookup defined in codex.models 19 | rel += "comicfts__match" 20 | fts_filter[rel] = text 21 | except Exception: 22 | LOG.exception("Getting Full Text Search Filter.") 23 | self.search_error = "Error creating full text search filter" 24 | return fts_filter 25 | -------------------------------------------------------------------------------- /codex/views/browser/metadata/__init__.py: -------------------------------------------------------------------------------- 1 | """Metadata View.""" 2 | -------------------------------------------------------------------------------- /codex/views/error.py: -------------------------------------------------------------------------------- 1 | """Custom Http Error Views.""" 2 | 3 | from django.shortcuts import redirect 4 | from django.urls import reverse 5 | from rest_framework import status 6 | from rest_framework.views import exception_handler 7 | 8 | from codex.exceptions import SeeOtherRedirectError 9 | 10 | _OPDS_PREFIX = "opds/v" 11 | 12 | 13 | def codex_exception_handler(exc, context): 14 | """Assume OPDS clients want redirects instead of errors.""" 15 | response = None 16 | request = context.get("request") 17 | if _OPDS_PREFIX in request.path: 18 | name = "opds:v2:feed" if _OPDS_PREFIX + "2" in request.path else "opds:v1:feed" 19 | if isinstance(exc, SeeOtherRedirectError): 20 | response = exc.get_response(name) 21 | elif hasattr(exc, "status_code") and exc.status_code in ( 22 | status.HTTP_400_BAD_REQUEST, 23 | status.HTTP_404_NOT_FOUND, 24 | ): 25 | url = reverse(name) 26 | response = redirect(url, permanent=False) 27 | 28 | if not response: 29 | response = exception_handler(exc, context) 30 | 31 | return response 32 | -------------------------------------------------------------------------------- /codex/views/opds/__init__.py: -------------------------------------------------------------------------------- 1 | """OPDS Views Common to all versions.""" 2 | -------------------------------------------------------------------------------- /codex/views/opds/auth.py: -------------------------------------------------------------------------------- 1 | """OPDS Authentican mixin.""" 2 | 3 | from rest_framework.authentication import BasicAuthentication, SessionAuthentication 4 | 5 | from codex.views.auth import IsAuthenticatedOrEnabledNonUsers 6 | from codex.views.template import CodexXMLTemplateView 7 | 8 | 9 | class OPDSAuthMixin: 10 | """Add Basic Auth.""" 11 | 12 | authentication_classes = (BasicAuthentication, SessionAuthentication) 13 | permission_classes = (IsAuthenticatedOrEnabledNonUsers,) 14 | 15 | 16 | class OPDSTemplateView(OPDSAuthMixin, CodexXMLTemplateView): 17 | """XML Template view with OPDSAuth.""" 18 | -------------------------------------------------------------------------------- /codex/views/opds/binary.py: -------------------------------------------------------------------------------- 1 | """Binary views with Basic Authentication added.""" 2 | 3 | from codex.views.browser.cover import CoverView 4 | from codex.views.download import DownloadView 5 | from codex.views.opds.auth import OPDSAuthMixin 6 | from codex.views.reader.page import ReaderPageView 7 | 8 | 9 | class OPDSCoverView(OPDSAuthMixin, CoverView): 10 | """Cover View with Basic Auth.""" 11 | 12 | 13 | class OPDSDownloadView(OPDSAuthMixin, DownloadView): 14 | """Download View with Basic Auth.""" 15 | 16 | 17 | class OPDSPageView(OPDSAuthMixin, ReaderPageView): 18 | """Page View with Basic Auth.""" 19 | -------------------------------------------------------------------------------- /codex/views/opds/urls.py: -------------------------------------------------------------------------------- 1 | """OPDS URLs.""" 2 | 3 | from django.urls import reverse 4 | from rest_framework.response import Response 5 | 6 | from codex.choices.browser import DEFAULT_BROWSER_ROUTE 7 | from codex.serializers.opds.urls import OPDSURLsSerializer 8 | from codex.views.auth import AuthGenericAPIView 9 | from codex.views.util import pop_name 10 | 11 | _OPDS_VERSIONS = (1, 2) 12 | 13 | 14 | class OPDSURLsView(AuthGenericAPIView): 15 | """OPDS URLs.""" 16 | 17 | serializer_class = OPDSURLsSerializer 18 | 19 | def get(self, *args, **kwargs): 20 | """Resolve the urls.""" 21 | obj = {} 22 | route = DEFAULT_BROWSER_ROUTE 23 | route = pop_name(route) 24 | for version in _OPDS_VERSIONS: 25 | key = f"v{version}" 26 | name = f"opds:v{version}:feed" 27 | value = reverse(name, kwargs=route) 28 | obj[key] = value 29 | serializer = self.get_serializer(obj) 30 | return Response(serializer.data) 31 | -------------------------------------------------------------------------------- /codex/views/opds/v1/__init__.py: -------------------------------------------------------------------------------- 1 | """OPDS v1 Views.""" 2 | -------------------------------------------------------------------------------- /codex/views/opds/v1/data.py: -------------------------------------------------------------------------------- 1 | """OPDS v1 Data classes.""" 2 | 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | 6 | 7 | @dataclass 8 | class OPDS1Link: 9 | """An OPDS Link.""" 10 | 11 | rel: str 12 | href: str 13 | mime_type: str 14 | title: str = "" 15 | length: int = 0 16 | facet_group: str = "" 17 | facet_active: bool = False 18 | thr_count: int = 0 19 | pse_count: int = 0 20 | pse_last_read: int = 0 21 | pse_last_read_date: datetime | None = None 22 | -------------------------------------------------------------------------------- /codex/views/opds/v1/entry/__init__.py: -------------------------------------------------------------------------------- 1 | """OPDS v1 Entries.""" 2 | -------------------------------------------------------------------------------- /codex/views/opds/v1/entry/data.py: -------------------------------------------------------------------------------- 1 | """OPDS v1 Entry Data classes.""" 2 | 3 | from collections.abc import Mapping 4 | from dataclasses import dataclass 5 | 6 | 7 | @dataclass 8 | class OPDS1EntryObject: 9 | """Fake entry db object for top link & facet entries.""" 10 | 11 | group: str = "" 12 | ids: frozenset[int] = frozenset() 13 | name: str = "" 14 | summary: str = "" 15 | fake: bool = True 16 | 17 | 18 | @dataclass 19 | class OPDS1EntryData: 20 | """Entry Data class to avoid to many args.""" 21 | 22 | acquisition_groups: frozenset 23 | zero_pad: int 24 | metadata: bool 25 | mime_type_map: Mapping[str, str] 26 | -------------------------------------------------------------------------------- /codex/views/opds/v1/opensearch_v1.py: -------------------------------------------------------------------------------- 1 | """Serve an opensearch v1 document.""" 2 | 3 | from drf_spectacular.types import OpenApiTypes 4 | from drf_spectacular.utils import extend_schema 5 | from rest_framework.throttling import ScopedRateThrottle 6 | 7 | from codex.views.opds.auth import OPDSTemplateView 8 | 9 | 10 | @extend_schema(responses={("200", "application/xml"): OpenApiTypes.BYTE}) 11 | class OpenSearch1View(OPDSTemplateView): 12 | """OpenSearchView.""" 13 | 14 | template_name = "opds_v1/opensearch_v1.xml" 15 | content_type = "application/xml" 16 | throttle_classes = (ScopedRateThrottle,) 17 | throttle_scope = "opensearch" 18 | -------------------------------------------------------------------------------- /codex/views/opds/v2/__init__.py: -------------------------------------------------------------------------------- 1 | """OPDS v2 Views.""" 2 | -------------------------------------------------------------------------------- /codex/views/public.py: -------------------------------------------------------------------------------- 1 | """Public non-authenticated views.""" 2 | 3 | from rest_framework.generics import GenericAPIView 4 | from rest_framework.mixins import RetrieveModelMixin 5 | 6 | from codex.choices.admin import ADMIN_FLAG_CHOICES 7 | from codex.logger.logger import get_logger 8 | from codex.models import AdminFlag 9 | from codex.serializers.auth import AuthAdminFlagsSerializer 10 | 11 | LOG = get_logger(__name__) 12 | _ADMIN_FLAG_KEYS = frozenset( 13 | { 14 | AdminFlag.FlagChoices.NON_USERS.value, 15 | AdminFlag.FlagChoices.REGISTRATION.value, 16 | AdminFlag.FlagChoices.BANNER_TEXT.value, 17 | } 18 | ) 19 | 20 | 21 | class AdminFlagsView(GenericAPIView, RetrieveModelMixin): 22 | """Get admin flags relevant to auth.""" 23 | 24 | serializer_class = AuthAdminFlagsSerializer 25 | queryset = AdminFlag.objects.filter(key__in=_ADMIN_FLAG_KEYS).only( 26 | "key", "on", "value" 27 | ) 28 | 29 | def get_object(self): 30 | """Get admin flags.""" 31 | flags = {} 32 | for obj in self.get_queryset(): 33 | name = ADMIN_FLAG_CHOICES[obj.key].lower().replace(" ", "_") 34 | if obj.key == AdminFlag.FlagChoices.BANNER_TEXT.value: 35 | val = obj.value 36 | else: 37 | val = obj.on 38 | flags[name] = val 39 | return flags 40 | 41 | def get(self, *args, **kwargs): 42 | """Get admin flags relevant to auth.""" 43 | return self.retrieve(*args, **kwargs) 44 | -------------------------------------------------------------------------------- /codex/views/pwa.py: -------------------------------------------------------------------------------- 1 | """PWA views.""" 2 | 3 | from codex.views.template import CodexTemplateView 4 | 5 | 6 | class WebManifestView(CodexTemplateView): 7 | """Serve the webmanifest spec.""" 8 | 9 | template_name = "pwa/manifest.webmanifest" 10 | content_type = "application/manifest+json" 11 | 12 | 13 | class ServiceWorkerRegisterView(CodexTemplateView): 14 | """Serve the serviceworker register javascript.""" 15 | 16 | template_name = "pwa/serviceworker-register.js" 17 | content_type = "application/javascript" 18 | 19 | 20 | class ServiceWorkerView(CodexTemplateView): 21 | """Serve the serviceworker javascript.""" 22 | 23 | template_name = "pwa/serviceworker.js" 24 | content_type = "application/javascript" 25 | -------------------------------------------------------------------------------- /codex/views/reader/__init__.py: -------------------------------------------------------------------------------- 1 | """Reader views.""" 2 | -------------------------------------------------------------------------------- /codex/views/reader/settings.py: -------------------------------------------------------------------------------- 1 | """Reader session view.""" 2 | 3 | from codex.serializers.reader import ReaderSettingsSerializer 4 | from codex.views.settings import SettingsView 5 | 6 | 7 | class ReaderSettingsView(SettingsView): 8 | """Get Reader Settings.""" 9 | 10 | serializer_class = ReaderSettingsSerializer 11 | 12 | SESSION_KEY = "reader" 13 | -------------------------------------------------------------------------------- /codex/views/template.py: -------------------------------------------------------------------------------- 1 | """Generic Codex Template View.""" 2 | 3 | from rest_framework import status 4 | from rest_framework.renderers import TemplateHTMLRenderer 5 | from rest_framework.response import Response 6 | from rest_framework.views import APIView 7 | 8 | 9 | class TemplateXMLRenderer(TemplateHTMLRenderer): 10 | """Template rendeerer for xml.""" 11 | 12 | media_type = "text/xml" 13 | format = "xml" 14 | 15 | 16 | class CodexAPIView(APIView): 17 | """APIView with a simple getter and no data.""" 18 | 19 | content_type = "application/json" 20 | status_code = status.HTTP_200_OK 21 | 22 | def get(self, *args, **kwargs): 23 | """Render the template with correct content_type.""" 24 | return Response( 25 | data={}, status=self.status_code, content_type=self.content_type 26 | ) 27 | 28 | 29 | class CodexTemplateView(CodexAPIView): 30 | """HTML Template View.""" 31 | 32 | renderer_classes = (TemplateHTMLRenderer,) 33 | content_type = "text/html" 34 | 35 | 36 | class CodexXMLTemplateView(CodexAPIView): 37 | """XML Template View.""" 38 | 39 | renderer_classes = (TemplateXMLRenderer,) 40 | content_type = "application/xml" 41 | -------------------------------------------------------------------------------- /codex/views/version.py: -------------------------------------------------------------------------------- 1 | """Version View.""" 2 | 3 | from rest_framework.response import Response 4 | 5 | from codex.librarian.janitor.tasks import JanitorLatestVersionTask 6 | from codex.librarian.mp_queue import LIBRARIAN_QUEUE 7 | from codex.models import Timestamp 8 | from codex.serializers.versions import VersionsSerializer 9 | from codex.version import VERSION 10 | from codex.views.auth import AuthGenericAPIView 11 | 12 | 13 | class VersionView(AuthGenericAPIView): 14 | """Return Codex Versions.""" 15 | 16 | serializer_class = VersionsSerializer 17 | 18 | def get_object(self) -> dict[str, str]: 19 | """Get the versions.""" 20 | ts = Timestamp.objects.get(key=Timestamp.TimestampChoices.CODEX_VERSION.value) 21 | if ts.version: 22 | latest_version = ts.version 23 | else: 24 | LIBRARIAN_QUEUE.put(JanitorLatestVersionTask()) 25 | latest_version = "fetching..." 26 | return {"installed": VERSION, "latest": latest_version} 27 | 28 | def get(self, *args, **kwargs): 29 | """Get Versions.""" 30 | obj = self.get_object() 31 | serializer = self.get_serializer(obj) 32 | return Response(serializer.data) 33 | -------------------------------------------------------------------------------- /codex/websockets/README.md: -------------------------------------------------------------------------------- 1 | # Why Channels? 2 | 3 | I use only a fraction of django channels' functionality, but, 4 | `channels.auth.AuthMiddlewareStack`is nice. 5 | -------------------------------------------------------------------------------- /codex/websockets/__init__.py: -------------------------------------------------------------------------------- 1 | """Django Channels for Codex.""" 2 | -------------------------------------------------------------------------------- /codex/websockets/aio_queue.py: -------------------------------------------------------------------------------- 1 | """Global queue to send async queue messages to consumers from other processes.""" 2 | 3 | from aioprocessing import AioQueue 4 | 5 | BROADCAST_QUEUE = AioQueue() 6 | -------------------------------------------------------------------------------- /codex/worker_base.py: -------------------------------------------------------------------------------- 1 | """General worker class inherits queues.""" 2 | 3 | from codex.logger_base import LoggerBaseMixin 4 | from codex.status_controller import StatusController 5 | 6 | 7 | class WorkerBaseMixin(LoggerBaseMixin): 8 | """General worker class for inheriting queues.""" 9 | 10 | def init_worker(self, log_queue, librarian_queue): 11 | """Initialize queues.""" 12 | self.init_logger(log_queue) 13 | self.librarian_queue = librarian_queue 14 | self.status_controller = StatusController(log_queue, librarian_queue) 15 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG CODEX_BUILDER_BASE_VERSION 2 | ARG CODEX_BASE_VERSION 3 | FROM ajslater/codex-builder-base:${CODEX_BUILDER_BASE_VERSION} AS codex-built 4 | ARG CODEX_WHEEL 5 | LABEL maintainer="AJ Slater " 6 | WORKDIR /app 7 | 8 | # Install codex 9 | COPY ./dist/$CODEX_WHEEL ./dist/$CODEX_WHEEL 10 | 11 | # hadolint ignore=DL3059,DL3013 12 | RUN PYMUPDF_SETUP_PY_LIMITED_API=0 pip3 install --no-cache-dir ./dist/$CODEX_WHEEL 13 | 14 | FROM ajslater/codex-base:${CODEX_BASE_VERSION} 15 | ARG PKG_VERSION 16 | LABEL maintainer="AJ Slater " 17 | LABEL version=$PKG_VERSION 18 | 19 | # Create the comics directory 20 | RUN mkdir -p /comics && touch /comics/DOCKER_UNMOUNTED_VOLUME 21 | 22 | # The final image is the mininimal base with /usr/local copied. 23 | # Possibly could optimize this further to only get python and bin 24 | COPY --from=codex-built /usr/local /usr/local 25 | 26 | VOLUME /comics 27 | VOLUME /config 28 | EXPOSE 9810 29 | CMD ["/usr/local/bin/codex"] 30 | -------------------------------------------------------------------------------- /docker/base.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ajslater/python-debian:3.13.0-slim-bookworm_0 2 | ARG CODEX_BASE_VERSION 3 | LABEL maintainer="AJ Slater " 4 | LABEL version=$CODEX_BASE_VERSION 5 | 6 | COPY docker/debian.sources /etc/apt/sources.list.d/ 7 | # hadolint ignore=DL3008 8 | RUN apt-get clean \ 9 | && apt-get update \ 10 | && apt-get install --no-install-recommends -y \ 11 | libimagequant0 \ 12 | libjpeg62-turbo \ 13 | libopenjp2-7 \ 14 | libssl3 \ 15 | libyaml-0-2 \ 16 | libtiff6 \ 17 | libwebp7 \ 18 | ruamel.yaml.clib \ 19 | unrar \ 20 | zlib1g \ 21 | && apt-get clean \ 22 | && rm -rf /var/lib/apt/lists/* 23 | -------------------------------------------------------------------------------- /docker/builder-base.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nikolaik/python-nodejs:python3.13-nodejs23 2 | ARG CODEX_BUILDER_BASE_VERSION 3 | LABEL maintainer="AJ Slater " 4 | LABEL version=${CODEX_BUILDER_BASE_VERSION} 5 | 6 | # hadolint ignore=DL4006 7 | RUN curl -fsSL https://deb.nodesource.com/setup_23.x | bash -o pipefail -s -- 8 | 9 | # **** install codex system build dependency packages ****" 10 | COPY docker/debian.sources /etc/apt/sources.list.d/ 11 | # hadolint ignore=DL3008 12 | RUN apt-get clean \ 13 | && apt-get update \ 14 | && apt-get install --no-install-recommends -y \ 15 | libimagequant0 \ 16 | libjpeg62-turbo \ 17 | libopenjp2-7 \ 18 | libssl3 \ 19 | libyaml-0-2 \ 20 | libtiff6 \ 21 | libwebp7 \ 22 | ruamel.yaml.clib \ 23 | unrar \ 24 | zlib1g \ 25 | bash \ 26 | build-essential \ 27 | git \ 28 | python3-dev \ 29 | && apt-get clean \ 30 | && rm -rf /var/lib/apt/lists/* 31 | 32 | WORKDIR /app 33 | 34 | COPY builder-requirements.txt ./ 35 | # hadolint ignore=DL3013,DL3042 36 | RUN pip3 install --no-cache --upgrade pip \ 37 | && pip3 install --no-cache --requirement builder-requirements.txt 38 | -------------------------------------------------------------------------------- /docker/debian.sources: -------------------------------------------------------------------------------- 1 | Types: deb 2 | URIs: http://deb.debian.org/debian 3 | Suites: bookworm bookworm-updates 4 | Components: main contrib non-free 5 | Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg 6 | 7 | Types: deb 8 | URIs: http://deb.debian.org/debian-security 9 | Suites: bookworm-security 10 | Components: main contrib non-free 11 | Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg 12 | -------------------------------------------------------------------------------- /docker/dev.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ajslater/codex-builder-base:bf76176cecc7837ce0d34351087e1089-aarch64 2 | LABEL maintainer="AJ Slater " 3 | LABEL version=dev 4 | 5 | # hadolint ignore=DL3008 6 | RUN apt-get clean \ 7 | && apt-get update \ 8 | && apt-get install --no-install-recommends -y \ 9 | htop \ 10 | neovim \ 11 | && apt-get clean \ 12 | && rm -rf /var/lib/apt/lists/* 13 | 14 | # hadolint ignore=DL3059,DL3013 15 | RUN pip3 install --upgrade --no-cache-dir \ 16 | pip 17 | 18 | WORKDIR /app 19 | RUN poetry config virtualenvs.in-project false 20 | COPY pyproject.toml . 21 | RUN poetry update 22 | -------------------------------------------------------------------------------- /docker/dist-builder.Dockerfile: -------------------------------------------------------------------------------- 1 | ARG CODEX_BUILDER_BASE_VERSION 2 | FROM ajslater/codex-builder-base:${CODEX_BUILDER_BASE_VERSION} 3 | ARG CODEX_DIST_BUILDER_VERSION 4 | LABEL maintainer="AJ Slater " 5 | LABEL version $CODEX_DIST_BUILDER_VERSION 6 | 7 | # hadolint ignore=DL3008 8 | RUN apt-get clean \ 9 | && apt-get update \ 10 | && apt-get install --no-install-recommends -y \ 11 | shellcheck \ 12 | && apt-get clean \ 13 | && rm -rf /var/lib/apt/lists/* 14 | 15 | WORKDIR /app 16 | # **** install python app dependencies **** 17 | # hadolint ignore=DL3022 18 | COPY pyproject.toml poetry.lock ./ 19 | RUN PIP_CACHE_DIR=$(pip3 cache dir) PYMUPDF_SETUP_PY_LIMITED_API=0 poetry sync --no-root --without dev 20 | 21 | # *** install node lint & test dependency packages *** 22 | COPY package.json package-lock.json ./ 23 | RUN npm install 24 | 25 | # **** install npm app dependencies **** 26 | WORKDIR /app/frontend 27 | COPY frontend/package.json frontend/package-lock.json ./ 28 | RUN npm install 29 | 30 | WORKDIR /app 31 | # **** copying source for dev build **** 32 | COPY . . 33 | 34 | VOLUME /app/codex/static_build 35 | VOLUME /app/codex/static_root 36 | VOLUME /app/dist 37 | VOLUME /app/test-results 38 | VOLUME /app/frontend/src/choices 39 | -------------------------------------------------------------------------------- /docker/docker-arch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # get the target arch for the platform 3 | if [[ ${PLATFORMS-} == "linux/armhf" ]]; then 4 | ARCH=aarch32 5 | else 6 | ARCH=$(uname -m) 7 | fi 8 | echo "$ARCH" 9 | -------------------------------------------------------------------------------- /docker/docker-compose-exit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run a docker compose service and return its exit code 3 | set -euo pipefail 4 | ENV_FN=$(./docker/docker-env-filename.sh) 5 | # shellcheck disable=SC1090 6 | source "$ENV_FN" 7 | export DOCKER_CLI_EXPERIMENTAL=enabled 8 | export DOCKER_BUILDKIT=1 9 | export CODEX_BUILDER_BASE_VERSION 10 | export CODEX_DIST_BUILDER_VERSION 11 | export CODEX_WHEEL 12 | export PKG_VERSION 13 | # docker compose without the dash doesn't have the exit-code-from param 14 | docker-compose up --exit-code-from "$1" "$1" 15 | -------------------------------------------------------------------------------- /docker/docker-create-multiarch-codex.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Combine arch specific images into a multiarch image 3 | set -euxo pipefail 4 | REPO=docker.io/ajslater/codex 5 | # Don't use arch-repo anymore 6 | #ARCH_REPO=docker.io/ajslater/codex-arch 7 | ARCH_REPO=$REPO 8 | ARCHES=(x86_64 aarch64) # aarch32) 9 | 10 | pip3 install --upgrade pip 11 | pip3 install --requirement builder-requirements.txt 12 | PKG_VERSION=$(./bin/version.sh) 13 | VERSION_TAG=$REPO:$PKG_VERSION 14 | echo "Creating $VERSION_TAG" 15 | AMEND_TAGS=() 16 | RM_TAGS=() 17 | for arch in "${ARCHES[@]}"; do 18 | TAG="${PKG_VERSION}-${arch}" 19 | IMAGE="$ARCH_REPO:${TAG}" 20 | AMEND_TAGS+=("--amend" "$IMAGE") 21 | RM_TAGS+=("$TAG") 22 | done 23 | 24 | #CREATE_VERSION_ARGS=("$VERSION_TAG" "${AMEND_TAGS[@]}") 25 | #docker manifest create "${CREATE_VERSION_ARGS[@]}" 26 | #docker manifest push "$VERSION_TAG" 27 | #echo "Created tag: ${VERSION_TAG}." 28 | 29 | ./docker/fix-manifest-deploy-to-docker-hub.sh "$VERSION_TAG" latest 30 | 31 | # cleanup main repo 32 | ./docker/docker-hub-remove-tags.sh "${RM_TAGS[@]}" 33 | echo "Cleaned up intermediary arch tags." 34 | 35 | if [[ $PKG_VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 36 | # If the version is just numbers push it as latest 37 | ./docker/docker-tag-remote-version-as-latest.sh "$PKG_VERSION" 38 | echo "Created codex:${PKG_VERSION}" 39 | fi 40 | -------------------------------------------------------------------------------- /docker/docker-env-filename.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # echo the arch specific .env filename 3 | set -euo pipefail 4 | ENV_FN=./.env 5 | if [ "${CIRCLECI-}" ]; then 6 | ARCH=$(./docker/docker-arch.sh) 7 | ENV_FN=${ENV_FN}-${ARCH} 8 | fi 9 | echo "$ENV_FN" 10 | -------------------------------------------------------------------------------- /docker/docker-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # create the docker .env firl for this architecture 3 | set -euo pipefail 4 | pip3 install --upgrade pip 5 | pip3 install --requirement builder-requirements.txt 6 | PKG_VERSION=$(./bin/version.sh) 7 | ENV_FN=$(./docker/docker-env-filename.sh) 8 | rm -f "$ENV_FN" 9 | # shellcheck disable=SC1091,SC2129 10 | cat << EOF >> "$ENV_FN" 11 | PKG_VERSION=${PKG_VERSION} 12 | CODEX_BASE_VERSION=$(./docker/docker-version-codex-base.sh) 13 | EOF 14 | echo "CODEX_BUILDER_BASE_VERSION=$(./docker/docker-version-codex-builder-base.sh)" >> "$ENV_FN" 15 | echo "CODEX_DIST_BUILDER_VERSION=$(./docker/docker-version-codex-dist-builder.sh)" >> "$ENV_FN" 16 | cat << EOF >> "$ENV_FN" 17 | CODEX_ARCH_VERSION=$(./docker/docker-version-codex-arch.sh) 18 | CODEX_WHEEL=codex-${PKG_VERSION}-py3-none-any.whl 19 | EOF 20 | -------------------------------------------------------------------------------- /docker/docker-hub-remove-tags.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Use docker hub API to login & delete tags 3 | # Because docker/hub-tool won't do non-interactive login yet. 4 | set -euo pipefail 5 | 6 | ORGANIZATION="ajslater" 7 | IMAGE="codex" 8 | TAGS=("$@") 9 | 10 | login_data() { 11 | cat << EOF 12 | { 13 | "username": "$DOCKER_USER", 14 | "password": "$DOCKER_PASS" 15 | } 16 | EOF 17 | } 18 | 19 | TOKEN=$(curl -s -H "Content-Type: application/json" -X POST -d "$(login_data)" "https://hub.docker.com/v2/users/login/" | jq -r .token) 20 | 21 | URL_HEAD="https://hub.docker.com/v2/repositories/${ORGANIZATION}/${IMAGE}/tags" 22 | for tag in "${TAGS[@]}"; do 23 | curl "$URL_HEAD/${tag}/" \ 24 | -X DELETE \ 25 | -H "Authorization: JWT ${TOKEN}" 26 | done 27 | -------------------------------------------------------------------------------- /docker/docker-init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # initialize docker builder with correct emulators for this arch 3 | set -euo pipefail 4 | ./bin/circleci/circleci-step-halt.sh 5 | export DOCKER_CLI_EXPERIMENTAL=enabled 6 | export DOCKER_BUILDKIT=1 7 | # login to docker using environment variables 8 | echo "$DOCKER_PASS" | docker login --username="$DOCKER_USER" --password-stdin 9 | # install emulator binaries if i need to 10 | EMULATORS= 11 | if [[ ${PLATFORMS-} == "linux/armhf" ]]; then 12 | # this is the only arch i need to cross compile on circleci 13 | EMULATORS=arm 14 | elif [[ "$(uname -m)" == "aarch64" ]]; then 15 | EMULATORS=aarch64 16 | fi 17 | if [[ -n ${EMULATORS-} ]]; then 18 | docker run --rm --privileged tonistiigi/binfmt:latest --install "$EMULATORS" 19 | fi 20 | # buildx requires creating a builder on a fresh system 21 | docker buildx create --use 22 | -------------------------------------------------------------------------------- /docker/docker-install-api-deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # install dependencies for hitting the hub.docker.com api directly 3 | set -euxo pipefail 4 | 5 | sudo apt-get update 6 | sudo apt-get install curl jq 7 | -------------------------------------------------------------------------------- /docker/docker-install-hub-tool.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Install docker hub-tool 3 | set -euxo pipefail 4 | 5 | VERSION=0.4.5 6 | URL="https://github.com/docker/hub-tool/releases/download/v${VERSION}/hub-tool-linux-amd64.tar.gz" 7 | DEST="$HOME" 8 | 9 | wget -c "$URL" -O - | tar xz -C "$DEST" --strip-components 1 hub-tool/hub-tool 10 | chmod 755 "$DEST"/hub-tool 11 | -------------------------------------------------------------------------------- /docker/docker-login.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # login to docker using environment variables 3 | set -euo pipefail 4 | echo "$DOCKER_PASS" | docker login --username="$DOCKER_USER" --password-stdin 5 | -------------------------------------------------------------------------------- /docker/docker-promote-latest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # push a latest tag from an-arch repo version 3 | set -euo pipefail 4 | REPO=docker.io/ajslater/codex 5 | ARCH_REPO=docker.io/ajslater/codex-arch 6 | ARCHES=(x86_64 aarch64) # aarch32) 7 | 8 | PKG_VERSION=$1 9 | AMEND_TAGS=() 10 | for arch in "${ARCHES[@]}"; do 11 | AMEND_TAGS+=("--amend" "$ARCH_REPO:${PKG_VERSION}-${arch}") 12 | done 13 | 14 | if [[ $PKG_VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]$ ]]; then 15 | # If the version is just numbers push it as latest 16 | LATEST_TAG="$REPO:latest" 17 | echo "Creating $LATEST_TAG." 18 | CREATE_LATEST_ARGS=("$LATEST_TAG" "${AMEND_TAGS[@]}") 19 | docker manifest create "${CREATE_LATEST_ARGS[@]}" 20 | docker manifest push "$LATEST_TAG" 21 | else 22 | echo "Not a valid latest version" 23 | fi 24 | -------------------------------------------------------------------------------- /docker/docker-tag-remote-version-as-latest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Tag a remote version as latest 3 | set -euo pipefail 4 | export DOCKER_CLI_EXPERIMENTAL=enabled 5 | export DOCKER_BUILDKIT=1 6 | REPO=docker.io/ajslater/codex 7 | VERSION=$1 8 | 9 | docker buildx imagetools create "$REPO:$VERSION" --tag "$REPO:latest" 10 | -------------------------------------------------------------------------------- /docker/docker-version-checksum.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # create an arched md5sum from a list of parts 3 | set -euo pipefail 4 | # This script must be sourced to pass these arrays in properly 5 | # Params: 6 | # EXTRA_MD5S double space separated array of md5s and labels 7 | # DEPS array of dependencies 8 | DEPS_MD5=$(md5sum "${DEPS[@]}") 9 | ALL_MD5S=("${EXTRA_MD5S[@]}" "${DEPS_MD5[@]}") 10 | VERSION=$( 11 | echo "${ALL_MD5S[@]}" \ 12 | | LC_ALL=C sort -k 2 \ 13 | | md5sum \ 14 | | awk '{print $1}' 15 | ) 16 | if [[ ${CIRCLECI-} ]]; then 17 | ARCH=$(./docker/docker-arch.sh) 18 | VERSION="${VERSION}-$ARCH" 19 | fi 20 | echo "$VERSION" 21 | -------------------------------------------------------------------------------- /docker/docker-version-codex-arch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Get the final runnable codex image version 3 | set -euo pipefail 4 | VERSION=$(./bin/version.sh) 5 | if [ "${CIRCLECI-}" ]; then 6 | ARCH=$(./docker/docker-arch.sh) 7 | VERSION=${VERSION}-${ARCH} 8 | fi 9 | echo "$VERSION" 10 | -------------------------------------------------------------------------------- /docker/docker-version-codex-base.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Compute the version tag for ajslater/codex-base 3 | set -euo pipefail 4 | EXTRA_MD5S=("x x") 5 | 6 | DEPS=( 7 | "$0" 8 | .dockerignore 9 | docker/base.Dockerfile 10 | docker/docker-arch.sh 11 | docker/docker-build-image.sh 12 | docker/docker-env.sh 13 | docker/docker-env-filename.sh 14 | docker/docker-init.sh 15 | docker/docker-version-checksum.sh 16 | docker/docker-version-codex-arch.sh 17 | docker-compose.yaml 18 | Makefile 19 | ) 20 | 21 | source ./docker/docker-version-checksum.sh 22 | -------------------------------------------------------------------------------- /docker/docker-version-codex-builder-base.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Compute the version tag for ajslater/codex-builder-base 3 | set -euo pipefail 4 | 5 | ENV_FN=$(./docker/docker-env-filename.sh) 6 | # shellcheck disable=SC1090 7 | source "$ENV_FN" 8 | EXTRA_MD5S=("$CODEX_BASE_VERSION codex-base-version") 9 | DEPS=( 10 | "$0" 11 | .dockerignore 12 | docker/builder-base.Dockerfile 13 | builder-requirements.txt 14 | ) 15 | 16 | source ./docker/docker-version-checksum.sh 17 | -------------------------------------------------------------------------------- /docker/docker-version-codex-dist-builder.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Compute the version tag for ajslater/codex-dist-builder 3 | set -euo pipefail 4 | 5 | ENV_FN=$(./docker/docker-env-filename.sh) 6 | # shellcheck disable=SC1090 7 | source "$ENV_FN" 8 | EXTRA_MD5S=("$CODEX_BUILDER_BASE_VERSION codex-builder-base-version") 9 | 10 | # shellcheck disable=SC2046 11 | readarray -d '' SOURCE_DEPS < <(find codex frontend -type f \( \ 12 | ! -path "*node_modules*" \ 13 | ! -path "*codex/static_build*" \ 14 | ! -path "*codex/static_root*" \ 15 | ! -name "*~" \ 16 | ! -name "*.pyc" \ 17 | ! -name ".eslintcache" \ 18 | ! -name ".DS_Store" \ 19 | -print0 \ 20 | \)) 21 | DEPS=( 22 | "$0" 23 | .dockerignore 24 | .prettierignore 25 | .shellcheckrc 26 | docker/dist-builder.Dockerfile 27 | eslint.config.js 28 | bin/build-dist.sh 29 | bin/collectstatic.sh 30 | bin/lint-backend.sh 31 | bin/manage.py 32 | bin/pm 33 | bin/test-backend.sh 34 | package.json 35 | package-lock.json 36 | pyproject.toml 37 | poetry.lock 38 | Makefile 39 | "${SOURCE_DEPS[@]}" 40 | ) 41 | 42 | source ./docker/docker-version-checksum.sh 43 | -------------------------------------------------------------------------------- /docker/nginx/README.md: -------------------------------------------------------------------------------- 1 | # nginx Reverse Proxy 2 | 3 | Docker configuration for an nginx reverse proxy to use for testing. 4 | -------------------------------------------------------------------------------- /docker/nginx/http.d/codex/test.conf: -------------------------------------------------------------------------------- 1 | upstream host_service { 2 | server host.docker.internal:9810; 3 | } 4 | 5 | server { 6 | listen 9811; 7 | charset utf-8; 8 | add_header X-Frame-Options SAMEORIGIN; 9 | access_log /dev/stdout; 10 | error_log /dev/stderr; 11 | # proxies 12 | # Docs for using variables to force name re-resolution when upstream containers are re-created. 13 | # https://tenzer.dk/nginx-with-dynamic-upstreams/ 14 | # proxy_buffering off; 15 | # proxy_buffers 8 64k; 16 | proxy_set_header Host $http_host; 17 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 18 | proxy_set_header X-Forwarded-Host $server_name; 19 | proxy_set_header X-Forwarded-Port $server_port; 20 | proxy_set_header X-Forwarded-Proto $scheme; 21 | # proxy_set_header X-Forwarded-Ssl on; 22 | proxy_set_header X-Real-IP $remote_addr; 23 | proxy_set_header X-Scheme $scheme; 24 | # WS 25 | proxy_http_version 1.1; 26 | proxy_set_header Upgrade $http_upgrade; 27 | proxy_set_header Connection "Upgrade"; 28 | gzip_comp_level 6; 29 | gzip_proxied any; 30 | gzip_types text/plain text/css text/js text/xml text/javascript 31 | application/javascript application/json application/xml image/svg+xml; 32 | set $codex_upstream http://host_service; 33 | 34 | location /codex { 35 | proxy_pass $codex_upstream; 36 | } 37 | } -------------------------------------------------------------------------------- /docker/registry.yaml: -------------------------------------------------------------------------------- 1 | name: registry 2 | services: 3 | registry: 4 | image: registry 5 | container_name: registry 6 | ports: 7 | - "80:5000" 8 | - "443:5000" 9 | -------------------------------------------------------------------------------- /frontend/.npmrc: -------------------------------------------------------------------------------- 1 | node-options=--trace-warnings 2 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | components.d.ts 3 | dist 4 | public 5 | node_modules 6 | webpack-stats.json 7 | -------------------------------------------------------------------------------- /frontend/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install 2 | ## Upgrade pip and poetry 3 | ## @category Install 4 | install: 5 | npm install 6 | 7 | .PHONY: build 8 | ## Build package 9 | ## @category build 10 | build: clean 11 | npm run build 12 | 13 | .PHONY: clean 14 | ## Remove static_build contents 15 | ## @category build 16 | clean: 17 | rm -rf ../codex/static_build/* 18 | 19 | .PHONY: fix 20 | ## Fix only frontend lint errors 21 | ## @category Lint 22 | fix: 23 | ./bin/fix-lint.sh 24 | 25 | .PHONY: lint 26 | ## Lint front and back end 27 | ## @category Lint 28 | lint: 29 | ./bin/lint.sh 30 | 31 | .PHONY: test 32 | ## Run All Tests 33 | ## @category Test 34 | test: 35 | ./bin/test.sh 36 | 37 | .PHONY: all 38 | 39 | .PHONY: dev-server 40 | ## Run Dev Frontend Server 41 | ## @category Run 42 | dev-server: 43 | ./bin/dev-server.sh 44 | 45 | include ../bin/makefile-help.mk 46 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Codex Frontend 2 | 3 | The codex frontend runs on [VueJS](https://vuejs.org/) and 4 | [Vuetify](https://vuetifyjs.com). 5 | 6 | ## Development 7 | 8 | See the package.json file for common development scripts. Running the live 9 | reloading dev server is your best bet. 10 | 11 | ## Production 12 | 13 | The Django collectstatic script packages the vite rolled up modules from 14 | `codex/static_build/` and packages them with the main server app in 15 | `codex/static_root/`. 16 | -------------------------------------------------------------------------------- /frontend/bin/dev-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Run the live reloading front end development server 3 | THIS_DIR="$(dirname "$0")" 4 | cd "$THIS_DIR" || exit 1 5 | npm run dev 6 | -------------------------------------------------------------------------------- /frontend/bin/fix-lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Fix lints frontend 3 | set -euo pipefail 4 | npm run fix 5 | -------------------------------------------------------------------------------- /frontend/bin/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # lint the frontend 3 | set -euo pipefail 4 | 5 | npm run lint 6 | npx prettier --check . 7 | -------------------------------------------------------------------------------- /frontend/bin/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run all frontend tests 3 | set -euxo pipefail 4 | 5 | cd "$(dirname "$0")" 6 | 7 | npm run test:ci 8 | -------------------------------------------------------------------------------- /frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules", "**/node_modules"] 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/admin.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 38 | -------------------------------------------------------------------------------- /frontend/src/api/v3/auth.js: -------------------------------------------------------------------------------- 1 | import { serializeParams } from "@/api/v3/common"; 2 | 3 | import { HTTP } from "./base"; 4 | 5 | const getAdminFlags = async () => { 6 | return await HTTP.get("/auth/flags/"); 7 | }; 8 | 9 | const get_tz = () => new Intl.DateTimeFormat().resolvedOptions().timeZone; 10 | 11 | const updateTimezone = async () => { 12 | const data = { 13 | timezone: get_tz(), 14 | }; 15 | return await HTTP.put("/auth/timezone/", data); 16 | }; 17 | 18 | const register = async (credentials) => { 19 | credentials.login = credentials.username; 20 | return await HTTP.post("/auth/register/", credentials); 21 | }; 22 | 23 | const login = async (credentials) => { 24 | credentials.login = credentials.username; 25 | return await HTTP.post("/auth/login/", credentials); 26 | }; 27 | 28 | const getProfile = async () => { 29 | const params = serializeParams(); 30 | return await HTTP.get("/auth/profile/", { params }); 31 | }; 32 | 33 | const logout = async () => { 34 | return await HTTP.post("/auth/logout/"); 35 | }; 36 | 37 | const updatePassword = async (credentials) => { 38 | return await HTTP.post("/auth/change-password/", credentials); 39 | }; 40 | 41 | export default { 42 | updatePassword, 43 | getAdminFlags, 44 | getProfile, 45 | updateTimezone, 46 | login, 47 | logout, 48 | register, 49 | }; 50 | -------------------------------------------------------------------------------- /frontend/src/api/v3/base.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | const CONFIG = { 3 | baseURL: globalThis.CODEX.API_V3_PATH, 4 | withCredentials: true, 5 | xsrfCookieName: "csrftoken", 6 | xsrfHeaderName: "X-CSRFTOKEN", 7 | }; 8 | export const HTTP = axios.create(CONFIG); 9 | -------------------------------------------------------------------------------- /frontend/src/api/v3/notify.js: -------------------------------------------------------------------------------- 1 | // Notifications and websockets 2 | const getSocketURL = () => { 3 | let socketProto = "ws"; 4 | if (globalThis.location.protocol === "https:") { 5 | socketProto += "s"; 6 | } 7 | return `${socketProto}://${location.host}${globalThis.CODEX.API_V3_PATH}ws`; 8 | }; 9 | export const SOCKET_URL = getSocketURL(); // This MUST export itself 10 | 11 | export default { 12 | SOCKET_URL, 13 | }; 14 | -------------------------------------------------------------------------------- /frontend/src/components/admin/browser-link.vue: -------------------------------------------------------------------------------- 1 | 4 | 24 | -------------------------------------------------------------------------------- /frontend/src/components/admin/create-update-dialog/create-update-button.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 38 | -------------------------------------------------------------------------------- /frontend/src/components/admin/create-update-dialog/relation-picker.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 47 | -------------------------------------------------------------------------------- /frontend/src/components/admin/create-update-dialog/time-text-field.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 29 | -------------------------------------------------------------------------------- /frontend/src/components/admin/drawer/admin-settings-drawer.vue: -------------------------------------------------------------------------------- 1 | 8 | 43 | -------------------------------------------------------------------------------- /frontend/src/components/admin/drawer/admin-settings-panel.vue: -------------------------------------------------------------------------------- 1 | 4 | 14 | -------------------------------------------------------------------------------- /frontend/src/components/admin/group-chip.vue: -------------------------------------------------------------------------------- 1 | 4 | 45 | 54 | -------------------------------------------------------------------------------- /frontend/src/components/admin/tabs/admin-table.vue: -------------------------------------------------------------------------------- 1 | 12 | 17 | 26 | -------------------------------------------------------------------------------- /frontend/src/components/admin/tabs/datetime-column.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 44 | 45 | 50 | -------------------------------------------------------------------------------- /frontend/src/components/admin/tabs/delete-row-dialog.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 56 | -------------------------------------------------------------------------------- /frontend/src/components/admin/tabs/relation-chips.vue: -------------------------------------------------------------------------------- 1 | 12 | 48 | -------------------------------------------------------------------------------- /frontend/src/components/anchors.scss: -------------------------------------------------------------------------------- 1 | a, 2 | a > .v-icon { 3 | color: rgb(var(--v-theme-primary)) !important; 4 | } 5 | /* 6 | a:visited, 7 | a:visited > .v-icon { 8 | color: rgb(var(--v-theme-secondary)) !important; 9 | } 10 | */ 11 | a:hover, 12 | a:hover > .v-icon { 13 | color: rgb(var(--v-theme-textPrimary)) !important; 14 | } 15 | a.v-btn > .v-icon { 16 | color: rgb(var(--v-theme-textPrimary)); 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/components/auth/auth-menu.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 53 | -------------------------------------------------------------------------------- /frontend/src/components/banner.vue: -------------------------------------------------------------------------------- 1 | 11 | 25 | 36 | -------------------------------------------------------------------------------- /frontend/src/components/book-cover.scss: -------------------------------------------------------------------------------- 1 | @use "sass:math"; 2 | $cover-ratio: 1.5372233400402415; /* Modal cover ratio */ 3 | $cover-width: 165px; 4 | $cover-height: math.round(calc($cover-ratio * $cover-width)); 5 | $small-cover-width: 100px; 6 | $small-cover-height: math.round(calc($cover-ratio * $small-cover-width)); 7 | -------------------------------------------------------------------------------- /frontend/src/components/browser/drawer/browser-settings-drawer.vue: -------------------------------------------------------------------------------- 1 | 8 | 20 | -------------------------------------------------------------------------------- /frontend/src/components/browser/toolbars/breadcrumbs/browser-toolbar-breadcrumbs.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | 18 | 25 | -------------------------------------------------------------------------------- /frontend/src/components/browser/toolbars/nav/browser-toolbar-nav.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 43 | -------------------------------------------------------------------------------- /frontend/src/components/browser/toolbars/search/browser-toolbar-search.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 33 | 34 | 39 | -------------------------------------------------------------------------------- /frontend/src/components/browser/toolbars/top/search-button.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 40 | -------------------------------------------------------------------------------- /frontend/src/components/browser/toolbars/top/toolbar-button.vue: -------------------------------------------------------------------------------- 1 | 8 | 17 | -------------------------------------------------------------------------------- /frontend/src/components/cancel-button.vue: -------------------------------------------------------------------------------- 1 | 4 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/src/components/close-button.vue: -------------------------------------------------------------------------------- 1 | 4 | 15 | 16 | 21 | -------------------------------------------------------------------------------- /frontend/src/components/codex-list-item.vue: -------------------------------------------------------------------------------- 1 | 4 | 9 | 14 | -------------------------------------------------------------------------------- /frontend/src/components/confirm-footer.vue: -------------------------------------------------------------------------------- 1 | 10 | 20 | 21 | 29 | -------------------------------------------------------------------------------- /frontend/src/components/empty.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 28 | -------------------------------------------------------------------------------- /frontend/src/components/metadata/expand-button.vue: -------------------------------------------------------------------------------- 1 | 4 | 9 | -------------------------------------------------------------------------------- /frontend/src/components/metadata/metadata-tags.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 48 | 49 | 64 | -------------------------------------------------------------------------------- /frontend/src/components/metadata/table.scss: -------------------------------------------------------------------------------- 1 | td { 2 | border-bottom: none !important; 3 | } 4 | td.key { 5 | color: rgb(var(--v-theme-textSecondary)); 6 | width: 1%; 7 | white-space: nowrap; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/components/metadata/tags-table.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 35 | 46 | -------------------------------------------------------------------------------- /frontend/src/components/pagination-nav-button.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 33 | 34 | 42 | -------------------------------------------------------------------------------- /frontend/src/components/pagination-slider.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 28 | 29 | 55 | -------------------------------------------------------------------------------- /frontend/src/components/pagination-toolbar.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | 19 | 27 | -------------------------------------------------------------------------------- /frontend/src/components/placeholder-loading.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 21 | 22 | 28 | -------------------------------------------------------------------------------- /frontend/src/components/reader/change-column.scss: -------------------------------------------------------------------------------- 1 | .changeColumn { 2 | position: fixed; 3 | top: 48px; 4 | height: calc(100vh - 96px); 5 | width: 33vw; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/components/reader/drawer/reader-settings-drawer.vue: -------------------------------------------------------------------------------- 1 | 8 | 20 | -------------------------------------------------------------------------------- /frontend/src/components/reader/drawer/reader-settings-super-panel.vue: -------------------------------------------------------------------------------- 1 | 8 | 22 | -------------------------------------------------------------------------------- /frontend/src/components/reader/empty.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 39 | -------------------------------------------------------------------------------- /frontend/src/components/reader/pager/page/page-error.vue: -------------------------------------------------------------------------------- 1 | 11 | 45 | 59 | -------------------------------------------------------------------------------- /frontend/src/components/reader/pager/page/page-loading.vue: -------------------------------------------------------------------------------- 1 | 6 | 17 | 18 | 34 | -------------------------------------------------------------------------------- /frontend/src/components/reader/pager/pager-full-pdf.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 37 | -------------------------------------------------------------------------------- /frontend/src/components/scale-button.vue: -------------------------------------------------------------------------------- 1 | 8 | 27 | -------------------------------------------------------------------------------- /frontend/src/components/settings/repo-footer.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 30 | 31 | 47 | -------------------------------------------------------------------------------- /frontend/src/copy-to-clipboard.js: -------------------------------------------------------------------------------- 1 | export const copyToClipboard = function (text, showTooltip) { 2 | navigator.clipboard 3 | .writeText(text) 4 | .then(() => { 5 | showTooltip.show = true; 6 | setTimeout(() => { 7 | showTooltip.show = false; 8 | }, 5000); 9 | return true; 10 | }) 11 | .catch(console.warn); 12 | }; 13 | 14 | export default { copyToClipboard }; 15 | -------------------------------------------------------------------------------- /frontend/src/datetime.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Date & time formats 3 | * Date is forced to YYYY-MM-DD with sv-SE 4 | * Time is by default undefined and browser based but can be forced to sv-SE 24 HR. 5 | * XXX Force to 24 hr is probably superfluous at this point 6 | * const TWELVE_HOUR_LOCALE = "en-NZ"; 7 | */ 8 | const TWENTY_FOUR_HOUR_LOCALE = "sv-SE"; 9 | export const DATE_FORMAT = new Intl.DateTimeFormat(TWENTY_FOUR_HOUR_LOCALE); 10 | export const NUMBER_FORMAT = new Intl.NumberFormat(); 11 | export const getTimeFormat = function (twentyFourHourTime) { 12 | const locale = twentyFourHourTime ? TWENTY_FOUR_HOUR_LOCALE : undefined; 13 | return new Intl.DateTimeFormat(locale, { 14 | timeStyle: "medium", 15 | }); 16 | }; 17 | 18 | export const getDateTime = function (dttm, twentyFourHourTime, br = false) { 19 | const date = new Date(dttm); 20 | const dttm_date = DATE_FORMAT.format(date); 21 | const timeFormat = getTimeFormat(twentyFourHourTime); 22 | const dttm_time = timeFormat.format(date); 23 | const divider = br ? "
" : ", "; 24 | return dttm_date + divider + dttm_time; 25 | }; 26 | 27 | export const getTimestamp = function () { 28 | return Math.floor(Date.now() / 1000); 29 | }; 30 | var foo = 1; 31 | 32 | export default { 33 | DATE_FORMAT, 34 | NUMBER_FORMAT, 35 | getDateTime, 36 | getTimeFormat, 37 | getTimestamp, 38 | }; 39 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import "@mdi/font/css/materialdesignicons.css"; 2 | import "vuetify/styles"; // Global CSS has to be imported 3 | 4 | import { createHead, VueHeadMixin } from "@unhead/vue/client"; 5 | import { createApp } from "vue"; 6 | import VueDragScroller from "vue-drag-scroller"; 7 | 8 | import App from "@/app.vue"; 9 | import router from "@/plugins/router"; 10 | import { setupNativeSock } from "@/plugins/vue-native-sock"; 11 | import vuetify from "@/plugins/vuetify"; 12 | import { setupStore } from "@/stores/store"; 13 | 14 | const app = createApp(App); 15 | 16 | app.use(vuetify); 17 | setupStore(app); 18 | setupNativeSock(app); 19 | app.use(router); 20 | app.use(createHead()); 21 | app.mixin(VueHeadMixin); 22 | app.use(VueDragScroller); 23 | 24 | app.config.performance = import.meta.env.PROD; 25 | 26 | router 27 | .isReady() 28 | .then(() => { 29 | return app.mount("#App"); 30 | }) 31 | // Top level await would require a plugin 32 | 33 | .catch(console.error); 34 | 35 | export default app; 36 | -------------------------------------------------------------------------------- /frontend/src/platform.js: -------------------------------------------------------------------------------- 1 | // Identify platforms for special behaviors 2 | 3 | const _IS_MOBILE_RE = /iP(?:ad|hone|od)|Android/; // codespell:ignore od 4 | 5 | const _IS_MOBILE_UA = _IS_MOBILE_RE.test(navigator.userAgent); 6 | 7 | export const IS_MOBILE = _IS_MOBILE_UA || globalThis.orientation !== undefined; 8 | 9 | /* 10 | *export const IS_TOUCH = 11 | * "ontouchstart" in window || 12 | * navigator.maxTouchPoints > 0 || 13 | * navigator.msMaxTouchPoints > 0 || 14 | * window.matchMedia("(any-hover: none)").matches; 15 | */ 16 | -------------------------------------------------------------------------------- /frontend/src/plugins/vue-native-sock.js: -------------------------------------------------------------------------------- 1 | import VueNativeSock from "vue-native-websocket-vue3"; 2 | 3 | import { SOCKET_URL } from "@/api/v3/notify"; 4 | import { useSocketStoreWithOut } from "@/stores/socket"; 5 | 6 | export function setupNativeSock(app) { 7 | const store = useSocketStoreWithOut(); 8 | store.app = app; 9 | 10 | const SOCKET_OPTIONS = { 11 | store, 12 | reconnection: true, 13 | }; 14 | 15 | app.use(VueNativeSock, SOCKET_URL, SOCKET_OPTIONS); 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/route.js: -------------------------------------------------------------------------------- 1 | const REVERSE_READING_DIRECTIONS = new Set("rtl", "btt"); 2 | Object.freeze(REVERSE_READING_DIRECTIONS); 3 | export const getReaderRoute = ( 4 | { ids, page, readingDirection, pageCount }, 5 | importMetadata, 6 | ) => { 7 | // Get the route to a comic with the correct entry page. 8 | if (ids.length === 0 || (importMetadata && !pageCount)) { 9 | return ""; 10 | } 11 | const pk = ids[0]; 12 | if (page) { 13 | page = Number(page); 14 | } else if (REVERSE_READING_DIRECTIONS.has(readingDirection)) { 15 | const maxPage = Number(pageCount) - 1; 16 | page = Math.max(maxPage, 0); 17 | } else { 18 | page = 0; 19 | } 20 | return { 21 | name: "reader", 22 | params: { pk, page }, 23 | }; 24 | }; 25 | 26 | export default { 27 | getReaderRoute, 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/src/stores/store.js: -------------------------------------------------------------------------------- 1 | import { createPinia } from "pinia"; 2 | const store = createPinia(); 3 | 4 | export function setupStore(app) { 5 | app.use(store); 6 | } 7 | 8 | export { store }; 9 | -------------------------------------------------------------------------------- /frontend/src/util.js: -------------------------------------------------------------------------------- 1 | // Utility functions 2 | export const range = (start, end = 0) => { 3 | start = Number.isInteger(start) && start >= 0 ? start : 0; 4 | end = Number.isInteger(end) && end >= 0 ? end : 0; 5 | const length = end > start ? Math.max(end - start, 0) : start; 6 | let result = [...Array.from({ length }).keys()]; 7 | if (end > 0) { 8 | result = result.map((i) => i + start); 9 | } 10 | return result; 11 | }; 12 | -------------------------------------------------------------------------------- /mock_comics/__init__.py: -------------------------------------------------------------------------------- 1 | """Create mock comics to test with.""" 2 | -------------------------------------------------------------------------------- /mock_comics/mock_comics.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # create a mock comics 3 | # mock_comics.sh 4 | set -euo pipefail 5 | poetry run ./mock_comics.py "$@" 6 | -------------------------------------------------------------------------------- /strange.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajslater/codex/f34aa9ff4e86592dae729ac841f872fa860ad9df/strange.jpg -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Codex Tests 2 | 3 | Codex is light on tests right now 4 | 5 | Frontend tests live in /frontend/tests/ 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for codex.""" 2 | -------------------------------------------------------------------------------- /tests/nginx-local-codex.conf: -------------------------------------------------------------------------------- 1 | upstream host_service { 2 | server localhost:9810; 3 | } 4 | 5 | server { 6 | listen 80; 7 | charset utf-8; 8 | add_header X-Frame-Options SAMEORIGIN; 9 | access_log /dev/stdout; 10 | error_log /dev/stderr; 11 | # proxies 12 | # Docs for using variables to force name re-resolution when upstream containers are re-created. 13 | # https://tenzer.dk/nginx-with-dynamic-upstreams/ 14 | # proxy_buffering off; 15 | # proxy_buffers 8 64k; 16 | proxy_set_header Host $http_host; 17 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 18 | proxy_set_header X-Forwarded-Host $server_name; 19 | proxy_set_header X-Forwarded-Port $server_port; 20 | proxy_set_header X-Forwarded-Proto $scheme; 21 | # proxy_set_header X-Forwarded-Ssl on; 22 | proxy_set_header X-Real-IP $remote_addr; 23 | proxy_set_header X-Scheme $scheme; 24 | # WS 25 | proxy_http_version 1.1; 26 | proxy_set_header Upgrade $http_upgrade; 27 | proxy_set_header Connection "Upgrade"; 28 | gzip_comp_level 6; 29 | gzip_proxied any; 30 | gzip_types text/plain text/css text/js text/xml text/javascript 31 | application/javascript application/json application/xml image/svg+xml; 32 | set $codex_upstream http://host_service; 33 | 34 | location /codex { 35 | proxy_pass $codex_upstream; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/test_asgi.py: -------------------------------------------------------------------------------- 1 | """Test the asgi server.""" 2 | 3 | from django.test import TestCase 4 | 5 | from codex.asgi import application 6 | 7 | 8 | class EnvironTestCase(TestCase): 9 | """Test environment variables.""" 10 | 11 | def receive(self): 12 | """Do nothing.""" 13 | 14 | def send(self): 15 | """Do nothing.""" 16 | 17 | async def test_application(self): 18 | """Don't even test application, yet.""" 19 | assert application 20 | --------------------------------------------------------------------------------